From 629141e27934c6ab2d09dcb1b6592e212bc4b64b Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 22:02:40 +0000 Subject: [PATCH] feat(pkg): add standalone log and errors packages Extract logging to pkg/log for use outside CLI: - Logger with Debug/Info/Warn/Error levels - Key-value pairs for structured logging - Customisable styling and output - Optional Core framework integration via Service Enhance pkg/errors with: - Wrap() and WrapCode() helpers - Code() for error codes - Op(), ErrCode(), Message(), Root() extractors - Standard library wrappers (Is, As, New, Join) Update pkg/cli/log.go to use pkg/log with CLI styling. Co-Authored-By: Claude Opus 4.5 --- pkg/cli/log.go | 152 +++++---------------------- pkg/errors/errors.go | 147 +++++++++++++++++++++++--- pkg/errors/errors_test.go | 182 ++++++++++++++++++++++++++++++++ pkg/log/log.go | 213 ++++++++++++++++++++++++++++++++++++++ pkg/log/log_test.go | 124 ++++++++++++++++++++++ pkg/log/service.go | 57 ++++++++++ 6 files changed, 739 insertions(+), 136 deletions(-) create mode 100644 pkg/errors/errors_test.go create mode 100644 pkg/log/log.go create mode 100644 pkg/log/log_test.go create mode 100644 pkg/log/service.go diff --git a/pkg/cli/log.go b/pkg/cli/log.go index 5b3473e8..8b81dd7b 100644 --- a/pkg/cli/log.go +++ b/pkg/cli/log.go @@ -1,145 +1,49 @@ package cli import ( - "context" - "fmt" - "io" - "os" - "sync" - "time" - "github.com/host-uk/core/pkg/framework" + "github.com/host-uk/core/pkg/log" ) -// LogLevel defines logging verbosity. -type LogLevel int +// LogLevel aliases for backwards compatibility. +type LogLevel = log.Level const ( - LogLevelQuiet LogLevel = iota - LogLevelError - LogLevelWarn - LogLevelInfo - LogLevelDebug + LogLevelQuiet = log.LevelQuiet + LogLevelError = log.LevelError + LogLevelWarn = log.LevelWarn + LogLevelInfo = log.LevelInfo + LogLevelDebug = log.LevelDebug ) -// LogService provides structured logging for the CLI. +// LogService wraps log.Service with CLI styling. type LogService struct { - *framework.ServiceRuntime[LogOptions] - mu sync.RWMutex - level LogLevel - output io.Writer + *log.Service } // LogOptions configures the log service. -type LogOptions struct { - Level LogLevel - Output io.Writer // defaults to os.Stderr -} +type LogOptions = log.Options -// NewLogService creates a log service factory. +// NewLogService creates a log service factory with CLI styling. func NewLogService(opts LogOptions) func(*framework.Core) (any, error) { return func(c *framework.Core) (any, error) { - output := opts.Output - if output == nil { - output = os.Stderr + // Create the underlying service + factory := log.NewService(opts) + svc, err := factory(c) + if err != nil { + return nil, err } - return &LogService{ - ServiceRuntime: framework.NewServiceRuntime(c, opts), - level: opts.Level, - output: output, - }, nil - } -} + logSvc := svc.(*log.Service) -// OnStartup registers query handlers. -func (s *LogService) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} + // Apply CLI styles + logSvc.StyleTimestamp = func(s string) string { return DimStyle.Render(s) } + logSvc.StyleDebug = func(s string) string { return DimStyle.Render(s) } + logSvc.StyleInfo = func(s string) string { return InfoStyle.Render(s) } + logSvc.StyleWarn = func(s string) string { return WarningStyle.Render(s) } + logSvc.StyleError = func(s string) string { return ErrorStyle.Render(s) } -// Queries and tasks for log service - -// QueryLogLevel returns the current log level. -type QueryLogLevel struct{} - -// TaskSetLogLevel changes the log level. -type TaskSetLogLevel struct { - Level LogLevel -} - -func (s *LogService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) { - switch q.(type) { - case QueryLogLevel: - s.mu.RLock() - defer s.mu.RUnlock() - return s.level, true, nil - } - return nil, false, nil -} - -func (s *LogService) handleTask(c *framework.Core, t framework.Task) (any, bool, error) { - switch m := t.(type) { - case TaskSetLogLevel: - s.mu.Lock() - s.level = m.Level - s.mu.Unlock() - return nil, true, nil - } - return nil, false, nil -} - -// SetLevel changes the log level. -func (s *LogService) SetLevel(level LogLevel) { - s.mu.Lock() - s.level = level - s.mu.Unlock() -} - -// Level returns the current log level. -func (s *LogService) Level() LogLevel { - s.mu.RLock() - defer s.mu.RUnlock() - return s.level -} - -func (s *LogService) shouldLog(level LogLevel) bool { - s.mu.RLock() - defer s.mu.RUnlock() - return level <= s.level -} - -func (s *LogService) log(level, prefix, msg string) { - timestamp := time.Now().Format("15:04:05") - fmt.Fprintf(s.output, "%s %s %s\n", DimStyle.Render(timestamp), prefix, msg) -} - -// Debug logs a debug message. -func (s *LogService) Debug(msg string) { - if s.shouldLog(LogLevelDebug) { - s.log("debug", DimStyle.Render("[DBG]"), msg) - } -} - -// Infof logs an info message. -func (s *LogService) Infof(msg string) { - if s.shouldLog(LogLevelInfo) { - s.log("info", InfoStyle.Render("[INF]"), msg) - } -} - -// Warnf logs a warning message. -func (s *LogService) Warnf(msg string) { - if s.shouldLog(LogLevelWarn) { - s.log("warn", WarningStyle.Render("[WRN]"), msg) - } -} - -// Errorf logs an error message. -func (s *LogService) Errorf(msg string) { - if s.shouldLog(LogLevelError) { - s.log("error", ErrorStyle.Render("[ERR]"), msg) + return &LogService{Service: logSvc}, nil } } @@ -167,20 +71,20 @@ func LogDebug(msg string) { // LogInfo logs an info message if log service is available. func LogInfo(msg string) { if l := Log(); l != nil { - l.Infof(msg) + l.Info(msg) } } // LogWarn logs a warning message if log service is available. func LogWarn(msg string) { if l := Log(); l != nil { - l.Warnf(msg) + l.Warn(msg) } } // LogError logs an error message if log service is available. func LogError(msg string) { if l := Log(); l != nil { - l.Errorf(msg) + l.Error(msg) } } diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 8635a7cf..19741d13 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -1,28 +1,151 @@ -// Package errors provides standardized error handling for the Core CLI. +// Package errors provides structured error handling for Core applications. +// +// Errors include operational context (what was being done) and support +// error wrapping for debugging while keeping user-facing messages clean: +// +// err := errors.E("user.Create", "email already exists", nil) +// err := errors.Wrap(dbErr, "user.Create", "failed to save user") +// +// // Check error types +// if errors.Is(err, sql.ErrNoRows) { ... } +// +// // Extract operation +// var e *errors.Error +// if errors.As(err, &e) { +// fmt.Println("Operation:", e.Op) +// } package errors -import "fmt" +import ( + stderrors "errors" + "fmt" +) -// Error represents a standardized error with operational context. +// Error represents a structured error with operational context. type Error struct { - Op string // Operation being performed - Msg string // Human-readable message - Err error // Underlying error + Op string // Operation being performed (e.g., "user.Create") + Msg string // Human-readable message + Err error // Underlying error (optional) + Code string // Error code for i18n/categorisation (optional) } // E creates a new Error with operation context. +// +// err := errors.E("config.Load", "file not found", os.ErrNotExist) +// err := errors.E("api.Call", "rate limited", nil) func E(op, msg string, err error) error { + return &Error{Op: op, Msg: msg, Err: err} +} + +// Wrap wraps an error with operation context. +// Returns nil if err is nil. +// +// return errors.Wrap(err, "db.Query", "failed to fetch user") +func Wrap(err error, op, msg string) error { if err == nil { - return &Error{Op: op, Msg: msg} + return nil } return &Error{Op: op, Msg: msg, Err: err} } -func (e *Error) Error() string { - if e.Err != nil { - return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) +// WrapCode wraps an error with operation context and an error code. +// +// return errors.WrapCode(err, "ERR_NOT_FOUND", "user.Get", "user not found") +func WrapCode(err error, code, op, msg string) error { + if err == nil && code == "" { + return nil } - return fmt.Sprintf("%s: %s", e.Op, e.Msg) + return &Error{Op: op, Msg: msg, Err: err, Code: code} } -func (e *Error) Unwrap() error { return e.Err } +// Code creates an error with just a code and message. +// +// return errors.Code("ERR_VALIDATION", "invalid email format") +func Code(code, msg string) error { + return &Error{Code: code, Msg: msg} +} + +// Error returns the error message. +func (e *Error) Error() string { + if e.Op != "" && e.Err != nil { + return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) + } + if e.Op != "" { + return fmt.Sprintf("%s: %s", e.Op, e.Msg) + } + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Msg, e.Err) + } + return e.Msg +} + +// Unwrap returns the underlying error. +func (e *Error) Unwrap() error { + return e.Err +} + +// --- Standard library wrappers --- + +// Is reports whether any error in err's tree matches target. +func Is(err, target error) bool { + return stderrors.Is(err, target) +} + +// As finds the first error in err's tree that matches target. +func As(err error, target any) bool { + return stderrors.As(err, target) +} + +// New returns an error with the given text. +func New(text string) error { + return stderrors.New(text) +} + +// Join returns an error that wraps the given errors. +func Join(errs ...error) error { + return stderrors.Join(errs...) +} + +// --- Helper functions --- + +// Op extracts the operation from an error, or empty string if not an Error. +func Op(err error) string { + var e *Error + if As(err, &e) { + return e.Op + } + return "" +} + +// ErrCode extracts the error code, or empty string if not set. +func ErrCode(err error) string { + var e *Error + if As(err, &e) { + return e.Code + } + return "" +} + +// Message extracts the message from an error. +// For Error types, returns Msg; otherwise returns err.Error(). +func Message(err error) string { + if err == nil { + return "" + } + var e *Error + if As(err, &e) { + return e.Msg + } + return err.Error() +} + +// Root returns the deepest error in the chain. +func Root(err error) error { + for { + unwrapped := stderrors.Unwrap(err) + if unwrapped == nil { + return err + } + err = unwrapped + } +} diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go new file mode 100644 index 00000000..383c3c32 --- /dev/null +++ b/pkg/errors/errors_test.go @@ -0,0 +1,182 @@ +package errors + +import ( + "io" + "testing" +) + +func TestE(t *testing.T) { + err := E("user.Create", "validation failed", nil) + + if err.Error() != "user.Create: validation failed" { + t.Errorf("unexpected error message: %s", err.Error()) + } +} + +func TestE_WithUnderlying(t *testing.T) { + underlying := New("database connection failed") + err := E("user.Create", "failed to save", underlying) + + if err.Error() != "user.Create: failed to save: database connection failed" { + t.Errorf("unexpected error message: %s", err.Error()) + } +} + +func TestWrap(t *testing.T) { + // Wrap nil returns nil + if Wrap(nil, "op", "msg") != nil { + t.Error("expected Wrap(nil) to return nil") + } + + // Wrap error + underlying := New("original") + err := Wrap(underlying, "user.Get", "failed") + + if !Is(err, underlying) { + t.Error("expected wrapped error to match underlying") + } +} + +func TestWrapCode(t *testing.T) { + underlying := New("not found") + err := WrapCode(underlying, "ERR_NOT_FOUND", "user.Get", "user not found") + + var e *Error + if !As(err, &e) { + t.Fatal("expected error to be *Error") + } + + if e.Code != "ERR_NOT_FOUND" { + t.Errorf("expected code ERR_NOT_FOUND, got %s", e.Code) + } +} + +func TestCode(t *testing.T) { + err := Code("ERR_VALIDATION", "invalid email") + + var e *Error + if !As(err, &e) { + t.Fatal("expected error to be *Error") + } + + if e.Code != "ERR_VALIDATION" { + t.Errorf("expected code ERR_VALIDATION, got %s", e.Code) + } + if e.Msg != "invalid email" { + t.Errorf("expected msg 'invalid email', got %s", e.Msg) + } +} + +func TestIs(t *testing.T) { + err := Wrap(io.EOF, "read", "failed") + + if !Is(err, io.EOF) { + t.Error("expected Is to find io.EOF in chain") + } + + if Is(err, io.ErrClosedPipe) { + t.Error("expected Is to not find io.ErrClosedPipe") + } +} + +func TestAs(t *testing.T) { + err := E("test.Op", "test message", nil) + + var e *Error + if !As(err, &e) { + t.Fatal("expected As to find *Error") + } + + if e.Op != "test.Op" { + t.Errorf("expected Op 'test.Op', got %s", e.Op) + } +} + +func TestOp(t *testing.T) { + err := E("user.Create", "failed", nil) + + if Op(err) != "user.Create" { + t.Errorf("expected Op 'user.Create', got %s", Op(err)) + } + + // Non-Error returns empty string + if Op(New("plain error")) != "" { + t.Error("expected empty Op for non-Error") + } +} + +func TestErrCode(t *testing.T) { + err := Code("ERR_TEST", "test") + + if ErrCode(err) != "ERR_TEST" { + t.Errorf("expected code ERR_TEST, got %s", ErrCode(err)) + } + + // Non-Error returns empty string + if ErrCode(New("plain error")) != "" { + t.Error("expected empty code for non-Error") + } +} + +func TestMessage(t *testing.T) { + err := E("op", "the message", nil) + + if Message(err) != "the message" { + t.Errorf("expected 'the message', got %s", Message(err)) + } + + // Plain error returns full error string + plain := New("plain error") + if Message(plain) != "plain error" { + t.Errorf("expected 'plain error', got %s", Message(plain)) + } + + // Nil returns empty string + if Message(nil) != "" { + t.Error("expected empty string for nil") + } +} + +func TestRoot(t *testing.T) { + root := New("root cause") + mid := Wrap(root, "mid", "middle") + top := Wrap(mid, "top", "top level") + + if Root(top) != root { + t.Error("expected Root to return deepest error") + } + + // Single error returns itself + single := New("single") + if Root(single) != single { + t.Error("expected Root of single error to return itself") + } +} + +func TestError_Unwrap(t *testing.T) { + underlying := New("underlying") + err := E("op", "msg", underlying) + + var e *Error + if !As(err, &e) { + t.Fatal("expected *Error") + } + + if e.Unwrap() != underlying { + t.Error("expected Unwrap to return underlying error") + } +} + +func TestJoin(t *testing.T) { + err1 := New("error 1") + err2 := New("error 2") + + joined := Join(err1, err2) + + if !Is(joined, err1) { + t.Error("expected joined error to contain err1") + } + if !Is(joined, err2) { + t.Error("expected joined error to contain err2") + } +} diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 00000000..d308cfcc --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,213 @@ +// Package log provides structured logging for Core applications. +// +// The package works standalone or integrated with the Core framework: +// +// // Standalone usage +// log.SetLevel(log.LevelDebug) +// log.Info("server started", "port", 8080) +// log.Error("failed to connect", "err", err) +// +// // With Core framework +// core.New( +// framework.WithName("log", log.NewService(log.Options{Level: log.LevelInfo})), +// ) +package log + +import ( + "fmt" + "io" + "os" + "sync" + "time" +) + +// Level defines logging verbosity. +type Level int + +const ( + LevelQuiet Level = iota + LevelError + LevelWarn + LevelInfo + 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 io.Writer + + // 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 +} + +// Options configures a Logger. +type Options struct { + Level Level + Output io.Writer // defaults to os.Stderr +} + +// New creates a new Logger with the given options. +func New(opts Options) *Logger { + output := opts.Output + if output == nil { + output = os.Stderr + } + + return &Logger{ + level: opts.Level, + output: output, + StyleTimestamp: identity, + StyleDebug: identity, + StyleInfo: identity, + StyleWarn: identity, + StyleError: 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 io.Writer) { + l.mu.Lock() + l.output = w + 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 + l.mu.RUnlock() + + timestamp := styleTimestamp(time.Now().Format("15:04:05")) + + // 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] + } + 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...) + } +} + +// --- Default logger --- + +var defaultLogger = New(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) +} + +// 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...) +} diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go new file mode 100644 index 00000000..6721e395 --- /dev/null +++ b/pkg/log/log_test.go @@ -0,0 +1,124 @@ +package log + +import ( + "bytes" + "strings" + "testing" +) + +func TestLogger_Levels(t *testing.T) { + tests := []struct { + name string + level Level + logFunc func(*Logger, string, ...any) + expected bool + }{ + {"debug at debug", LevelDebug, (*Logger).Debug, true}, + {"info at debug", LevelDebug, (*Logger).Info, true}, + {"warn at debug", LevelDebug, (*Logger).Warn, true}, + {"error at debug", LevelDebug, (*Logger).Error, true}, + + {"debug at info", LevelInfo, (*Logger).Debug, false}, + {"info at info", LevelInfo, (*Logger).Info, true}, + {"warn at info", LevelInfo, (*Logger).Warn, true}, + {"error at info", LevelInfo, (*Logger).Error, true}, + + {"debug at warn", LevelWarn, (*Logger).Debug, false}, + {"info at warn", LevelWarn, (*Logger).Info, false}, + {"warn at warn", LevelWarn, (*Logger).Warn, true}, + {"error at warn", LevelWarn, (*Logger).Error, true}, + + {"debug at error", LevelError, (*Logger).Debug, false}, + {"info at error", LevelError, (*Logger).Info, false}, + {"warn at error", LevelError, (*Logger).Warn, false}, + {"error at error", LevelError, (*Logger).Error, true}, + + {"debug at quiet", LevelQuiet, (*Logger).Debug, false}, + {"info at quiet", LevelQuiet, (*Logger).Info, false}, + {"warn at quiet", LevelQuiet, (*Logger).Warn, false}, + {"error at quiet", LevelQuiet, (*Logger).Error, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: tt.level, Output: &buf}) + tt.logFunc(l, "test message") + + hasOutput := buf.Len() > 0 + if hasOutput != tt.expected { + t.Errorf("expected output=%v, got output=%v", tt.expected, hasOutput) + } + }) + } +} + +func TestLogger_KeyValues(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelDebug, Output: &buf}) + + l.Info("test message", "key1", "value1", "key2", 42) + + output := buf.String() + if !strings.Contains(output, "test message") { + t.Error("expected message in output") + } + if !strings.Contains(output, "key1=value1") { + t.Error("expected key1=value1 in output") + } + if !strings.Contains(output, "key2=42") { + t.Error("expected key2=42 in output") + } +} + +func TestLogger_SetLevel(t *testing.T) { + l := New(Options{Level: LevelInfo}) + + if l.Level() != LevelInfo { + t.Error("expected initial level to be Info") + } + + l.SetLevel(LevelDebug) + if l.Level() != LevelDebug { + t.Error("expected level to be Debug after SetLevel") + } +} + +func TestLevel_String(t *testing.T) { + tests := []struct { + level Level + expected string + }{ + {LevelQuiet, "quiet"}, + {LevelError, "error"}, + {LevelWarn, "warn"}, + {LevelInfo, "info"}, + {LevelDebug, "debug"}, + {Level(99), "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if got := tt.level.String(); got != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func TestDefault(t *testing.T) { + // Default logger should exist + if Default() == nil { + t.Error("expected default logger to exist") + } + + // Package-level functions should work + var buf bytes.Buffer + l := New(Options{Level: LevelDebug, Output: &buf}) + SetDefault(l) + + Info("test") + if buf.Len() == 0 { + t.Error("expected package-level Info to produce output") + } +} diff --git a/pkg/log/service.go b/pkg/log/service.go new file mode 100644 index 00000000..ec2103d8 --- /dev/null +++ b/pkg/log/service.go @@ -0,0 +1,57 @@ +package log + +import ( + "context" + + "github.com/host-uk/core/pkg/framework" +) + +// Service wraps Logger for Core framework integration. +type Service struct { + *framework.ServiceRuntime[Options] + *Logger +} + +// NewService creates a log service factory for Core. +func NewService(opts Options) func(*framework.Core) (any, error) { + return func(c *framework.Core) (any, error) { + logger := New(opts) + + return &Service{ + ServiceRuntime: framework.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 *framework.Core, q framework.Query) (any, bool, error) { + switch q.(type) { + case QueryLevel: + return s.Level(), true, nil + } + return nil, false, nil +} + +func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) { + switch m := t.(type) { + case TaskSetLevel: + s.SetLevel(m.Level) + return nil, true, nil + } + return nil, false, nil +}