Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

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()
}