1package logging
2
3import (
4 "compress/gzip"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11 "sync"
12 "time"
13)
14
15type LogRotator struct {
16 mu sync.Mutex
17 baseDir string
18 baseName string
19 maxSize int64 // bytes
20 maxAge time.Duration
21 maxBackups int
22 compress bool
23 currentFile *os.File
24 currentSize int64
25}
26
27func NewLogRotator(baseDir string, maxSizeMB, maxBackups, maxAgeDays int, compress bool) (*LogRotator, error) {
28 r := &LogRotator{
29 baseDir: baseDir,
30 baseName: "dumber.log",
31 maxSize: int64(maxSizeMB) * 1024 * 1024, // Convert MB to bytes
32 maxAge: time.Duration(maxAgeDays) * 24 * time.Hour,
33 maxBackups: maxBackups,
34 compress: compress,
35 }
36
37 if err := r.openCurrentFile(); err != nil {
38 return nil, err
39 }
40
41 return r, nil
42}
43
44func (r *LogRotator) openCurrentFile() error {
45 logPath := filepath.Join(r.baseDir, r.baseName)
46
47 // Get current file size if it exists
48 if info, err := os.Stat(logPath); err == nil {
49 r.currentSize = info.Size()
50 }
51
52 file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
53 if err != nil {
54 return fmt.Errorf("failed to open log file: %w", err)
55 }
56
57 r.currentFile = file
58 return nil
59}
60
61func (r *LogRotator) Write(p []byte) (n int, err error) {
62 r.mu.Lock()
63 defer r.mu.Unlock()
64
65 if r.currentFile == nil {
66 if err := r.openCurrentFile(); err != nil {
67 return 0, err
68 }
69 }
70
71 // Check if rotation is needed
72 if r.currentSize+int64(len(p)) > r.maxSize {
73 if err := r.rotate(); err != nil {
74 return 0, err
75 }
76 }
77
78 n, err = r.currentFile.Write(p)
79 if err != nil {
80 return n, err
81 }
82
83 r.currentSize += int64(n)
84 return n, nil
85}
86
87func (r *LogRotator) rotate() error {
88 if r.currentFile != nil {
89 r.currentFile.Close()
90 }
91
92 // Generate backup filename with timestamp
93 timestamp := time.Now().Format("2006-01-02-15-04-05")
94 backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp)
95
96 currentPath := filepath.Join(r.baseDir, r.baseName)
97 backupPath := filepath.Join(r.baseDir, backupName)
98
99 // Move current file to backup
100 if err := os.Rename(currentPath, backupPath); err != nil {
101 return fmt.Errorf("failed to rotate log file: %w", err)
102 }
103
104 // Compress if enabled
105 if r.compress {
106 if err := r.compressFile(backupPath); err != nil {
107 // Log warning but don't fail rotation
108 fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err)
109 } else {
110 // Remove uncompressed file after successful compression
111 os.Remove(backupPath)
112 }
113 }
114
115 // Clean up old files
116 r.cleanup()
117
118 // Reset size counter and open new file
119 r.currentSize = 0
120 return r.openCurrentFile()
121}
122
123func (r *LogRotator) compressFile(filePath string) error {
124 inputFile, err := os.Open(filePath)
125 if err != nil {
126 return err
127 }
128 defer inputFile.Close()
129
130 outputPath := filePath + ".gz"
131 outputFile, err := os.Create(outputPath)
132 if err != nil {
133 return err
134 }
135 defer outputFile.Close()
136
137 gzipWriter := gzip.NewWriter(outputFile)
138 defer gzipWriter.Close()
139
140 _, err = io.Copy(gzipWriter, inputFile)
141 return err
142}
143
144func (r *LogRotator) cleanup() {
145 files, err := os.ReadDir(r.baseDir)
146 if err != nil {
147 return
148 }
149
150 var backupFiles []os.FileInfo
151 now := time.Now()
152
153 for _, file := range files {
154 if file.IsDir() {
155 continue
156 }
157
158 name := file.Name()
159 if !strings.HasPrefix(name, r.baseName+".") {
160 continue
161 }
162
163 info, err := file.Info()
164 if err != nil {
165 continue
166 }
167
168 // Remove files older than maxAge
169 if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge {
170 os.Remove(filepath.Join(r.baseDir, name))
171 continue
172 }
173
174 backupFiles = append(backupFiles, info)
175 }
176
177 // Remove excess backup files (keep only maxBackups)
178 if r.maxBackups > 0 && len(backupFiles) > r.maxBackups {
179 // Sort by modification time (oldest first)
180 sort.Slice(backupFiles, func(i, j int) bool {
181 return backupFiles[i].ModTime().Before(backupFiles[j].ModTime())
182 })
183
184 // Remove oldest files
185 for i := 0; i < len(backupFiles)-r.maxBackups; i++ {
186 os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name()))
187 }
188 }
189}
190
191func (r *LogRotator) Close() error {
192 r.mu.Lock()
193 defer r.mu.Unlock()
194
195 if r.currentFile != nil {
196 return r.currentFile.Close()
197 }
198 return nil
199}