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, 0600)
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 if err := r.currentFile.Close(); err != nil {
90 fmt.Fprintf(os.Stderr, "Warning: failed to close current log file: %v\n", err)
91 }
92 }
93
94 // Generate backup filename with timestamp
95 timestamp := time.Now().Format("2006-01-02-15-04-05")
96 backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp)
97
98 currentPath := filepath.Join(r.baseDir, r.baseName)
99 backupPath := filepath.Join(r.baseDir, backupName)
100
101 // Move current file to backup
102 if err := os.Rename(currentPath, backupPath); err != nil {
103 return fmt.Errorf("failed to rotate log file: %w", err)
104 }
105
106 // Compress if enabled
107 if r.compress {
108 if err := r.compressFile(backupPath); err != nil {
109 // Log warning but don't fail rotation
110 fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err)
111 } else {
112 // Remove uncompressed file after successful compression
113 if err := os.Remove(backupPath); err != nil {
114 fmt.Fprintf(os.Stderr, "Warning: failed to remove uncompressed log file %s: %v\n", backupPath, err)
115 }
116 }
117 }
118
119 // Clean up old files
120 r.cleanup()
121
122 // Reset size counter and open new file
123 r.currentSize = 0
124 return r.openCurrentFile()
125}
126
127func (r *LogRotator) compressFile(filePath string) error {
128 inputFile, err := os.Open(filePath)
129 if err != nil {
130 return err
131 }
132 defer func() {
133 if err := inputFile.Close(); err != nil {
134 fmt.Fprintf(os.Stderr, "Warning: failed to close input file during compression: %v\n", err)
135 }
136 }()
137
138 outputPath := filePath + ".gz"
139 outputFile, err := os.Create(outputPath)
140 if err != nil {
141 return err
142 }
143 defer func() {
144 if err := outputFile.Close(); err != nil {
145 fmt.Fprintf(os.Stderr, "Warning: failed to close output file during compression: %v\n", err)
146 }
147 }()
148
149 gzipWriter := gzip.NewWriter(outputFile)
150 defer func() {
151 if err := gzipWriter.Close(); err != nil {
152 fmt.Fprintf(os.Stderr, "Warning: failed to close gzip writer: %v\n", err)
153 }
154 }()
155
156 _, err = io.Copy(gzipWriter, inputFile)
157 return err
158}
159
160func (r *LogRotator) cleanup() {
161 files, err := os.ReadDir(r.baseDir)
162 if err != nil {
163 return
164 }
165
166 var backupFiles []os.FileInfo
167 now := time.Now()
168
169 for _, file := range files {
170 if file.IsDir() {
171 continue
172 }
173
174 name := file.Name()
175 if !strings.HasPrefix(name, r.baseName+".") {
176 continue
177 }
178
179 info, err := file.Info()
180 if err != nil {
181 continue
182 }
183
184 // Remove files older than maxAge
185 if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge {
186 if err := os.Remove(filepath.Join(r.baseDir, name)); err != nil {
187 fmt.Fprintf(os.Stderr, "warning: failed to remove old log file: %v\n", err)
188 }
189 continue
190 }
191
192 backupFiles = append(backupFiles, info)
193 }
194
195 // Remove excess backup files (keep only maxBackups)
196 if r.maxBackups > 0 && len(backupFiles) > r.maxBackups {
197 // Sort by modification time (oldest first)
198 sort.Slice(backupFiles, func(i, j int) bool {
199 return backupFiles[i].ModTime().Before(backupFiles[j].ModTime())
200 })
201
202 // Remove oldest files
203 for i := 0; i < len(backupFiles)-r.maxBackups; i++ {
204 if err := os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())); err != nil {
205 fmt.Fprintf(os.Stderr, "warning: failed to remove excess backup file: %v\n", err)
206 }
207 }
208 }
209}
210
211func (r *LogRotator) Close() error {
212 r.mu.Lock()
213 defer r.mu.Unlock()
214
215 if r.currentFile != nil {
216 return r.currentFile.Close()
217 }
218 return nil
219}