output/file.go

package output

import (
	"errors"
	"io"
	"os"
	"path/filepath"
	"sync"
)

// FileOptions controls the rotating file writer. Max size is the only
// rotation trigger; time-based rotation is out of scope because local
// development does not need it.
type FileOptions struct {
	// Path is the filename to write to. Empty means "./lambdalog.log".
	Path string
	// MaxBytes triggers rotation when the active file reaches this size.
	// 0 disables rotation.
	MaxBytes int64
	// MaxBackups caps the number of rotated files kept on disk. Older
	// backups beyond MaxBackups are deleted.
	MaxBackups int
}

// File returns a rotating file writer intended for local development. In
// production, prefer Stdout or CloudWatch.
func File(opts FileOptions) (io.WriteCloser, error) {
	if opts.Path == "" {
		opts.Path = "./lambdalog.log"
	}
	if err := os.MkdirAll(filepath.Dir(opts.Path), 0o755); err != nil {
		return nil, err
	}
	f, err := os.OpenFile(opts.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
	if err != nil {
		return nil, err
	}
	info, err := f.Stat()
	if err != nil {
		_ = f.Close()
		return nil, err
	}
	return &fileWriter{opts: opts, f: f, size: info.Size()}, nil
}

type fileWriter struct {
	opts FileOptions
	mu   sync.Mutex
	f    *os.File
	size int64
}

// Write appends p and rotates if the size limit is exceeded.
func (w *fileWriter) Write(p []byte) (int, error) {
	w.mu.Lock()
	defer w.mu.Unlock()
	if w.f == nil {
		return 0, errors.New("output: file writer closed")
	}
	if w.opts.MaxBytes > 0 && w.size+int64(len(p)) > w.opts.MaxBytes {
		if err := w.rotateLocked(); err != nil {
			return 0, err
		}
	}
	n, err := w.f.Write(p)
	w.size += int64(n)
	return n, err
}

// Close closes the underlying file.
func (w *fileWriter) Close() error {
	w.mu.Lock()
	defer w.mu.Unlock()
	if w.f == nil {
		return nil
	}
	err := w.f.Close()
	w.f = nil
	return err
}

func (w *fileWriter) rotateLocked() error {
	if err := w.f.Close(); err != nil {
		return err
	}
	rotated := w.opts.Path + ".1"
	_ = os.Remove(rotated)
	if err := os.Rename(w.opts.Path, rotated); err != nil {
		return err
	}
	// Shift older backups by one; discard anything past MaxBackups.
	for i := w.opts.MaxBackups; i >= 2; i-- {
		prev := w.opts.Path + "." + itoa(i-1)
		next := w.opts.Path + "." + itoa(i)
		_ = os.Rename(prev, next)
	}
	f, err := os.OpenFile(w.opts.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
	if err != nil {
		return err
	}
	w.f = f
	w.size = 0
	return nil
}

func itoa(n int) string {
	if n < 10 {
		return string(rune('0' + n))
	}
	return itoa(n/10) + string(rune('0'+n%10))
}