180 lines
4.8 KiB
Go
180 lines
4.8 KiB
Go
// Package applog provides logging for cliamp.
|
|
//
|
|
// Two sinks are layered behind one API:
|
|
//
|
|
// - A file sink, written through log/slog, for diagnostic logs the user
|
|
// reads after the fact (~/.config/cliamp/cliamp.log).
|
|
// - An in-memory ring buffer drained by the TUI footer for short-lived,
|
|
// user-facing messages. The buffer exists because writing to stderr
|
|
// would corrupt the TUI.
|
|
//
|
|
// Callers pick by intent, not sink:
|
|
//
|
|
// - Debug/Info/Warn/Error write only to the file.
|
|
// - Status writes only to the footer (transient UI
|
|
// feedback that wouldn't help post-mortem debugging).
|
|
// - UserWarn/UserError write to both.
|
|
//
|
|
// Init must be called once at startup. Calls before Init are silently
|
|
// dropped on the file side; the footer buffer always works.
|
|
package applog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Entry is a single footer message with a timestamp.
|
|
type Entry struct {
|
|
Text string
|
|
At time.Time
|
|
}
|
|
|
|
// Level is an alias for slog.Level so callers don't need a second import.
|
|
type Level = slog.Level
|
|
|
|
const (
|
|
LevelDebug = slog.LevelDebug
|
|
LevelInfo = slog.LevelInfo
|
|
LevelWarn = slog.LevelWarn
|
|
LevelError = slog.LevelError
|
|
)
|
|
|
|
const maxEntries = 4
|
|
|
|
var (
|
|
logger atomic.Pointer[slog.Logger]
|
|
|
|
mu sync.Mutex
|
|
entries []Entry
|
|
currentFile io.Closer
|
|
)
|
|
|
|
func init() {
|
|
logger.Store(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
}
|
|
|
|
// Init opens path for append, installs a slog text handler at the given
|
|
// level, and returns a close func. Calling Init twice closes the previous
|
|
// file before swapping handlers.
|
|
func Init(path string, level Level) (func() error, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, fmt.Errorf("create log dir: %w", err)
|
|
}
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open log file: %w", err)
|
|
}
|
|
|
|
h := slog.NewTextHandler(f, &slog.HandlerOptions{Level: level})
|
|
logger.Store(slog.New(h))
|
|
|
|
mu.Lock()
|
|
prev := currentFile
|
|
currentFile = f
|
|
mu.Unlock()
|
|
if prev != nil {
|
|
_ = prev.Close()
|
|
}
|
|
|
|
return func() error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if currentFile == nil {
|
|
return nil
|
|
}
|
|
err := currentFile.Close()
|
|
currentFile = nil
|
|
logger.Store(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
return err
|
|
}, nil
|
|
}
|
|
|
|
// ParseLevel maps a string to a Level. Empty input maps to LevelInfo.
|
|
// "warning" is accepted as an alias for "warn".
|
|
func ParseLevel(s string) (Level, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return LevelInfo, nil
|
|
}
|
|
if strings.EqualFold(s, "warning") {
|
|
return LevelWarn, nil
|
|
}
|
|
var lvl Level
|
|
if err := lvl.UnmarshalText([]byte(strings.ToUpper(s))); err != nil {
|
|
return LevelInfo, fmt.Errorf("invalid log level %q (want debug|info|warn|error)", s)
|
|
}
|
|
return lvl, nil
|
|
}
|
|
|
|
// Debug, Info, Warn, Error log only to the file. Format follows fmt.Sprintf.
|
|
// The level guard avoids paying the Sprintf cost when the level is filtered out.
|
|
func Debug(format string, args ...any) { logf(slog.LevelDebug, format, args...) }
|
|
func Info(format string, args ...any) { logf(slog.LevelInfo, format, args...) }
|
|
func Warn(format string, args ...any) { logf(slog.LevelWarn, format, args...) }
|
|
func Error(format string, args ...any) { logf(slog.LevelError, format, args...) }
|
|
|
|
// Status pushes a message into the footer buffer without writing to the
|
|
// log file. Use for ephemeral, user-facing notifications that wouldn't help
|
|
// post-mortem debugging.
|
|
func Status(format string, args ...any) {
|
|
pushFooter(fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// UserWarn logs at warn level and pushes the same message into the footer.
|
|
// The Sprintf cost is paid unconditionally because the footer needs the
|
|
// formatted string, so no Enabled gate here.
|
|
func UserWarn(format string, args ...any) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
logger.Load().Warn(msg)
|
|
pushFooter(msg)
|
|
}
|
|
|
|
// UserError logs at error level and pushes the same message into the footer.
|
|
func UserError(format string, args ...any) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
logger.Load().Error(msg)
|
|
pushFooter(msg)
|
|
}
|
|
|
|
// Drain returns and clears all buffered footer entries.
|
|
func Drain() []Entry {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
out := entries
|
|
entries = nil
|
|
return out
|
|
}
|
|
|
|
func logf(level slog.Level, format string, args ...any) {
|
|
lg := logger.Load()
|
|
if !lg.Enabled(context.Background(), level) {
|
|
return
|
|
}
|
|
lg.Log(context.Background(), level, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func pushFooter(msg string) {
|
|
msg = strings.TrimRight(msg, "\n")
|
|
if msg == "" {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
entries = append(entries, Entry{Text: msg, At: time.Now()})
|
|
if len(entries) > maxEntries {
|
|
entries = entries[len(entries)-maxEntries:]
|
|
}
|
|
mu.Unlock()
|
|
}
|