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