1package cmd
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13 "time"
14
15 "github.com/spf13/cobra"
16
17 "github.com/bnema/dumber/internal/cli/styles"
18 "github.com/bnema/dumber/internal/domain/entity"
19 "github.com/bnema/dumber/internal/infrastructure/config"
20 "github.com/bnema/dumber/internal/logging"
21)
22
23var (
24 logsFollow bool
25 logsLines int
26 logsClearAll bool
27)
28
29const (
30 defaultLogsLines = 50
31 recentSessionsLimit = 200
32)
33
34var logsCmd = &cobra.Command{
35 Use: "logs [session]",
36 Short: "View application logs",
37 Long: `View dumber application logs by session.
38
39Without arguments, lists all available sessions.
40With a session ID (or partial match), shows logs for that session.
41
42Examples:
43 dumber logs # List all sessions
44 dumber logs a7b3 # View logs for session ending in 'a7b3'
45 dumber logs -f a7b3 # Follow logs in real-time
46 dumber logs -n 100 a7b3 # Show last 100 lines`,
47 RunE: runLogs,
48}
49
50func init() {
51 rootCmd.AddCommand(logsCmd)
52
53 logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time")
54 logsCmd.Flags().IntVarP(&logsLines, "lines", "n", defaultLogsLines, "number of lines to show")
55}
56
57// SessionInfo holds metadata about a log session.
58type SessionInfo struct {
59 SessionID string
60 ShortID string
61 Filename string
62 Path string
63 Size int64
64 ModTime time.Time
65
66 // DB metadata (optional)
67 StartedAt time.Time
68 EndedAt *time.Time
69 Type string
70 FromDB bool
71}
72
73type sessionManager interface {
74 GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error)
75}
76
77func runLogs(_ *cobra.Command, args []string) error {
78 app := GetApp()
79 if app == nil {
80 return fmt.Errorf("app not initialized")
81 }
82
83 ctx := app.Ctx()
84 logDir := getLogDir()
85
86 // List sessions if no argument provided
87 if len(args) == 0 {
88 return listSessions(ctx, app.SessionUC, logDir, app.Theme)
89 }
90
91 // Find session by partial match
92 sessionQuery := args[0]
93 session, err := findSession(ctx, app.SessionUC, logDir, sessionQuery)
94 if err != nil {
95 return err
96 }
97
98 if _, err := os.Stat(session.Path); err != nil {
99 if os.IsNotExist(err) {
100 return fmt.Errorf("log file for session '%s' not found at %s", session.ShortID, session.Path)
101 }
102 return fmt.Errorf("stat log file: %w", err)
103 }
104
105 if logsFollow {
106 return tailSession(session.Path, app.Theme)
107 }
108
109 return showSession(session.Path, logsLines, app.Theme)
110}
111
112// getLogDir returns the log directory path.
113func getLogDir() string {
114 logDir, err := config.GetLogDir()
115 if err != nil {
116 // Fallback to XDG default
117 stateDir := os.Getenv("XDG_STATE_HOME")
118 if stateDir == "" {
119 home, _ := os.UserHomeDir()
120 stateDir = filepath.Join(home, ".local", "state")
121 }
122 return filepath.Join(stateDir, "dumber", "logs")
123 }
124 return logDir
125}
126
127// listSessions displays all available log sessions.
128func listSessions(ctx context.Context, sessionMgr sessionManager, logDir string, theme *styles.Theme) error {
129 sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
130 if err != nil {
131 return err
132 }
133
134 if len(sessions) == 0 {
135 fmt.Println(theme.Subtle.Render("No sessions found. Run 'dumber browse' to create logs."))
136 return nil
137 }
138
139 // Header
140 fmt.Println(theme.Title.Render("Sessions (newest first):"))
141 fmt.Println()
142
143 // Table format: ShortID | DateTime | Status | Size
144 for i := range sessions {
145 s := &sessions[i]
146 timeStr := sessionSortTime(*s).Format("2006-01-02 15:04:05")
147
148 status := ""
149 if s.FromDB {
150 if s.EndedAt == nil {
151 status = theme.SuccessStyle.Render("active")
152 } else {
153 status = theme.Subtle.Render("ended")
154 }
155 }
156
157 sizeStr := "?"
158 if s.Size > 0 {
159 sizeStr = formatSize(s.Size)
160 }
161
162 line := fmt.Sprintf(" %s %s %s %s",
163 theme.Highlight.Render(s.ShortID),
164 theme.Subtle.Render(timeStr),
165 status,
166 theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)),
167 )
168 fmt.Println(line)
169 }
170
171 fmt.Println()
172 fmt.Println(theme.Subtle.Render("Use 'dumber logs <id>' to view a session"))
173 return nil
174}
175
176// getSessions returns all session log files, sorted by modification time (newest first).
177func getSessions(logDir string) ([]SessionInfo, error) {
178 entries, err := os.ReadDir(logDir)
179 if err != nil {
180 if os.IsNotExist(err) {
181 return nil, nil
182 }
183 return nil, fmt.Errorf("read log directory: %w", err)
184 }
185
186 var sessions []SessionInfo
187 for _, entry := range entries {
188 if entry.IsDir() {
189 continue
190 }
191
192 sessionID, ok := logging.ParseSessionFilename(entry.Name())
193 if !ok {
194 continue
195 }
196
197 info, err := entry.Info()
198 if err != nil {
199 continue
200 }
201
202 sessions = append(sessions, SessionInfo{
203 SessionID: sessionID,
204 ShortID: logging.ShortSessionID(sessionID),
205 Filename: entry.Name(),
206 Path: filepath.Join(logDir, entry.Name()),
207 Size: info.Size(),
208 ModTime: info.ModTime(),
209 })
210 }
211
212 // Sort by modification time (newest first)
213 sort.Slice(sessions, func(i, j int) bool {
214 return sessions[i].ModTime.After(sessions[j].ModTime)
215 })
216
217 return sessions, nil
218}
219
220func getSessionsMerged(ctx context.Context, sessionMgr sessionManager, logDir string) ([]SessionInfo, error) {
221 fsSessions, err := getSessions(logDir)
222 if err != nil {
223 return nil, err
224 }
225 if sessionMgr == nil {
226 return fsSessions, nil
227 }
228
229 dbSessions, err := sessionMgr.GetRecentSessions(ctx, recentSessionsLimit)
230 if err != nil {
231 if isMissingSessionsTable(err) {
232 return fsSessions, nil
233 }
234 return nil, err
235 }
236
237 byID := make(map[string]SessionInfo, len(dbSessions))
238 merged := make([]SessionInfo, 0, len(dbSessions)+len(fsSessions))
239 for _, s := range dbSessions {
240 if s == nil {
241 continue
242 }
243 filename := logging.SessionFilename(string(s.ID))
244 path := filepath.Join(logDir, filename)
245 info := SessionInfo{
246 SessionID: string(s.ID),
247 ShortID: s.ShortID(),
248 Filename: filename,
249 Path: path,
250 StartedAt: s.StartedAt,
251 EndedAt: s.EndedAt,
252 Type: string(s.Type),
253 FromDB: true,
254 }
255 if stat, statErr := os.Stat(path); statErr == nil {
256 info.Size = stat.Size()
257 info.ModTime = stat.ModTime()
258 }
259 byID[info.SessionID] = info
260 merged = append(merged, info)
261 }
262
263 for i := range fsSessions {
264 if _, ok := byID[fsSessions[i].SessionID]; ok {
265 continue
266 }
267 merged = append(merged, fsSessions[i])
268 }
269
270 sort.Slice(merged, func(i, j int) bool {
271 return sessionSortTime(merged[i]).After(sessionSortTime(merged[j]))
272 })
273 return merged, nil
274}
275
276func sessionSortTime(s SessionInfo) time.Time {
277 if !s.StartedAt.IsZero() {
278 return s.StartedAt
279 }
280 return s.ModTime
281}
282
283func isMissingSessionsTable(err error) bool {
284 if err == nil {
285 return false
286 }
287 // sqlite returns e.g. "no such table: sessions" before migrations run.
288 return strings.Contains(err.Error(), "no such table: sessions")
289}
290
291// findSession finds a session by partial ID match.
292func findSession(ctx context.Context, sessionMgr sessionManager, logDir, query string) (*SessionInfo, error) {
293 sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
294 if err != nil {
295 return nil, err
296 }
297
298 if len(sessions) == 0 {
299 return nil, fmt.Errorf("no sessions found")
300 }
301
302 queryNormalized := strings.ToLower(strings.TrimSpace(query))
303
304 // Try exact short ID match first.
305 // Some entries may not have ShortID populated; derive it from SessionID.
306 for i := range sessions {
307 shortID := sessions[i].ShortID
308 if shortID == "" {
309 shortID = logging.ShortSessionID(sessions[i].SessionID)
310 }
311 if strings.EqualFold(shortID, queryNormalized) {
312 return &sessions[i], nil
313 }
314 }
315
316 // Try partial match on full session ID
317 var matches []SessionInfo
318 for i := range sessions {
319 if strings.Contains(strings.ToLower(sessions[i].SessionID), queryNormalized) {
320 matches = append(matches, sessions[i])
321 }
322 }
323
324 switch len(matches) {
325 case 0:
326 return nil, fmt.Errorf("no session matching '%s' found", query)
327 case 1:
328 return &matches[0], nil
329 default:
330 // Multiple matches - show them and ask user to be more specific
331 var ids []string
332 for i := range matches {
333 ids = append(ids, matches[i].ShortID)
334 }
335 return nil, fmt.Errorf("multiple sessions match '%s': %s", query, strings.Join(ids, ", "))
336 }
337}
338
339// showSession displays the last N lines of a session log.
340func showSession(logPath string, lines int, theme *styles.Theme) (retErr error) {
341 file, err := os.Open(logPath)
342 if err != nil {
343 return fmt.Errorf("open log file: %w", err)
344 }
345 defer func() {
346 if closeErr := file.Close(); closeErr != nil && retErr == nil {
347 retErr = fmt.Errorf("close log file: %w", closeErr)
348 }
349 }()
350
351 // Read all lines
352 var allLines []string
353 scanner := bufio.NewScanner(file)
354 for scanner.Scan() {
355 allLines = append(allLines, scanner.Text())
356 }
357
358 if err := scanner.Err(); err != nil {
359 return fmt.Errorf("read log file: %w", err)
360 }
361
362 // Get last N lines
363 start := 0
364 if len(allLines) > lines {
365 start = len(allLines) - lines
366 }
367
368 for _, line := range allLines[start:] {
369 fmt.Println(colorizeLogLine(line, theme))
370 }
371
372 return nil
373}
374
375// tailSession follows a session log in real-time.
376func tailSession(logPath string, theme *styles.Theme) error {
377 file, err := os.Open(logPath)
378 if err != nil {
379 return fmt.Errorf("open log file: %w", err)
380 }
381 defer func() { _ = file.Close() }()
382
383 // Seek to end
384 _, _ = file.Seek(0, io.SeekEnd)
385
386 fmt.Println(theme.Subtle.Render("Following logs... (Ctrl+C to stop)"))
387 fmt.Println()
388
389 reader := bufio.NewReader(file)
390 pending := ""
391 for {
392 chunk, err := reader.ReadString('\n')
393 if err != nil {
394 if err == io.EOF {
395 // No full line yet; keep partial data.
396 pending += chunk
397 time.Sleep(100 * time.Millisecond)
398 continue
399 }
400 return fmt.Errorf("read log file: %w", err)
401 }
402
403 pending += chunk
404 for {
405 idx := strings.IndexByte(pending, '\n')
406 if idx == -1 {
407 break
408 }
409 line := pending[:idx]
410 pending = pending[idx+1:]
411 fmt.Println(colorizeLogLine(line, theme))
412 }
413 }
414}
415
416// logEntry represents a parsed JSON log entry.
417type logEntry struct {
418 Level string `json:"level"`
419 Time string `json:"time"`
420 Message string `json:"message"`
421 Session string `json:"session"`
422}
423
424// colorizeLogLine adds color based on log level.
425func colorizeLogLine(line string, theme *styles.Theme) string {
426 // Try to parse as JSON
427 var entry logEntry
428 if err := json.Unmarshal([]byte(line), &entry); err == nil {
429 return formatJSONLogLine(entry, theme)
430 }
431
432 // Fallback to pattern matching for non-JSON logs
433 switch {
434 case containsAny(line, "ERR", "ERROR", "error"):
435 return theme.ErrorStyle.Render(line)
436 case containsAny(line, "WRN", "WARN", "warn"):
437 return theme.WarningStyle.Render(line)
438 case containsAny(line, "DBG", "DEBUG", "debug"):
439 return theme.Subtle.Render(line)
440 default:
441 return line
442 }
443}
444
445// formatJSONLogLine formats a parsed JSON log entry with colors.
446func formatJSONLogLine(entry logEntry, theme *styles.Theme) string {
447 // Parse time if present
448 timeStr := ""
449 if entry.Time != "" {
450 if t, err := time.Parse(time.RFC3339, entry.Time); err == nil {
451 timeStr = t.Format("15:04:05")
452 } else {
453 timeStr = entry.Time
454 }
455 }
456
457 // Level styling
458 var levelStr string
459 switch entry.Level {
460 case "error":
461 levelStr = theme.ErrorStyle.Render("ERR")
462 case "warn":
463 levelStr = theme.WarningStyle.Render("WRN")
464 case "info":
465 levelStr = theme.Highlight.Render("INF")
466 case "debug":
467 levelStr = theme.Subtle.Render("DBG")
468 case "trace":
469 levelStr = theme.Subtle.Render("TRC")
470 default:
471 levelStr = entry.Level
472 }
473
474 return fmt.Sprintf("%s %s %s", theme.Subtle.Render(timeStr), levelStr, entry.Message)
475}
476
477// containsAny checks if s contains any of the substrings.
478func containsAny(s string, substrs ...string) bool {
479 sLower := strings.ToLower(s)
480 for _, substr := range substrs {
481 if strings.Contains(sLower, strings.ToLower(substr)) {
482 return true
483 }
484 }
485 return false
486}
487
488// logsClearCmd clears old session logs.
489var logsClearCmd = &cobra.Command{
490 Use: "clear",
491 Short: "Clear old log files",
492 Long: `Remove old session log files.
493
494By default, removes sessions older than the configured MaxAge (default 7 days).
495Use --all to remove all sessions.`,
496 RunE: runLogsClear,
497}
498
499func init() {
500 logsCmd.AddCommand(logsClearCmd)
501 logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs")
502}
503
504func runLogsClear(_ *cobra.Command, _ []string) error {
505 app := GetApp()
506 if app == nil {
507 return fmt.Errorf("app not initialized")
508 }
509
510 logDir := getLogDir()
511 sessions, err := getSessions(logDir)
512 if err != nil {
513 return err
514 }
515
516 if len(sessions) == 0 {
517 fmt.Println(app.Theme.Subtle.Render("No logs to clear"))
518 return nil
519 }
520
521 // Get MaxAge from config (default 7 days)
522 maxAge := 7
523 if app.Config != nil && app.Config.Logging.MaxAge > 0 {
524 maxAge = app.Config.Logging.MaxAge
525 }
526
527 cutoff := time.Now().AddDate(0, 0, -maxAge)
528 var removed int
529
530 for i := range sessions {
531 s := &sessions[i]
532 shouldRemove := logsClearAll || s.ModTime.Before(cutoff)
533 if !shouldRemove {
534 continue
535 }
536
537 if err := os.Remove(s.Path); err != nil {
538 fmt.Printf("%s %s: %v\n",
539 app.Theme.ErrorStyle.Render("\u2717"),
540 s.ShortID,
541 err,
542 )
543 continue
544 }
545
546 fmt.Printf("%s %s (%s)\n",
547 app.Theme.SuccessStyle.Render("\u2713"),
548 s.ShortID,
549 formatSize(s.Size),
550 )
551 removed++
552 }
553
554 if removed == 0 {
555 fmt.Println(app.Theme.Subtle.Render("No sessions older than " + fmt.Sprintf("%d days", maxAge)))
556 } else {
557 fmt.Printf("\n%s\n", app.Theme.SuccessStyle.Render(fmt.Sprintf("Cleared %d session(s)", removed)))
558 }
559
560 return nil
561}