package diary import ( "errors" "io/fs" "os" "path/filepath" "strings" "sync" "time" ) const dateLayout = "2006-01-02" // dailyWriter writes to one file per local date: path storage/logs/summer.log // gives storage/logs/summer-2026-10-21.log. Files open lazily on the first // write (no empty files), are created 0o640 in directories created 0o750, // and on every open the files of the same base older than the retention are // deleted. If a file cannot be opened the writer reports once through warn // and is disabled: later writes succeed without writing anything. type dailyWriter struct { mu sync.Mutex dir string base string ext string days int now func() time.Time warn func(path string, err error) file *os.File date string disabled bool } func newDailyWriter(path string, days int, now func() time.Time, warn func(string, error)) *dailyWriter { dir, name := filepath.Split(path) ext := filepath.Ext(name) if dir == "" { dir = "." } return &dailyWriter{ dir: filepath.Clean(dir), base: strings.TrimSuffix(name, ext), ext: ext, days: days, now: now, warn: warn, } } func (d *dailyWriter) fileName(date string) string { return filepath.Join(d.dir, d.base+"-"+date+d.ext) } // Write appends p to the current day's file in one write call. func (d *dailyWriter) Write(p []byte) (int, error) { d.mu.Lock() if d.disabled { d.mu.Unlock() return len(p), nil } date := d.now().Local().Format(dateLayout) if d.file == nil || date != d.date { if err := d.open(date); err != nil { d.disabled = true warn := d.warn name := d.fileName(date) d.mu.Unlock() if warn != nil { warn(name, err) } return len(p), nil } } n, err := d.file.Write(p) d.mu.Unlock() return n, err } func (d *dailyWriter) open(date string) error { if d.file != nil { _ = d.file.Close() d.file = nil } if err := os.MkdirAll(d.dir, 0o750); err != nil { return err } name := d.fileName(date) _, statErr := os.Stat(name) f, err := os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640) if err != nil { return err } if errors.Is(statErr, fs.ErrNotExist) { // The process umask may strip bits; new log files are 0o640 exactly. _ = f.Chmod(0o640) } d.file, d.date = f, date d.prune(date) return nil } // prune deletes files named base-YYYY-MM-DD+ext whose date falls outside the // retention window: with days N the files of today and the N-1 days before // it are kept. Other files in the directory are never touched. func (d *dailyWriter) prune(today string) { if d.days <= 0 { return } now, err := time.ParseInLocation(dateLayout, today, time.Local) if err != nil { return } cutoff := now.AddDate(0, 0, -d.days) entries, err := os.ReadDir(d.dir) if err != nil { return } prefix := d.base + "-" for _, e := range entries { if e.IsDir() { continue } name := e.Name() rest, ok := strings.CutPrefix(name, prefix) if !ok { continue } stamp, ok := strings.CutSuffix(rest, d.ext) if !ok || len(stamp) != len(dateLayout) { continue } day, err := time.ParseInLocation(dateLayout, stamp, time.Local) if err != nil { continue } if !day.After(cutoff) { _ = os.Remove(filepath.Join(d.dir, name)) } } } // Close closes the open file. A later write opens it again. func (d *dailyWriter) Close() error { d.mu.Lock() defer d.mu.Unlock() if d.file == nil { return nil } err := d.file.Close() d.file, d.date = nil, "" return err }