1// Package cli provides CLI commands using Bubble Tea TUI.
2package cli
3
4import (
5 "context"
6 "database/sql"
7 "fmt"
8 "os"
9 "path/filepath"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/cli/styles"
14 "github.com/bnema/dumber/internal/domain/build"
15 "github.com/bnema/dumber/internal/domain/repository"
16 "github.com/bnema/dumber/internal/infrastructure/config"
17 "github.com/bnema/dumber/internal/infrastructure/favicon"
18 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
19 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
20 "github.com/bnema/dumber/internal/logging"
21)
22
23// App holds CLI dependencies.
24type App struct {
25 Config *config.Config
26 Theme *styles.Theme
27 BuildInfo build.Info
28 db *sql.DB
29 History repository.HistoryRepository
30
31 // Use cases
32 SearchHistoryUC *usecase.SearchHistoryUseCase
33 SessionUC *usecase.ManageSessionUseCase
34 ListSessionsUC *usecase.ListSessionsUseCase
35 RestoreUC *usecase.RestoreSessionUseCase
36 DeleteSessionUC *usecase.DeleteSessionUseCase
37
38 // Services
39 FaviconService *favicon.Service
40
41 // Context with logger
42 ctx context.Context
43 logCleanup func()
44}
45
46// NewApp creates a new CLI application with all dependencies.
47func NewApp() (*App, error) {
48 const dataDirPerm = 0o755
49 var err error
50 // Load config
51 cfg := loadConfig()
52
53 // Create theme from config
54 theme := styles.NewTheme(cfg)
55
56 // Start with a quiet logger. If a browser session is active, we'll attach to it.
57 logLevel := cfg.Logging.Level
58 if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" {
59 logLevel = envLevel
60 }
61
62 logger, logCleanup, _ := logging.NewWithFile(
63 logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"},
64 logging.FileConfig{Enabled: false, WriteToStderr: false},
65 )
66 ctx := logging.WithContext(context.Background(), logger)
67
68 // Determine database path - use share directory per XDG spec (user data)
69 home, _ := os.UserHomeDir()
70 dbFile := cfg.Database.Path
71 if dbFile == "" {
72 dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
73 }
74 dbPath := filepath.Dir(dbFile)
75
76 // Ensure data directory exists
77 if err = os.MkdirAll(dbPath, dataDirPerm); err != nil {
78 return nil, fmt.Errorf("create data dir: %w", err)
79 }
80
81 // Open database connection
82 db, err := sqlite.NewConnection(ctx, dbFile)
83 if err != nil {
84 return nil, fmt.Errorf("open database: %w", err)
85 }
86
87 // Attach CLI logs to the active browser session (if any).
88 // This avoids creating extra tiny session log files for each CLI invocation.
89 sessionRepo := sqlite.NewSessionRepository(db)
90 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
91 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
92 if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil {
93 attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{
94 Level: logLevel,
95 Format: cfg.Logging.Format,
96 TimeFormat: "15:04:05",
97 LogDir: cfg.Logging.LogDir,
98 WriteToStderr: false,
99 EnableFileLog: cfg.Logging.EnableFileLog,
100 })
101 logger = attachedLogger
102 logCleanup = attachedCleanup
103 ctx = logging.WithContext(context.Background(), logger)
104 logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session")
105 }
106
107 logger.Debug().Str("db_path", dbFile).Msg("database connected")
108
109 // Create repositories
110 historyRepo := sqlite.NewHistoryRepository(db)
111 sessionStateRepo := sqlite.NewSessionStateRepository(db)
112
113 // Create use cases
114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
115 listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)
116 restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
117 deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir)
118
119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
120 faviconCacheDir, _ := config.GetFaviconCacheDir()
121 faviconService := favicon.NewService(faviconCacheDir)
122
123 return &App{
124 Config: cfg,
125 Theme: theme,
126 db: db,
127 History: historyRepo,
128 SearchHistoryUC: searchHistoryUC,
129 SessionUC: sessionUC,
130 ListSessionsUC: listSessionsUC,
131 RestoreUC: restoreUC,
132 DeleteSessionUC: deleteSessionUC,
133 FaviconService: faviconService,
134 ctx: ctx,
135 logCleanup: logCleanup,
136 }, nil
137}
138
139// Close releases all resources.
140func (a *App) Close() error {
141 if a.logCleanup != nil {
142 a.logCleanup()
143 }
144 if a.FaviconService != nil {
145 a.FaviconService.Close()
146 }
147 if a.db != nil {
148 return a.db.Close()
149 }
150 return nil
151}
152
153// Ctx returns the application context with logger.
154func (a *App) Ctx() context.Context {
155 return a.ctx
156}
157
158// loadConfig loads configuration from standard locations.
159func loadConfig() *config.Config {
160 mgr, err := config.NewManager()
161 if err != nil {
162 // Return default config if manager fails
163 return config.DefaultConfig()
164 }
165
166 if err := mgr.Load(); err != nil {
167 // Return default config if loading fails
168 return config.DefaultConfig()
169 }
170
171 return mgr.Get()
172}