1package cli
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/bnema/dumber/internal/config"
14 "github.com/spf13/cobra"
15)
16
17// NewLogsCmd creates the logs command and its subcommands
18func NewLogsCmd() *cobra.Command {
19 cmd := &cobra.Command{
20 Use: "logs",
21 Short: "View and manage application logs",
22 Long: "Access logs of the aplication, including viewing recent entries, listing log files, cleaning old logs, and showing the log directory path.",
23 }
24
25 cmd.AddCommand(newLogsTailCmd())
26 cmd.AddCommand(newLogsListCmd())
27 cmd.AddCommand(newLogsCleanCmd())
28 cmd.AddCommand(newLogsPathCmd())
29 cmd.AddCommand(newLogsConsoleCmd())
30
31 return cmd
32}
33
34// newLogsTailCmd creates the tail subcommand
35func newLogsTailCmd() *cobra.Command {
36 var lines int
37
38 cmd := &cobra.Command{
39 Use: "tail [lines]",
40 Short: "Show last N lines of current log",
41 Long: "Display the last N lines from the current log file. Defaults to 50 lines.",
42 Args: cobra.MaximumNArgs(1),
43 RunE: func(cmd *cobra.Command, args []string) error {
44 // Parse lines argument if provided
45 if len(args) > 0 {
46 var err error
47 lines, err = strconv.Atoi(args[0])
48 if err != nil {
49 return fmt.Errorf("invalid number of lines: %s", args[0])
50 }
51 }
52
53 logDir, err := config.GetLogDir()
54 if err != nil {
55 return fmt.Errorf("failed to get log directory: %w", err)
56 }
57
58 currentLog := filepath.Join(logDir, "dumber.log")
59 return tailFile(currentLog, lines)
60 },
61 }
62
63 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
64 return cmd
65}
66
67// newLogsListCmd creates the list subcommand
68func newLogsListCmd() *cobra.Command {
69 cmd := &cobra.Command{
70 Use: "list",
71 Short: "List all available log files",
72 Long: "Display all log files in the log directory with their sizes and modification times.",
73 RunE: func(cmd *cobra.Command, args []string) error {
74 logDir, err := config.GetLogDir()
75 if err != nil {
76 return fmt.Errorf("failed to get log directory: %w", err)
77 }
78
79 return listLogFiles(logDir)
80 },
81 }
82
83 return cmd
84}
85
86// newLogsCleanCmd creates the clean subcommand
87func newLogsCleanCmd() *cobra.Command {
88 var dryRun bool
89 var maxAge int
90
91 cmd := &cobra.Command{
92 Use: "clean",
93 Short: "Remove old log files",
94 Long: "Remove log files older than the specified age in days. Use --dry-run to see what would be deleted.",
95 RunE: func(cmd *cobra.Command, args []string) error {
96 logDir, err := config.GetLogDir()
97 if err != nil {
98 return fmt.Errorf("failed to get log directory: %w", err)
99 }
100
101 return cleanOldLogs(logDir, maxAge, dryRun)
102 },
103 }
104
105 cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting")
106 cmd.Flags().IntVar(&maxAge, "max-age", 30, "Maximum age of log files in days")
107 return cmd
108}
109
110// newLogsPathCmd creates the path subcommand
111func newLogsPathCmd() *cobra.Command {
112 cmd := &cobra.Command{
113 Use: "path",
114 Short: "Show the path to the log directory",
115 Long: "Display the XDG-compliant path where logs are stored.",
116 RunE: func(cmd *cobra.Command, args []string) error {
117 logDir, err := config.GetLogDir()
118 if err != nil {
119 return fmt.Errorf("failed to get log directory: %w", err)
120 }
121
122 fmt.Println(logDir)
123 return nil
124 },
125 }
126
127 return cmd
128}
129
130// newLogsConsoleCmd creates the console subcommand for filtering console logs
131func newLogsConsoleCmd() *cobra.Command {
132 var lines int
133 var level string
134
135 cmd := &cobra.Command{
136 Use: "console",
137 Short: "View console logs from WebKit",
138 Long: "Display console.log, console.error, and other console messages from web pages.",
139 RunE: func(cmd *cobra.Command, args []string) error {
140 logDir, err := config.GetLogDir()
141 if err != nil {
142 return fmt.Errorf("failed to get log directory: %w", err)
143 }
144
145 currentLog := filepath.Join(logDir, "dumber.log")
146 return tailConsoleMessages(currentLog, lines, level)
147 },
148 }
149
150 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
151 cmd.Flags().StringVarP(&level, "level", "l", "", "Filter by console level (error, warn, info, log, debug)")
152
153 return cmd
154}
155
156// tailFile displays the last N lines of a file
157func tailFile(filePath string, lines int) error {
158 file, err := os.Open(filePath)
159 if err != nil {
160 if os.IsNotExist(err) {
161 fmt.Printf("Log file does not exist: %s\n", filePath)
162 return nil
163 }
164 return fmt.Errorf("failed to open log file: %w", err)
165 }
166 defer func() {
167 if err := file.Close(); err != nil {
168 fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
169 }
170 }()
171
172 // Read all lines
173 var allLines []string
174 scanner := bufio.NewScanner(file)
175 for scanner.Scan() {
176 allLines = append(allLines, scanner.Text())
177 }
178
179 if err := scanner.Err(); err != nil {
180 return fmt.Errorf("error reading log file: %w", err)
181 }
182
183 // Display last N lines
184 start := len(allLines) - lines
185 if start < 0 {
186 start = 0
187 }
188
189 for i := start; i < len(allLines); i++ {
190 fmt.Println(allLines[i])
191 }
192
193 return nil
194}
195
196// listLogFiles displays all log files with their information
197func listLogFiles(logDir string) error {
198 if _, err := os.Stat(logDir); os.IsNotExist(err) {
199 fmt.Printf("Log directory does not exist: %s\n", logDir)
200 return nil
201 }
202
203 files, err := os.ReadDir(logDir)
204 if err != nil {
205 return fmt.Errorf("failed to read log directory: %w", err)
206 }
207
208 // Filter and collect log files
209 var logFiles []os.FileInfo
210 for _, file := range files {
211 if file.IsDir() {
212 continue
213 }
214
215 name := file.Name()
216 if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
217 info, err := file.Info()
218 if err != nil {
219 continue
220 }
221 logFiles = append(logFiles, info)
222 }
223 }
224
225 if len(logFiles) == 0 {
226 fmt.Println("No log files found")
227 return nil
228 }
229
230 // Sort by modification time (newest first)
231 sort.Slice(logFiles, func(i, j int) bool {
232 return logFiles[i].ModTime().After(logFiles[j].ModTime())
233 })
234
235 // Display files
236 fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
237 fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
238
239 for _, file := range logFiles {
240 size := formatSize(file.Size())
241 modTime := file.ModTime().Format("2006-01-02 15:04:05")
242 fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size)
243 }
244
245 return nil
246}
247
248// cleanOldLogs removes log files older than maxAge days
249func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
250 if _, err := os.Stat(logDir); os.IsNotExist(err) {
251 fmt.Printf("Log directory does not exist: %s\n", logDir)
252 return nil
253 }
254
255 files, err := os.ReadDir(logDir)
256 if err != nil {
257 return fmt.Errorf("failed to read log directory: %w", err)
258 }
259
260 cutoffTime := time.Now().AddDate(0, 0, -maxAge)
261 var deletedCount int
262 var deletedSize int64
263
264 for _, file := range files {
265 if file.IsDir() {
266 continue
267 }
268
269 name := file.Name()
270 // Skip the current log file
271 if name == "dumber.log" {
272 continue
273 }
274
275 // Only process log files
276 if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") {
277 continue
278 }
279
280 info, err := file.Info()
281 if err != nil {
282 continue
283 }
284
285 if info.ModTime().Before(cutoffTime) {
286 filePath := filepath.Join(logDir, name)
287
288 if dryRun {
289 fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
290 name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
291 } else {
292 err := os.Remove(filePath)
293 if err != nil {
294 fmt.Printf("Warning: failed to delete %s: %v\n", name, err)
295 } else {
296 fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
297 }
298 }
299
300 deletedCount++
301 deletedSize += info.Size()
302 }
303 }
304
305 if dryRun {
306 fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
307 deletedCount, float64(deletedSize)/1024)
308 } else {
309 fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
310 deletedCount, float64(deletedSize)/1024)
311 }
312
313 return nil
314}
315
316// formatSize formats file size in human-readable format
317func formatSize(size int64) string {
318 const unit = 1024
319 if size < unit {
320 return fmt.Sprintf("%d B", size)
321 }
322
323 div, exp := int64(unit), 0
324 for n := size / unit; n >= unit; n /= unit {
325 div *= unit
326 exp++
327 }
328
329 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
330}
331
332// tailConsoleMessages displays console messages from the log file
333func tailConsoleMessages(filePath string, lines int, level string) error {
334 file, err := os.Open(filePath)
335 if err != nil {
336 if os.IsNotExist(err) {
337 fmt.Printf("Log file does not exist: %s\n", filePath)
338 return nil
339 }
340 return fmt.Errorf("failed to open log file: %w", err)
341 }
342 defer func() {
343 if err := file.Close(); err != nil {
344 fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err)
345 }
346 }()
347
348 // Read all lines and filter for console messages
349 var consoleLines []string
350 scanner := bufio.NewScanner(file)
351 for scanner.Scan() {
352 line := scanner.Text()
353
354 // Filter for [CONSOLE] tagged messages
355 if !strings.Contains(line, "[CONSOLE]") {
356 continue
357 }
358
359 // Filter by level if specified
360 if level != "" {
361 levelFilter := fmt.Sprintf("[%s]", strings.ToUpper(level))
362 if !strings.Contains(line, levelFilter) {
363 continue
364 }
365 }
366
367 consoleLines = append(consoleLines, line)
368 }
369
370 if err := scanner.Err(); err != nil {
371 return fmt.Errorf("error reading log file: %w", err)
372 }
373
374 if len(consoleLines) == 0 {
375 if level != "" {
376 fmt.Printf("No console messages found with level '%s'\n", level)
377 } else {
378 fmt.Println("No console messages found")
379 }
380 return nil
381 }
382
383 // Display last N lines
384 start := len(consoleLines) - lines
385 if start < 0 {
386 start = 0
387 }
388
389 for i := start; i < len(consoleLines); i++ {
390 fmt.Println(consoleLines[i])
391 }
392
393 return nil
394}