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 file.Close()
167
168 // Read all lines
169 var allLines []string
170 scanner := bufio.NewScanner(file)
171 for scanner.Scan() {
172 allLines = append(allLines, scanner.Text())
173 }
174
175 if err := scanner.Err(); err != nil {
176 return fmt.Errorf("error reading log file: %w", err)
177 }
178
179 // Display last N lines
180 start := len(allLines) - lines
181 if start < 0 {
182 start = 0
183 }
184
185 for i := start; i < len(allLines); i++ {
186 fmt.Println(allLines[i])
187 }
188
189 return nil
190}
191
192// listLogFiles displays all log files with their information
193func listLogFiles(logDir string) error {
194 if _, err := os.Stat(logDir); os.IsNotExist(err) {
195 fmt.Printf("Log directory does not exist: %s\n", logDir)
196 return nil
197 }
198
199 files, err := os.ReadDir(logDir)
200 if err != nil {
201 return fmt.Errorf("failed to read log directory: %w", err)
202 }
203
204 // Filter and collect log files
205 var logFiles []os.FileInfo
206 for _, file := range files {
207 if file.IsDir() {
208 continue
209 }
210
211 name := file.Name()
212 if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
213 info, err := file.Info()
214 if err != nil {
215 continue
216 }
217 logFiles = append(logFiles, info)
218 }
219 }
220
221 if len(logFiles) == 0 {
222 fmt.Println("No log files found")
223 return nil
224 }
225
226 // Sort by modification time (newest first)
227 sort.Slice(logFiles, func(i, j int) bool {
228 return logFiles[i].ModTime().After(logFiles[j].ModTime())
229 })
230
231 // Display files
232 fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
233 fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
234
235 for _, file := range logFiles {
236 size := formatSize(file.Size())
237 modTime := file.ModTime().Format("2006-01-02 15:04:05")
238 fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size)
239 }
240
241 return nil
242}
243
244// cleanOldLogs removes log files older than maxAge days
245func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
246 if _, err := os.Stat(logDir); os.IsNotExist(err) {
247 fmt.Printf("Log directory does not exist: %s\n", logDir)
248 return nil
249 }
250
251 files, err := os.ReadDir(logDir)
252 if err != nil {
253 return fmt.Errorf("failed to read log directory: %w", err)
254 }
255
256 cutoffTime := time.Now().AddDate(0, 0, -maxAge)
257 var deletedCount int
258 var deletedSize int64
259
260 for _, file := range files {
261 if file.IsDir() {
262 continue
263 }
264
265 name := file.Name()
266 // Skip the current log file
267 if name == "dumber.log" {
268 continue
269 }
270
271 // Only process log files
272 if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") {
273 continue
274 }
275
276 info, err := file.Info()
277 if err != nil {
278 continue
279 }
280
281 if info.ModTime().Before(cutoffTime) {
282 filePath := filepath.Join(logDir, name)
283
284 if dryRun {
285 fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
286 name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
287 } else {
288 err := os.Remove(filePath)
289 if err != nil {
290 fmt.Printf("Warning: failed to delete %s: %v\n", name, err)
291 } else {
292 fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
293 }
294 }
295
296 deletedCount++
297 deletedSize += info.Size()
298 }
299 }
300
301 if dryRun {
302 fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
303 deletedCount, float64(deletedSize)/1024)
304 } else {
305 fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
306 deletedCount, float64(deletedSize)/1024)
307 }
308
309 return nil
310}
311
312// formatSize formats file size in human-readable format
313func formatSize(size int64) string {
314 const unit = 1024
315 if size < unit {
316 return fmt.Sprintf("%d B", size)
317 }
318
319 div, exp := int64(unit), 0
320 for n := size / unit; n >= unit; n /= unit {
321 div *= unit
322 exp++
323 }
324
325 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
326}
327
328// tailConsoleMessages displays console messages from the log file
329func tailConsoleMessages(filePath string, lines int, level string) error {
330 file, err := os.Open(filePath)
331 if err != nil {
332 if os.IsNotExist(err) {
333 fmt.Printf("Log file does not exist: %s\n", filePath)
334 return nil
335 }
336 return fmt.Errorf("failed to open log file: %w", err)
337 }
338 defer file.Close()
339
340 // Read all lines and filter for console messages
341 var consoleLines []string
342 scanner := bufio.NewScanner(file)
343 for scanner.Scan() {
344 line := scanner.Text()
345
346 // Filter for [CONSOLE] tagged messages
347 if !strings.Contains(line, "[CONSOLE]") {
348 continue
349 }
350
351 // Filter by level if specified
352 if level != "" {
353 levelFilter := fmt.Sprintf("[%s]", strings.ToUpper(level))
354 if !strings.Contains(line, levelFilter) {
355 continue
356 }
357 }
358
359 consoleLines = append(consoleLines, line)
360 }
361
362 if err := scanner.Err(); err != nil {
363 return fmt.Errorf("error reading log file: %w", err)
364 }
365
366 if len(consoleLines) == 0 {
367 if level != "" {
368 fmt.Printf("No console messages found with level '%s'\n", level)
369 } else {
370 fmt.Println("No console messages found")
371 }
372 return nil
373 }
374
375 // Display last N lines
376 start := len(consoleLines) - lines
377 if start < 0 {
378 start = 0
379 }
380
381 for i := start; i < len(consoleLines); i++ {
382 fmt.Println(consoleLines[i])
383 }
384
385 return nil
386}