package config import ( "fmt" "log/slog" "os" "strings" "sync" ) // LogWriter wraps a log destination and supports reopening for log rotation. // When the output is a file, Reopen closes the old handle and opens a new one // so that newsyslog rotation works after SIGHUP. Shared by the application log // and the counting transaction log. (Same implementation as rcptd.) type LogWriter struct { mu sync.Mutex path string // empty for stdout/stderr file *os.File // current output file (or os.Stdout/os.Stderr) } func (w *LogWriter) Write(p []byte) (n int, err error) { w.mu.Lock() defer w.mu.Unlock() return w.file.Write(p) } // Reopen closes and reopens the log file. No-op for stdout/stderr. func (w *LogWriter) Reopen() error { if w.path == "" { return nil } w.mu.Lock() defer w.mu.Unlock() newFile, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return fmt.Errorf("reopen log file %s: %w", w.path, err) } w.file.Close() w.file = newFile return nil } // Close closes the log file. No-op for stdout/stderr. func (w *LogWriter) Close() error { if w.path == "" { return nil } w.mu.Lock() defer w.mu.Unlock() return w.file.Close() } // NewWriter opens a log destination: "stderr", "stdout" or a file path. func NewWriter(output string) (*LogWriter, error) { return newLogWriter(output) } // NewLogger creates the application slog.Logger from cfg and installs it as // the slog default. The returned LogWriter must be closed by the caller. func NewLogger(cfg LoggingConfig) (*slog.Logger, *LogWriter, error) { level, err := parseLogLevel(cfg.Level) if err != nil { return nil, nil, fmt.Errorf("invalid log level: %w", err) } writer, err := newLogWriter(cfg.Output) if err != nil { return nil, nil, fmt.Errorf("failed to create log writer: %w", err) } var handler slog.Handler opts := &slog.HandlerOptions{Level: level} switch strings.ToLower(cfg.Format) { case "json": handler = slog.NewJSONHandler(writer, opts) case "console", "": handler = slog.NewTextHandler(writer, opts) default: writer.Close() return nil, nil, fmt.Errorf("invalid log format: %s (must be 'console' or 'json')", cfg.Format) } logger := slog.New(handler) slog.SetDefault(logger) return logger, writer, nil } func parseLogLevel(level string) (slog.Level, error) { switch strings.ToLower(level) { case "debug": return slog.LevelDebug, nil case "info", "": return slog.LevelInfo, nil case "warn", "warning": return slog.LevelWarn, nil case "error": return slog.LevelError, nil default: return slog.LevelInfo, fmt.Errorf("unknown level: %s (must be debug, info, warn, or error)", level) } } func newLogWriter(output string) (*LogWriter, error) { switch strings.ToLower(output) { case "stderr", "": return &LogWriter{file: os.Stderr}, nil case "stdout": return &LogWriter{file: os.Stdout}, nil default: file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return nil, fmt.Errorf("failed to open log file %s: %w", output, err) } return &LogWriter{path: output, file: file}, nil } }