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 stored in XDG-compliant directory for debugging purposes",
23 }
24
25 cmd.AddCommand(newLogsTailCmd())
26 cmd.AddCommand(newLogsListCmd())
27 cmd.AddCommand(newLogsCleanCmd())
28 cmd.AddCommand(newLogsPathCmd())
29
30 return cmd
31}
32
33// newLogsTailCmd creates the tail subcommand
34func newLogsTailCmd() *cobra.Command {
35 var lines int
36
37 cmd := &cobra.Command{
38 Use: "tail [lines]",
39 Short: "Show last N lines of current log",
40 Long: "Display the last N lines from the current log file. Defaults to 50 lines.",
41 Args: cobra.MaximumNArgs(1),
42 RunE: func(cmd *cobra.Command, args []string) error {
43 // Parse lines argument if provided
44 if len(args) > 0 {
45 var err error
46 lines, err = strconv.Atoi(args[0])
47 if err != nil {
48 return fmt.Errorf("invalid number of lines: %s", args[0])
49 }
50 }
51
52 logDir, err := config.GetLogDir()
53 if err != nil {
54 return fmt.Errorf("failed to get log directory: %w", err)
55 }
56
57 currentLog := filepath.Join(logDir, "dumber.log")
58 return tailFile(currentLog, lines)
59 },
60 }
61
62 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
63 return cmd
64}
65
66// newLogsListCmd creates the list subcommand
67func newLogsListCmd() *cobra.Command {
68 cmd := &cobra.Command{
69 Use: "list",
70 Short: "List all available log files",
71 Long: "Display all log files in the log directory with their sizes and modification times.",
72 RunE: func(cmd *cobra.Command, args []string) error {
73 logDir, err := config.GetLogDir()
74 if err != nil {
75 return fmt.Errorf("failed to get log directory: %w", err)
76 }
77
78 return listLogFiles(logDir)
79 },
80 }
81
82 return cmd
83}
84
85// newLogsCleanCmd creates the clean subcommand
86func newLogsCleanCmd() *cobra.Command {
87 var dryRun bool
88 var maxAge int
89
90 cmd := &cobra.Command{
91 Use: "clean",
92 Short: "Remove old log files",
93 Long: "Remove log files older than the specified age in days. Use --dry-run to see what would be deleted.",
94 RunE: func(cmd *cobra.Command, args []string) error {
95 logDir, err := config.GetLogDir()
96 if err != nil {
97 return fmt.Errorf("failed to get log directory: %w", err)
98 }
99
100 return cleanOldLogs(logDir, maxAge, dryRun)
101 },
102 }
103
104 cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting")
105 cmd.Flags().IntVar(&maxAge, "max-age", 30, "Maximum age of log files in days")
106 return cmd
107}
108
109// newLogsPathCmd creates the path subcommand
110func newLogsPathCmd() *cobra.Command {
111 cmd := &cobra.Command{
112 Use: "path",
113 Short: "Show the path to the log directory",
114 Long: "Display the XDG-compliant path where logs are stored.",
115 RunE: func(cmd *cobra.Command, args []string) error {
116 logDir, err := config.GetLogDir()
117 if err != nil {
118 return fmt.Errorf("failed to get log directory: %w", err)
119 }
120
121 fmt.Println(logDir)
122 return nil
123 },
124 }
125
126 return cmd
127}
128
129// tailFile displays the last N lines of a file
130func tailFile(filePath string, lines int) error {
131 file, err := os.Open(filePath)
132 if err != nil {
133 if os.IsNotExist(err) {
134 fmt.Printf("Log file does not exist: %s\n", filePath)
135 return nil
136 }
137 return fmt.Errorf("failed to open log file: %w", err)
138 }
139 defer file.Close()
140
141 // Read all lines
142 var allLines []string
143 scanner := bufio.NewScanner(file)
144 for scanner.Scan() {
145 allLines = append(allLines, scanner.Text())
146 }
147
148 if err := scanner.Err(); err != nil {
149 return fmt.Errorf("error reading log file: %w", err)
150 }
151
152 // Display last N lines
153 start := len(allLines) - lines
154 if start < 0 {
155 start = 0
156 }
157
158 for i := start; i < len(allLines); i++ {
159 fmt.Println(allLines[i])
160 }
161
162 return nil
163}
164
165// listLogFiles displays all log files with their information
166func listLogFiles(logDir string) error {
167 if _, err := os.Stat(logDir); os.IsNotExist(err) {
168 fmt.Printf("Log directory does not exist: %s\n", logDir)
169 return nil
170 }
171
172 files, err := os.ReadDir(logDir)
173 if err != nil {
174 return fmt.Errorf("failed to read log directory: %w", err)
175 }
176
177 // Filter and collect log files
178 var logFiles []os.FileInfo
179 for _, file := range files {
180 if file.IsDir() {
181 continue
182 }
183
184 name := file.Name()
185 if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
186 info, err := file.Info()
187 if err != nil {
188 continue
189 }
190 logFiles = append(logFiles, info)
191 }
192 }
193
194 if len(logFiles) == 0 {
195 fmt.Println("No log files found")
196 return nil
197 }
198
199 // Sort by modification time (newest first)
200 sort.Slice(logFiles, func(i, j int) bool {
201 return logFiles[i].ModTime().After(logFiles[j].ModTime())
202 })
203
204 // Display files
205 fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
206 fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
207
208 for _, file := range logFiles {
209 size := formatSize(file.Size())
210 modTime := file.ModTime().Format("2006-01-02 15:04:05")
211 fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size)
212 }
213
214 return nil
215}
216
217// cleanOldLogs removes log files older than maxAge days
218func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
219 if _, err := os.Stat(logDir); os.IsNotExist(err) {
220 fmt.Printf("Log directory does not exist: %s\n", logDir)
221 return nil
222 }
223
224 files, err := os.ReadDir(logDir)
225 if err != nil {
226 return fmt.Errorf("failed to read log directory: %w", err)
227 }
228
229 cutoffTime := time.Now().AddDate(0, 0, -maxAge)
230 var deletedCount int
231 var deletedSize int64
232
233 for _, file := range files {
234 if file.IsDir() {
235 continue
236 }
237
238 name := file.Name()
239 // Skip the current log file
240 if name == "dumber.log" {
241 continue
242 }
243
244 // Only process log files
245 if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") {
246 continue
247 }
248
249 info, err := file.Info()
250 if err != nil {
251 continue
252 }
253
254 if info.ModTime().Before(cutoffTime) {
255 filePath := filepath.Join(logDir, name)
256
257 if dryRun {
258 fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
259 name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
260 } else {
261 err := os.Remove(filePath)
262 if err != nil {
263 fmt.Printf("Warning: failed to delete %s: %v\n", name, err)
264 } else {
265 fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
266 }
267 }
268
269 deletedCount++
270 deletedSize += info.Size()
271 }
272 }
273
274 if dryRun {
275 fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
276 deletedCount, float64(deletedSize)/1024)
277 } else {
278 fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
279 deletedCount, float64(deletedSize)/1024)
280 }
281
282 return nil
283}
284
285// formatSize formats file size in human-readable format
286func formatSize(size int64) string {
287 const unit = 1024
288 if size < unit {
289 return fmt.Sprintf("%d B", size)
290 }
291
292 div, exp := int64(unit), 0
293 for n := size / unit; n >= unit; n /= unit {
294 div *= unit
295 exp++
296 }
297
298 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
299}