From 16a985ad5c15e7e12cd31c029bbd73b05bfd028d Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:23:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20absorb=20go-log=20into=20core=20?= =?UTF-8?q?=E2=80=94=20error.go=20+=20log.go=20in=20pkg/core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings go-log's errors and logger directly into the Core package: core.E("pkg.Method", "msg", err) — structured errors core.Err{Op, Msg, Err, Code} — error type core.Wrap(err, op, msg) — error wrapping core.NewLogger(opts) — structured logger core.Info/Warn/Error/Debug(msg, kv) — logging functions Removed: pkg/core/e.go — was re-exporting from go-log, now source is inline pkg/log/ — was re-exporting, no longer needed Renames to avoid conflicts: log.New() → core.NewLogger() (core.New is the DI constructor) log.Message() → core.ErrorMessage() (core.Message is the IPC type) go-log still exists as a separate module for external consumers. Core framework now has errors + logging built-in. Zero deps. Co-Authored-By: Virgil --- pkg/core/e.go | 26 --- pkg/core/e_test.go | 2 +- pkg/core/error.go | 270 +++++++++++++++++++++++++++++++ pkg/core/fuzz_test.go | 4 +- pkg/core/log.go | 342 +++++++++++++++++++++++++++++++++++++++ pkg/log/log.go | 74 --------- pkg/log/rotation.go | 170 ------------------- pkg/log/rotation_test.go | 215 ------------------------ pkg/log/service.go | 57 ------- pkg/log/service_test.go | 126 --------------- 10 files changed, 615 insertions(+), 671 deletions(-) delete mode 100644 pkg/core/e.go create mode 100644 pkg/core/error.go create mode 100644 pkg/core/log.go delete mode 100644 pkg/log/log.go delete mode 100644 pkg/log/rotation.go delete mode 100644 pkg/log/rotation_test.go delete mode 100644 pkg/log/service.go delete mode 100644 pkg/log/service_test.go diff --git a/pkg/core/e.go b/pkg/core/e.go deleted file mode 100644 index a124696..0000000 --- a/pkg/core/e.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package core re-exports the structured error types from go-log. -// -// All error construction in the framework MUST use E() (or Wrap, WrapCode, etc.) -// rather than fmt.Errorf. This ensures every error carries an operation context -// for structured logging and tracing. -// -// Example: -// -// return core.E("config.Load", "failed to load config file", err) -package core - -import ( - coreerr "forge.lthn.ai/core/go-log" -) - -// Error is the structured error type from go-log. -// It carries Op (operation), Msg (human-readable), Err (underlying), and Code fields. -type Error = coreerr.Err - -// E creates a new structured error with operation context. -// This is the primary way to create errors in the Core framework. -// -// The 'op' parameter should be in the format of 'package.function' or 'service.method'. -// The 'msg' parameter should be a human-readable message. -// The 'err' parameter is the underlying error (may be nil). -var E = coreerr.E diff --git a/pkg/core/e_test.go b/pkg/core/e_test.go index 71b04c0..eaf1683 100644 --- a/pkg/core/e_test.go +++ b/pkg/core/e_test.go @@ -23,7 +23,7 @@ func TestE_Unwrap(t *testing.T) { assert.True(t, errors.Is(err, originalErr)) - var eErr *Error + var eErr *Err assert.True(t, errors.As(err, &eErr)) assert.Equal(t, "test.op", eErr.Op) } diff --git a/pkg/core/error.go b/pkg/core/error.go new file mode 100644 index 0000000..b7a4bdf --- /dev/null +++ b/pkg/core/error.go @@ -0,0 +1,270 @@ +// Package log provides structured logging and error handling for Core applications. +// +// This file implements structured error types and combined log-and-return helpers +// that simplify common error handling patterns. + +package core + +import ( + "errors" + "fmt" + "iter" + "strings" +) + +// Err represents a structured error with operational context. +// It implements the error interface and supports unwrapping. +type Err struct { + Op string // Operation being performed (e.g., "user.Save") + Msg string // Human-readable message + Err error // Underlying error (optional) + Code string // Error code (optional, e.g., "VALIDATION_FAILED") +} + +// Error implements the error interface. +func (e *Err) Error() string { + var prefix string + if e.Op != "" { + prefix = e.Op + ": " + } + if e.Err != nil { + if e.Code != "" { + return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err) + } + return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err) + } + if e.Code != "" { + return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code) + } + return fmt.Sprintf("%s%s", prefix, e.Msg) +} + +// Unwrap returns the underlying error for use with errors.Is and errors.As. +func (e *Err) Unwrap() error { + return e.Err +} + +// --- Error Creation Functions --- + +// E creates a new Err with operation context. +// The underlying error can be nil for creating errors without a cause. +// +// Example: +// +// return log.E("user.Save", "failed to save user", err) +// return log.E("api.Call", "rate limited", nil) // No underlying cause +func E(op, msg string, err error) error { + return &Err{Op: op, Msg: msg, Err: err} +} + +// Wrap wraps an error with operation context. +// Returns nil if err is nil, to support conditional wrapping. +// Preserves error Code if the wrapped error is an *Err. +// +// Example: +// +// return log.Wrap(err, "db.Query", "database query failed") +func Wrap(err error, op, msg string) error { + if err == nil { + return nil + } + // Preserve Code from wrapped *Err + var logErr *Err + if As(err, &logErr) && logErr.Code != "" { + return &Err{Op: op, Msg: msg, Err: err, Code: logErr.Code} + } + return &Err{Op: op, Msg: msg, Err: err} +} + +// WrapCode wraps an error with operation context and error code. +// Returns nil only if both err is nil AND code is empty. +// Useful for API errors that need machine-readable codes. +// +// Example: +// +// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email") +func WrapCode(err error, code, op, msg string) error { + if err == nil && code == "" { + return nil + } + return &Err{Op: op, Msg: msg, Err: err, Code: code} +} + +// NewCode creates an error with just code and message (no underlying error). +// Useful for creating sentinel errors with codes. +// +// Example: +// +// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found") +func NewCode(code, msg string) error { + return &Err{Msg: msg, Code: code} +} + +// --- Standard Library Wrappers --- + +// Is reports whether any error in err's tree matches target. +// Wrapper around errors.Is for convenience. +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As finds the first error in err's tree that matches target. +// Wrapper around errors.As for convenience. +func As(err error, target any) bool { + return errors.As(err, target) +} + +// NewError creates a simple error with the given text. +// Wrapper around errors.New for convenience. +func NewError(text string) error { + return errors.New(text) +} + +// Join combines multiple errors into one. +// Wrapper around errors.Join for convenience. +func Join(errs ...error) error { + return errors.Join(errs...) +} + +// --- Error Introspection Helpers --- + +// Op extracts the operation name from an error. +// Returns empty string if the error is not an *Err. +func Op(err error) string { + var e *Err + if As(err, &e) { + return e.Op + } + return "" +} + +// ErrCode extracts the error code from an error. +// Returns empty string if the error is not an *Err or has no code. +func ErrCode(err error) string { + var e *Err + if As(err, &e) { + return e.Code + } + return "" +} + +// Message extracts the message from an error. +// Returns the error's Error() string if not an *Err. +func ErrorMessage(err error) string { + if err == nil { + return "" + } + var e *Err + if As(err, &e) { + return e.Msg + } + return err.Error() +} + +// Root returns the root cause of an error chain. +// Unwraps until no more wrapped errors are found. +func Root(err error) error { + if err == nil { + return nil + } + for { + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + return err + } + err = unwrapped + } +} + +// AllOps returns an iterator over all operational contexts in the error chain. +// It traverses the error tree using errors.Unwrap. +func AllOps(err error) iter.Seq[string] { + return func(yield func(string) bool) { + for err != nil { + if e, ok := err.(*Err); ok { + if e.Op != "" { + if !yield(e.Op) { + return + } + } + } + err = errors.Unwrap(err) + } + } +} + +// StackTrace returns the logical stack trace (chain of operations) from an error. +// It returns an empty slice if no operational context is found. +func StackTrace(err error) []string { + var stack []string + for op := range AllOps(err) { + stack = append(stack, op) + } + return stack +} + +// FormatStackTrace returns a pretty-printed logical stack trace. +func FormatStackTrace(err error) string { + var ops []string + for op := range AllOps(err) { + ops = append(ops, op) + } + if len(ops) == 0 { + return "" + } + return strings.Join(ops, " -> ") +} + +// --- Combined Log-and-Return Helpers --- + +// LogError logs an error at Error level and returns a wrapped error. +// Reduces boilerplate in error handling paths. +// +// Example: +// +// // Before +// if err != nil { +// log.Error("failed to save", "err", err) +// return errors.Wrap(err, "user.Save", "failed to save") +// } +// +// // After +// if err != nil { +// return log.LogError(err, "user.Save", "failed to save") +// } +func LogError(err error, op, msg string) error { + if err == nil { + return nil + } + wrapped := Wrap(err, op, msg) + defaultLogger.Error(msg, "op", op, "err", err) + return wrapped +} + +// LogWarn logs at Warn level and returns a wrapped error. +// Use for recoverable errors that should be logged but not treated as critical. +// +// Example: +// +// return log.LogWarn(err, "cache.Get", "cache miss, falling back to db") +func LogWarn(err error, op, msg string) error { + if err == nil { + return nil + } + wrapped := Wrap(err, op, msg) + defaultLogger.Warn(msg, "op", op, "err", err) + return wrapped +} + +// Must panics if err is not nil, logging first. +// Use for errors that should never happen and indicate programmer error. +// +// Example: +// +// log.Must(Initialize(), "app", "startup failed") +func Must(err error, op, msg string) { + if err != nil { + defaultLogger.Error(msg, "op", op, "err", err) + panic(Wrap(err, op, msg)) + } +} diff --git a/pkg/core/fuzz_test.go b/pkg/core/fuzz_test.go index f084151..4835c13 100644 --- a/pkg/core/fuzz_test.go +++ b/pkg/core/fuzz_test.go @@ -28,9 +28,9 @@ func FuzzE(f *testing.F) { } // Round-trip: Unwrap should return the underlying error - var coreErr *Error + var coreErr *Err if !errors.As(e, &coreErr) { - t.Fatal("errors.As failed for *Error") + t.Fatal("errors.As failed for *Err") } if withErr && coreErr.Unwrap() == nil { t.Fatal("Unwrap() returned nil with underlying error") diff --git a/pkg/core/log.go b/pkg/core/log.go new file mode 100644 index 0000000..99f62ad --- /dev/null +++ b/pkg/core/log.go @@ -0,0 +1,342 @@ +// Package log provides structured logging and error handling for Core applications. +// +// log.SetLevel(log.LevelDebug) +// log.Info("server started", "port", 8080) +// log.Error("failed to connect", "err", err) +package core + +import ( + "fmt" + goio "io" + "os" + "os/user" + "slices" + "sync" + "time" +) + +// Level defines logging verbosity. +type Level int + +// Logging level constants ordered by increasing verbosity. +const ( + // LevelQuiet suppresses all log output. + LevelQuiet Level = iota + // LevelError shows only error messages. + LevelError + // LevelWarn shows warnings and errors. + LevelWarn + // LevelInfo shows informational messages, warnings, and errors. + LevelInfo + // LevelDebug shows all messages including debug details. + LevelDebug +) + +// String returns the level name. +func (l Level) String() string { + switch l { + case LevelQuiet: + return "quiet" + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + case LevelDebug: + return "debug" + default: + return "unknown" + } +} + +// Logger provides structured logging. +type Logger struct { + mu sync.RWMutex + level Level + output goio.Writer + + // RedactKeys is a list of keys whose values should be masked in logs. + redactKeys []string + + // Style functions for formatting (can be overridden) + StyleTimestamp func(string) string + StyleDebug func(string) string + StyleInfo func(string) string + StyleWarn func(string) string + StyleError func(string) string + StyleSecurity func(string) string +} + +// RotationOptions defines the log rotation and retention policy. +type RotationOptions struct { + // Filename is the log file path. If empty, rotation is disabled. + Filename string + + // MaxSize is the maximum size of the log file in megabytes before it gets rotated. + // It defaults to 100 megabytes. + MaxSize int + + // MaxAge is the maximum number of days to retain old log files based on their + // file modification time. It defaults to 28 days. + // Note: set to a negative value to disable age-based retention. + MaxAge int + + // MaxBackups is the maximum number of old log files to retain. + // It defaults to 5 backups. + MaxBackups int + + // Compress determines if the rotated log files should be compressed using gzip. + // It defaults to true. + Compress bool +} + +// Options configures a Logger. +type Options struct { + Level Level + // Output is the destination for log messages. If Rotation is provided, + // Output is ignored and logs are written to the rotating file instead. + Output goio.Writer + // Rotation enables log rotation to file. If provided, Filename must be set. + Rotation *RotationOptions + // RedactKeys is a list of keys whose values should be masked in logs. + RedactKeys []string +} + +// RotationWriterFactory creates a rotating writer from options. +// Set this to enable log rotation (provided by core/go-io integration). +var RotationWriterFactory func(RotationOptions) goio.WriteCloser + +// New creates a new Logger with the given options. +func NewLogger(opts Options) *Logger { + output := opts.Output + if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil { + output = RotationWriterFactory(*opts.Rotation) + } + if output == nil { + output = os.Stderr + } + + return &Logger{ + level: opts.Level, + output: output, + redactKeys: slices.Clone(opts.RedactKeys), + StyleTimestamp: identity, + StyleDebug: identity, + StyleInfo: identity, + StyleWarn: identity, + StyleError: identity, + StyleSecurity: identity, + } +} + +func identity(s string) string { return s } + +// SetLevel changes the log level. +func (l *Logger) SetLevel(level Level) { + l.mu.Lock() + l.level = level + l.mu.Unlock() +} + +// Level returns the current log level. +func (l *Logger) Level() Level { + l.mu.RLock() + defer l.mu.RUnlock() + return l.level +} + +// SetOutput changes the output writer. +func (l *Logger) SetOutput(w goio.Writer) { + l.mu.Lock() + l.output = w + l.mu.Unlock() +} + +// SetRedactKeys sets the keys to be redacted. +func (l *Logger) SetRedactKeys(keys ...string) { + l.mu.Lock() + l.redactKeys = slices.Clone(keys) + l.mu.Unlock() +} + +func (l *Logger) shouldLog(level Level) bool { + l.mu.RLock() + defer l.mu.RUnlock() + return level <= l.level +} + +func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { + l.mu.RLock() + output := l.output + styleTimestamp := l.StyleTimestamp + redactKeys := l.redactKeys + l.mu.RUnlock() + + timestamp := styleTimestamp(time.Now().Format("15:04:05")) + + // Automatically extract context from error if present in keyvals + origLen := len(keyvals) + for i := 0; i < origLen; i += 2 { + if i+1 < origLen { + if err, ok := keyvals[i+1].(error); ok { + if op := Op(err); op != "" { + // Check if op is already in keyvals + hasOp := false + for j := 0; j < len(keyvals); j += 2 { + if k, ok := keyvals[j].(string); ok && k == "op" { + hasOp = true + break + } + } + if !hasOp { + keyvals = append(keyvals, "op", op) + } + } + if stack := FormatStackTrace(err); stack != "" { + // Check if stack is already in keyvals + hasStack := false + for j := 0; j < len(keyvals); j += 2 { + if k, ok := keyvals[j].(string); ok && k == "stack" { + hasStack = true + break + } + } + if !hasStack { + keyvals = append(keyvals, "stack", stack) + } + } + } + } + } + + // Format key-value pairs + var kvStr string + if len(keyvals) > 0 { + kvStr = " " + for i := 0; i < len(keyvals); i += 2 { + if i > 0 { + kvStr += " " + } + key := keyvals[i] + var val any + if i+1 < len(keyvals) { + val = keyvals[i+1] + } + + // Redaction logic + keyStr := fmt.Sprintf("%v", key) + if slices.Contains(redactKeys, keyStr) { + val = "[REDACTED]" + } + + // Secure formatting to prevent log injection + if s, ok := val.(string); ok { + kvStr += fmt.Sprintf("%v=%q", key, s) + } else { + kvStr += fmt.Sprintf("%v=%v", key, val) + } + } + } + + _, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, kvStr) +} + +// Debug logs a debug message with optional key-value pairs. +func (l *Logger) Debug(msg string, keyvals ...any) { + if l.shouldLog(LevelDebug) { + l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...) + } +} + +// Info logs an info message with optional key-value pairs. +func (l *Logger) Info(msg string, keyvals ...any) { + if l.shouldLog(LevelInfo) { + l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...) + } +} + +// Warn logs a warning message with optional key-value pairs. +func (l *Logger) Warn(msg string, keyvals ...any) { + if l.shouldLog(LevelWarn) { + l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...) + } +} + +// Error logs an error message with optional key-value pairs. +func (l *Logger) Error(msg string, keyvals ...any) { + if l.shouldLog(LevelError) { + l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...) + } +} + +// Security logs a security event with optional key-value pairs. +// It uses LevelError to ensure security events are visible even in restrictive +// log configurations. +func (l *Logger) Security(msg string, keyvals ...any) { + if l.shouldLog(LevelError) { + l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...) + } +} + +// Username returns the current system username. +// It uses os/user for reliability and falls back to environment variables. +func Username() string { + if u, err := user.Current(); err == nil { + return u.Username + } + // Fallback for environments where user lookup might fail + if u := os.Getenv("USER"); u != "" { + return u + } + return os.Getenv("USERNAME") +} + +// --- Default logger --- + +var defaultLogger = NewLogger(Options{Level: LevelInfo}) + +// Default returns the default logger. +func Default() *Logger { + return defaultLogger +} + +// SetDefault sets the default logger. +func SetDefault(l *Logger) { + defaultLogger = l +} + +// SetLevel sets the default logger's level. +func SetLevel(level Level) { + defaultLogger.SetLevel(level) +} + +// SetRedactKeys sets the default logger's redaction keys. +func SetRedactKeys(keys ...string) { + defaultLogger.SetRedactKeys(keys...) +} + +// Debug logs to the default logger. +func Debug(msg string, keyvals ...any) { + defaultLogger.Debug(msg, keyvals...) +} + +// Info logs to the default logger. +func Info(msg string, keyvals ...any) { + defaultLogger.Info(msg, keyvals...) +} + +// Warn logs to the default logger. +func Warn(msg string, keyvals ...any) { + defaultLogger.Warn(msg, keyvals...) +} + +// Error logs to the default logger. +func Error(msg string, keyvals ...any) { + defaultLogger.Error(msg, keyvals...) +} + +// Security logs to the default logger. +func Security(msg string, keyvals ...any) { + defaultLogger.Security(msg, keyvals...) +} diff --git a/pkg/log/log.go b/pkg/log/log.go deleted file mode 100644 index 14e5467..0000000 --- a/pkg/log/log.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package log re-exports go-log and provides framework integration (Service) -// and log rotation (RotatingWriter) that depend on core/go internals. -// -// New code should import forge.lthn.ai/core/go-log directly. -package log - -import ( - "io" - - golog "forge.lthn.ai/core/go-log" -) - -// Type aliases — all go-log types available as log.X -type ( - Level = golog.Level - Logger = golog.Logger - Options = golog.Options - RotationOptions = golog.RotationOptions - Err = golog.Err -) - -// Level constants. -const ( - LevelQuiet = golog.LevelQuiet - LevelError = golog.LevelError - LevelWarn = golog.LevelWarn - LevelInfo = golog.LevelInfo - LevelDebug = golog.LevelDebug -) - -func init() { - // Wire rotation into go-log: when go-log's New() gets RotationOptions, - // it calls this factory to create the RotatingWriter (which needs go-io). - golog.RotationWriterFactory = func(opts RotationOptions) io.WriteCloser { - return NewRotatingWriter(opts, nil) - } -} - -// --- Logging functions (re-exported from go-log) --- - -var ( - New = golog.New - Default = golog.Default - SetDefault = golog.SetDefault - SetLevel = golog.SetLevel - Debug = golog.Debug - Info = golog.Info - Warn = golog.Warn - Error = golog.Error - Security = golog.Security - Username = golog.Username -) - -// --- Error functions (re-exported from go-log) --- - -var ( - E = golog.E - Wrap = golog.Wrap - WrapCode = golog.WrapCode - NewCode = golog.NewCode - Is = golog.Is - As = golog.As - NewError = golog.NewError - Join = golog.Join - Op = golog.Op - ErrCode = golog.ErrCode - Message = golog.Message - Root = golog.Root - StackTrace = golog.StackTrace - FormatStackTrace = golog.FormatStackTrace - LogError = golog.LogError - LogWarn = golog.LogWarn - Must = golog.Must -) diff --git a/pkg/log/rotation.go b/pkg/log/rotation.go deleted file mode 100644 index f226640..0000000 --- a/pkg/log/rotation.go +++ /dev/null @@ -1,170 +0,0 @@ -package log - -import ( - "fmt" - "io" - "sync" - "time" - - coreio "forge.lthn.ai/core/go-io" -) - -// RotatingWriter implements io.WriteCloser and provides log rotation. -type RotatingWriter struct { - opts RotationOptions - medium coreio.Medium - mu sync.Mutex - file io.WriteCloser - size int64 -} - -// NewRotatingWriter creates a new RotatingWriter with the given options and medium. -func NewRotatingWriter(opts RotationOptions, m coreio.Medium) *RotatingWriter { - if m == nil { - m = coreio.Local - } - if opts.MaxSize <= 0 { - opts.MaxSize = 100 // 100 MB - } - if opts.MaxBackups <= 0 { - opts.MaxBackups = 5 - } - if opts.MaxAge == 0 { - opts.MaxAge = 28 // 28 days - } else if opts.MaxAge < 0 { - opts.MaxAge = 0 // disabled - } - - return &RotatingWriter{ - opts: opts, - medium: m, - } -} - -// Write writes data to the current log file, rotating it if necessary. -func (w *RotatingWriter) Write(p []byte) (n int, err error) { - w.mu.Lock() - defer w.mu.Unlock() - - if w.file == nil { - if err := w.openExistingOrNew(); err != nil { - return 0, err - } - } - - if w.size+int64(len(p)) > int64(w.opts.MaxSize)*1024*1024 { - if err := w.rotate(); err != nil { - return 0, err - } - } - - n, err = w.file.Write(p) - if err == nil { - w.size += int64(n) - } - return n, err -} - -// Close closes the current log file. -func (w *RotatingWriter) Close() error { - w.mu.Lock() - defer w.mu.Unlock() - return w.close() -} - -func (w *RotatingWriter) close() error { - if w.file == nil { - return nil - } - err := w.file.Close() - w.file = nil - return err -} - -func (w *RotatingWriter) openExistingOrNew() error { - info, err := w.medium.Stat(w.opts.Filename) - if err == nil { - w.size = info.Size() - f, err := w.medium.Append(w.opts.Filename) - if err != nil { - return err - } - w.file = f - return nil - } - - f, err := w.medium.Create(w.opts.Filename) - if err != nil { - return err - } - w.file = f - w.size = 0 - return nil -} - -func (w *RotatingWriter) rotate() error { - if err := w.close(); err != nil { - return err - } - - if err := w.rotateFiles(); err != nil { - // Try to reopen current file even if rotation failed - _ = w.openExistingOrNew() - return err - } - - if err := w.openExistingOrNew(); err != nil { - return err - } - - w.cleanup() - - return nil -} - -func (w *RotatingWriter) rotateFiles() error { - // Rotate existing backups: log.N -> log.N+1 - for i := w.opts.MaxBackups; i >= 1; i-- { - oldPath := w.backupPath(i) - newPath := w.backupPath(i + 1) - - if w.medium.Exists(oldPath) { - if i+1 > w.opts.MaxBackups { - _ = w.medium.Delete(oldPath) - } else { - _ = w.medium.Rename(oldPath, newPath) - } - } - } - - // log -> log.1 - return w.medium.Rename(w.opts.Filename, w.backupPath(1)) -} - -func (w *RotatingWriter) backupPath(n int) string { - return fmt.Sprintf("%s.%d", w.opts.Filename, n) -} - -func (w *RotatingWriter) cleanup() { - // 1. Remove backups beyond MaxBackups - // This is already partially handled by rotateFiles but we can be thorough - for i := w.opts.MaxBackups + 1; ; i++ { - path := w.backupPath(i) - if !w.medium.Exists(path) { - break - } - _ = w.medium.Delete(path) - } - - // 2. Remove backups older than MaxAge - if w.opts.MaxAge > 0 { - cutoff := time.Now().AddDate(0, 0, -w.opts.MaxAge) - for i := 1; i <= w.opts.MaxBackups; i++ { - path := w.backupPath(i) - info, err := w.medium.Stat(path) - if err == nil && info.ModTime().Before(cutoff) { - _ = w.medium.Delete(path) - } - } - } -} diff --git a/pkg/log/rotation_test.go b/pkg/log/rotation_test.go deleted file mode 100644 index 001fa8a..0000000 --- a/pkg/log/rotation_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package log - -import ( - "strings" - "testing" - "time" - - "forge.lthn.ai/core/go-io" -) - -func TestRotatingWriter_Basic(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, // 1 MB - MaxBackups: 3, - } - - w := NewRotatingWriter(opts, m) - defer w.Close() - - msg := "test message\n" - _, err := w.Write([]byte(msg)) - if err != nil { - t.Fatalf("failed to write: %v", err) - } - w.Close() - - content, err := m.Read("test.log") - if err != nil { - t.Fatalf("failed to read from medium: %v", err) - } - if content != msg { - t.Errorf("expected %q, got %q", msg, content) - } -} - -func TestRotatingWriter_Rotation(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, // 1 MB - MaxBackups: 2, - } - - w := NewRotatingWriter(opts, m) - defer w.Close() - - // 1. Write almost 1MB - largeMsg := strings.Repeat("a", 1024*1024-10) - _, _ = w.Write([]byte(largeMsg)) - - // 2. Write more to trigger rotation - _, _ = w.Write([]byte("trigger rotation\n")) - w.Close() - - // Check if test.log.1 exists and contains the large message - if !m.Exists("test.log.1") { - t.Error("expected test.log.1 to exist") - } - - // Check if test.log exists and contains the new message - content, _ := m.Read("test.log") - if !strings.Contains(content, "trigger rotation") { - t.Errorf("expected test.log to contain new message, got %q", content) - } -} - -func TestRotatingWriter_Retention(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, - MaxBackups: 2, - } - - w := NewRotatingWriter(opts, m) - defer w.Close() - - // Trigger rotation 4 times to test retention of only the latest backups - for i := 1; i <= 4; i++ { - _, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1))) - } - w.Close() - - // Should have test.log, test.log.1, test.log.2 - // test.log.3 should have been deleted because MaxBackups is 2 - if !m.Exists("test.log") { - t.Error("expected test.log to exist") - } - if !m.Exists("test.log.1") { - t.Error("expected test.log.1 to exist") - } - if !m.Exists("test.log.2") { - t.Error("expected test.log.2 to exist") - } - if m.Exists("test.log.3") { - t.Error("expected test.log.3 NOT to exist") - } -} - -func TestRotatingWriter_Append(t *testing.T) { - m := io.NewMockMedium() - _ = m.Write("test.log", "existing content\n") - - opts := RotationOptions{ - Filename: "test.log", - } - - w := NewRotatingWriter(opts, m) - _, _ = w.Write([]byte("new content\n")) - _ = w.Close() - - content, _ := m.Read("test.log") - expected := "existing content\nnew content\n" - if content != expected { - t.Errorf("expected %q, got %q", expected, content) - } -} - -func TestNewRotatingWriter_Defaults(t *testing.T) { - m := io.NewMockMedium() - - // MaxAge < 0 disables age-based cleanup - w := NewRotatingWriter(RotationOptions{ - Filename: "test.log", - MaxAge: -1, - }, m) - defer w.Close() - - if w.opts.MaxSize != 100 { - t.Errorf("expected default MaxSize 100, got %d", w.opts.MaxSize) - } - if w.opts.MaxBackups != 5 { - t.Errorf("expected default MaxBackups 5, got %d", w.opts.MaxBackups) - } - if w.opts.MaxAge != 0 { - t.Errorf("expected MaxAge 0 (disabled), got %d", w.opts.MaxAge) - } -} - -func TestRotatingWriter_RotateEndToEnd(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, // 1 MB - MaxBackups: 2, - } - - w := NewRotatingWriter(opts, m) - - // Write just under 1 MB - _, _ = w.Write([]byte(strings.Repeat("a", 1024*1024-10))) - - // Write more to trigger rotation - _, err := w.Write([]byte(strings.Repeat("b", 20))) - if err != nil { - t.Fatalf("write after rotation failed: %v", err) - } - w.Close() - - // Verify rotation happened - if !m.Exists("test.log.1") { - t.Error("expected test.log.1 after rotation") - } - - content, _ := m.Read("test.log") - if !strings.Contains(content, "bbb") { - t.Errorf("expected new data in test.log after rotation, got %q", content) - } -} - -func TestRotatingWriter_AgeRetention(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, - MaxBackups: 5, - MaxAge: 7, // 7 days - } - - w := NewRotatingWriter(opts, m) - - // Create some backup files - m.Write("test.log.1", "recent") - m.ModTimes["test.log.1"] = time.Now() - - m.Write("test.log.2", "old") - m.ModTimes["test.log.2"] = time.Now().AddDate(0, 0, -10) // 10 days old - - // Trigger rotation to run cleanup - _, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1))) - w.Close() - - if !m.Exists("test.log.1") { - t.Error("expected test.log.1 (now test.log.2) to exist as it's recent") - } - // Note: test.log.1 becomes test.log.2 after rotation, etc. - // But wait, my cleanup runs AFTER rotation. - // Initial state: - // test.log.1 (now) - // test.log.2 (-10d) - // Write triggers rotation: - // test.log -> test.log.1 - // test.log.1 -> test.log.2 - // test.log.2 -> test.log.3 - // Then cleanup runs: - // test.log.1 (now) - keep - // test.log.2 (now) - keep - // test.log.3 (-10d) - delete (since MaxAge is 7) - - if m.Exists("test.log.3") { - t.Error("expected test.log.3 to be deleted as it's too old") - } -} diff --git a/pkg/log/service.go b/pkg/log/service.go deleted file mode 100644 index 263a7b1..0000000 --- a/pkg/log/service.go +++ /dev/null @@ -1,57 +0,0 @@ -package log - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Service wraps Logger for Core framework integration. -type Service struct { - *core.ServiceRuntime[Options] - *Logger -} - -// NewService creates a log service factory for Core. -func NewService(opts Options) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - logger := New(opts) - - return &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - Logger: logger, - }, nil - } -} - -// OnStartup registers query and task handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -// QueryLevel returns the current log level. -type QueryLevel struct{} - -// TaskSetLevel changes the log level. -type TaskSetLevel struct { - Level Level -} - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q.(type) { - case QueryLevel: - return s.Level(), true, nil - } - return nil, false, nil -} - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch m := t.(type) { - case TaskSetLevel: - s.SetLevel(m.Level) - return nil, true, nil - } - return nil, false, nil -} diff --git a/pkg/log/service_test.go b/pkg/log/service_test.go deleted file mode 100644 index fd329a1..0000000 --- a/pkg/log/service_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package log - -import ( - "context" - "testing" - - "forge.lthn.ai/core/go/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewService_Good(t *testing.T) { - opts := Options{Level: LevelInfo} - factory := NewService(opts) - - c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) { - return factory(cc) - })) - require.NoError(t, err) - - svc := c.Service("log") - require.NotNil(t, svc) - - logSvc, ok := svc.(*Service) - require.True(t, ok) - assert.NotNil(t, logSvc.Logger) - assert.NotNil(t, logSvc.ServiceRuntime) -} - -func TestService_OnStartup_Good(t *testing.T) { - opts := Options{Level: LevelInfo} - factory := NewService(opts) - - c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) { - return factory(cc) - })) - require.NoError(t, err) - - svc := c.Service("log").(*Service) - - err = svc.OnStartup(context.Background()) - assert.NoError(t, err) -} - -func TestService_QueryLevel_Good(t *testing.T) { - opts := Options{Level: LevelDebug} - factory := NewService(opts) - - c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) { - return factory(cc) - })) - require.NoError(t, err) - - svc := c.Service("log").(*Service) - err = svc.OnStartup(context.Background()) - require.NoError(t, err) - - result, handled, err := c.QUERY(QueryLevel{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, LevelDebug, result) -} - -func TestService_QueryLevel_Bad(t *testing.T) { - opts := Options{Level: LevelInfo} - factory := NewService(opts) - - c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) { - return factory(cc) - })) - require.NoError(t, err) - - svc := c.Service("log").(*Service) - err = svc.OnStartup(context.Background()) - require.NoError(t, err) - - // Unknown query type should not be handled - result, handled, err := c.QUERY("unknown") - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestService_TaskSetLevel_Good(t *testing.T) { - opts := Options{Level: LevelInfo} - factory := NewService(opts) - - c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) { - return factory(cc) - })) - require.NoError(t, err) - - svc := c.Service("log").(*Service) - err = svc.OnStartup(context.Background()) - require.NoError(t, err) - - // Change level via task - _, handled, err := c.PERFORM(TaskSetLevel{Level: LevelError}) - assert.NoError(t, err) - assert.True(t, handled) - - // Verify level changed via query - result, handled, err := c.QUERY(QueryLevel{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, LevelError, result) -} - -func TestService_TaskSetLevel_Bad(t *testing.T) { - opts := Options{Level: LevelInfo} - factory := NewService(opts) - - c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) { - return factory(cc) - })) - require.NoError(t, err) - - svc := c.Service("log").(*Service) - err = svc.OnStartup(context.Background()) - require.NoError(t, err) - - // Unknown task type should not be handled - _, handled, err := c.PERFORM("unknown") - assert.NoError(t, err) - assert.False(t, handled) -}