From e1294b84128afded46a3164ad5d3c95e886e58b5 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 15 Mar 2026 15:44:39 +0000 Subject: [PATCH 01/31] chore: sync workspace dependencies Co-Authored-By: Virgil --- .claude/settings.json | 5 +++++ .mcp.json | 9 +++++++++ 2 files changed, 14 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .mcp.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0a65def --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "code@core-agent": true + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..816cdd4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "core": { + "type": "stdio", + "command": "core-mcp", + "args": ["mcp", "serve"] + } + } +} From d64099b02824770c7bcac2c191e4d383a2c2a4e2 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 01:31:19 +0000 Subject: [PATCH 02/31] feat(core): add LocaleProvider interface for automatic i18n collection Services implementing LocaleProvider have their locale FS collected during RegisterService. The i18n service reads Core.Locales() on startup to load all translations. Zero explicit wiring needed. Co-Authored-By: Virgil --- pkg/core/core.go | 6 ++++++ pkg/core/interfaces.go | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/pkg/core/core.go b/pkg/core/core.go index eb7c64b..99b9e37 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -295,7 +295,13 @@ func (c *Core) RegisterTask(handler TaskHandler) { } // RegisterService adds a new service to the Core. +// If the service implements LocaleProvider, its locale FS is collected +// for the i18n service to load during startup. func (c *Core) RegisterService(name string, api any) error { + // Collect locale filesystems from services that provide them + if lp, ok := api.(LocaleProvider); ok { + c.locales = append(c.locales, lp.Locales()) + } return c.svc.registerService(name, api) } diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go index 036b4b2..f8b4ad4 100644 --- a/pkg/core/interfaces.go +++ b/pkg/core/interfaces.go @@ -4,6 +4,7 @@ import ( "context" "embed" goio "io" + "io/fs" "slices" "sync" "sync/atomic" @@ -77,6 +78,20 @@ type Stoppable interface { OnShutdown(ctx context.Context) error } +// LocaleProvider is implemented by services that ship their own translation files. +// Core discovers this interface during service registration and collects the +// locale filesystems. The i18n service loads them during startup. +// +// Usage in a service package: +// +// //go:embed locales +// var localeFS embed.FS +// +// func (s *MyService) Locales() fs.FS { return localeFS } +type LocaleProvider interface { + Locales() fs.FS +} + // Core is the central application object that manages services, assets, and communication. type Core struct { App any // GUI runtime (e.g., Wails App) - set by WithApp option @@ -84,12 +99,19 @@ type Core struct { Features *Features svc *serviceManager bus *messageBus + locales []fs.FS // collected from LocaleProvider services taskIDCounter atomic.Uint64 wg sync.WaitGroup shutdown atomic.Bool } +// Locales returns all locale filesystems collected from registered services. +// The i18n service uses this during startup to load translations. +func (c *Core) Locales() []fs.FS { + return c.locales +} + // Config provides access to application configuration. type Config interface { // Get retrieves a configuration value by key and stores it in the 'out' variable. From f4e27010183e621b0323dc9b81b076bf9c7ca0de Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 01:45:42 +0000 Subject: [PATCH 03/31] chore: save LocaleProvider and Locales changes Co-Authored-By: Virgil --- .claude/settings.json | 2 +- .mcp.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 0a65def..9f474d7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { "enabledPlugins": { - "code@core-agent": true + } } diff --git a/.mcp.json b/.mcp.json index 816cdd4..fe40be8 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,8 +2,8 @@ "mcpServers": { "core": { "type": "stdio", - "command": "core-mcp", - "args": ["mcp", "serve"] + "command": "core-agent", + "args": ["mcp"] } } } From 29e6d066332bd05eea2d813970b58bda5d29b7b1 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 08:03:05 +0000 Subject: [PATCH 04/31] fix(core): replace fmt.Errorf with structured errors, add log service tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all fmt.Errorf calls with coreerr.E() from go-log for structured error context (op, msg, underlying error) across core.go, service_manager.go, and runtime_pkg.go (12 violations fixed) - Replace local Error type and E() in e.go with re-exports from go-log, eliminating duplicate implementation while preserving public API - Add comprehensive tests for pkg/log Service (NewService, OnStartup, QueryLevel, TaskSetLevel) — coverage 72.2% → 87.8% - Update CLAUDE.md: Go 1.25 → 1.26, runtime.go → runtime_pkg.go, document go-log error convention - No os.ReadFile/os.WriteFile violations found (all I/O uses go-io) Co-Authored-By: Virgil --- CLAUDE.md | 9 +-- pkg/core/core.go | 16 ++--- pkg/core/e.go | 65 +++++-------------- pkg/core/fuzz_test.go | 4 +- pkg/core/runtime_pkg.go | 4 +- pkg/core/service_manager.go | 4 +- pkg/log/rotation_test.go | 52 +++++++++++++++ pkg/log/service_test.go | 126 ++++++++++++++++++++++++++++++++++++ 8 files changed, 213 insertions(+), 67 deletions(-) create mode 100644 pkg/log/service_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 09471af..243018c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ core.New(core.WithService(NewMyService)) - `WithService`: Auto-discovers service name from package path, registers IPC handler if service has `HandleIPCEvents` method - `WithName`: Explicitly names a service -### ServiceRuntime Generic Helper (`runtime.go`) +### ServiceRuntime Generic Helper (`runtime_pkg.go`) Embed `ServiceRuntime[T]` in services to get access to Core and typed options: ```go @@ -77,11 +77,12 @@ type MyService struct { } ``` -### Error Handling (`e.go`) +### Error Handling (go-log) -Use the `E()` helper for contextual errors: +All errors MUST use `E()` from `go-log` (re-exported in `e.go`), never `fmt.Errorf`: ```go return core.E("service.Method", "what failed", underlyingErr) +return core.E("service.Method", fmt.Sprintf("service %q not found", name), nil) ``` ### Test Naming Convention @@ -100,6 +101,6 @@ Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern: ## Go Workspace -Uses Go 1.25 workspaces. This module is part of the workspace at `~/Code/go.work`. +Uses Go 1.26 workspaces. This module is part of the workspace at `~/Code/go.work`. After adding modules: `go work sync` diff --git a/pkg/core/core.go b/pkg/core/core.go index 99b9e37..52041cf 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -64,10 +64,10 @@ func WithService(factory func(*Core) (any, error)) Option { serviceInstance, err := factory(c) if err != nil { - return fmt.Errorf("core: failed to create service: %w", err) + return E("core.WithService", "failed to create service", err) } if serviceInstance == nil { - return fmt.Errorf("core: service factory returned nil instance") + return E("core.WithService", "service factory returned nil instance", nil) } // --- Service Name Discovery --- @@ -79,7 +79,7 @@ func WithService(factory func(*Core) (any, error)) Option { parts := strings.Split(pkgPath, "/") name := strings.ToLower(parts[len(parts)-1]) if name == "" { - return fmt.Errorf("core: service name could not be discovered for type %T (PkgPath is empty)", serviceInstance) + return E("core.WithService", fmt.Sprintf("service name could not be discovered for type %T (PkgPath is empty)", serviceInstance), nil) } // --- IPC Handler Discovery --- @@ -89,7 +89,7 @@ func WithService(factory func(*Core) (any, error)) Option { if handler, ok := handlerMethod.Interface().(func(*Core, Message) error); ok { c.RegisterAction(handler) } else { - return fmt.Errorf("core: service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name) + return E("core.WithService", fmt.Sprintf("service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name), nil) } } @@ -107,7 +107,7 @@ func WithName(name string, factory func(*Core) (any, error)) Option { return func(c *Core) error { serviceInstance, err := factory(c) if err != nil { - return fmt.Errorf("core: failed to create service '%s': %w", name, err) + return E("core.WithName", fmt.Sprintf("failed to create service %q", name), err) } return c.RegisterService(name, serviceInstance) } @@ -259,7 +259,7 @@ func (c *Core) PerformAsync(t Task) string { c.wg.Go(func() { result, handled, err := c.PERFORM(t) if !handled && err == nil { - err = fmt.Errorf("no handler found for task type %T", t) + err = E("core.PerformAsync", fmt.Sprintf("no handler found for task type %T", t), nil) } // Broadcast task completed @@ -316,11 +316,11 @@ func ServiceFor[T any](c *Core, name string) (T, error) { var zero T raw := c.Service(name) if raw == nil { - return zero, fmt.Errorf("service '%s' not found", name) + return zero, E("core.ServiceFor", fmt.Sprintf("service %q not found", name), nil) } typed, ok := raw.(T) if !ok { - return zero, fmt.Errorf("service '%s' is of type %T, but expected %T", name, raw, zero) + return zero, E("core.ServiceFor", fmt.Sprintf("service %q is type %T, expected %T", name, raw, zero), nil) } return typed, nil } diff --git a/pkg/core/e.go b/pkg/core/e.go index edd2028..a124696 100644 --- a/pkg/core/e.go +++ b/pkg/core/e.go @@ -1,59 +1,26 @@ -// Package core provides a standardized error handling mechanism for the Core library. -// It allows for wrapping errors with contextual information, making it easier to -// trace the origin of an error and provide meaningful feedback. +// Package core re-exports the structured error types from go-log. // -// The design of this package is influenced by the need for a simple, yet powerful -// way to handle errors that can occur in different layers of the application, -// from low-level file operations to high-level service interactions. +// 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. // -// The key features of this package are: -// - Error wrapping: The Op and an optional Msg field provide context about -// where and why an error occurred. -// - Stack traces: By wrapping errors, we can build a logical stack trace -// that is more informative than a raw stack trace. -// - Consistent error handling: Encourages a uniform approach to error -// handling across the entire codebase. +// Example: +// +// return core.E("config.Load", "failed to load config file", err) package core import ( - "fmt" + coreerr "forge.lthn.ai/core/go-log" ) -// Error represents a standardized error with operational context. -type Error struct { - // Op is the operation being performed, e.g., "config.Load". - Op string - // Msg is a human-readable message explaining the error. - Msg string - // Err is the underlying error that was wrapped. - Err error -} +// 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 is a helper function to create a new Error. -// This is the primary way to create errors that will be consumed by the system. -// For example: -// -// return e.E("config.Load", "failed to load config file", 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 that can be displayed to the user. -// The 'err' parameter is the underlying error that is being wrapped. -func E(op, msg string, err error) error { - if err == nil { - return &Error{Op: op, Msg: msg} - } - return &Error{Op: op, Msg: msg, Err: err} -} - -// Error returns the string representation of the error. -func (e *Error) Error() string { - if e.Err != nil { - return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) - } - return fmt.Sprintf("%s: %s", e.Op, e.Msg) -} - -// Unwrap provides compatibility for Go's errors.Is and errors.As functions. -func (e *Error) Unwrap() error { - return e.Err -} +// 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/fuzz_test.go b/pkg/core/fuzz_test.go index 93972e0..8bbee0e 100644 --- a/pkg/core/fuzz_test.go +++ b/pkg/core/fuzz_test.go @@ -23,8 +23,8 @@ func FuzzE(f *testing.F) { } s := e.Error() - if s == "" { - t.Fatal("Error() returned empty string") + if s == "" && (op != "" || msg != "") { + t.Fatal("Error() returned empty string for non-empty op/msg") } // Round-trip: Unwrap should return the underlying error diff --git a/pkg/core/runtime_pkg.go b/pkg/core/runtime_pkg.go index 7071e9c..0c78556 100644 --- a/pkg/core/runtime_pkg.go +++ b/pkg/core/runtime_pkg.go @@ -64,11 +64,11 @@ func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, e for _, name := range names { factory := factories[name] if factory == nil { - return nil, fmt.Errorf("failed to create service %s: factory is nil", name) + return nil, E("core.NewWithFactories", fmt.Sprintf("factory is nil for service %q", name), nil) } svc, err := factory() if err != nil { - return nil, fmt.Errorf("failed to create service %s: %w", name, err) + return nil, E("core.NewWithFactories", fmt.Sprintf("failed to create service %q", name), err) } svcCopy := svc coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil })) diff --git a/pkg/core/service_manager.go b/pkg/core/service_manager.go index 0105cf7..95fe85f 100644 --- a/pkg/core/service_manager.go +++ b/pkg/core/service_manager.go @@ -34,10 +34,10 @@ func (m *serviceManager) registerService(name string, svc any) error { m.mu.Lock() defer m.mu.Unlock() if m.locked { - return fmt.Errorf("core: service %q is not permitted by the serviceLock setting", name) + return E("core.RegisterService", fmt.Sprintf("service %q is not permitted by the serviceLock setting", name), nil) } if _, exists := m.services[name]; exists { - return fmt.Errorf("core: service %q already registered", name) + return E("core.RegisterService", fmt.Sprintf("service %q already registered", name), nil) } m.services[name] = svc diff --git a/pkg/log/rotation_test.go b/pkg/log/rotation_test.go index 97a012e..001fa8a 100644 --- a/pkg/log/rotation_test.go +++ b/pkg/log/rotation_test.go @@ -118,6 +118,58 @@ func TestRotatingWriter_Append(t *testing.T) { } } +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{ diff --git a/pkg/log/service_test.go b/pkg/log/service_test.go new file mode 100644 index 0000000..fd329a1 --- /dev/null +++ b/pkg/log/service_test.go @@ -0,0 +1,126 @@ +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) +} From fbb26b1be219c9a0a38dc4aee36e1b6b4e919471 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 17:47:41 +0000 Subject: [PATCH 05/31] chore: sync dependencies for v0.3.2 Co-Authored-By: Virgil --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index e7d2a4e..2df1b6d 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module forge.lthn.ai/core/go go 1.26.0 require ( - forge.lthn.ai/core/go-io v0.0.5 - forge.lthn.ai/core/go-log v0.0.1 + forge.lthn.ai/core/go-io v0.1.5 + forge.lthn.ai/core/go-log v0.0.4 github.com/stretchr/testify v1.11.1 ) diff --git a/go.sum b/go.sum index 57f3230..dd6331f 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -forge.lthn.ai/core/go-io v0.0.5 h1:oSyngKTkB1gR5fEWYKXftTg9FxwnpddSiCq2dlwfImE= -forge.lthn.ai/core/go-io v0.0.5/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= -forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= -forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM= +forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= +forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= +forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= From 7a9c9caabcc6d4ecaf3811998b659f785eb09231 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 17:52:47 +0000 Subject: [PATCH 06/31] chore: sync dependencies for v0.3.3 Co-Authored-By: Virgil --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2df1b6d..2620c9e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module forge.lthn.ai/core/go go 1.26.0 require ( - forge.lthn.ai/core/go-io v0.1.5 + forge.lthn.ai/core/go-io v0.1.6 forge.lthn.ai/core/go-log v0.0.4 github.com/stretchr/testify v1.11.1 ) diff --git a/go.sum b/go.sum index dd6331f..b83e5f7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM= -forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= +forge.lthn.ai/core/go-io v0.1.6 h1:RByYeP829HFqR2yLg5iBM5dGHKzPFYc+udl/Y1DZIRs= +forge.lthn.ai/core/go-io v0.1.6/go.mod h1:3MSuQZuzhCi6aefECQ/LxhM8ooVLam1KgEvgeEjYZVc= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= From 21c4f718d35220a044a0ddd77cf6b24253b24d5f Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 23:32:53 +0000 Subject: [PATCH 07/31] =?UTF-8?q?feat:=20add=20pkg/mnt=20=E2=80=94=20mount?= =?UTF-8?q?=20operations=20for=20Core=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.mnt provides zero-dep mount operations: - mnt.FS(embed, "subdir") — scoped embed.FS access (debme pattern) - mnt.Extract(fs, targetDir, data) — template directory extraction (gosod/Install pattern) Template extraction supports: - Go text/template in file contents (.tmpl suffix) - Go text/template in directory and file names ({{.Name}}) - Ignore files, rename files - Variable substitution from any struct or map Based on leaanthony/debme (70 lines) + leaanthony/gosod (280 lines), rewritten as single zero-dep package. All stdlib, no transitive deps. 8 tests covering FS, Sub, ReadFile, ReadString, ReadDir, Extract. Co-Authored-By: Virgil --- pkg/mnt/extract.go | 194 +++++++++++++++++++++++ pkg/mnt/mnt.go | 86 ++++++++++ pkg/mnt/mnt_test.go | 106 +++++++++++++ pkg/mnt/testdata/hello.txt | 1 + pkg/mnt/testdata/subdir/nested.txt | 1 + pkg/mnt/testdata/template/README.md.tmpl | 1 + pkg/mnt/testdata/template/go.mod.tmpl | 1 + pkg/mnt/testdata/template/main.go | 1 + 8 files changed, 391 insertions(+) create mode 100644 pkg/mnt/extract.go create mode 100644 pkg/mnt/mnt.go create mode 100644 pkg/mnt/mnt_test.go create mode 100644 pkg/mnt/testdata/hello.txt create mode 100644 pkg/mnt/testdata/subdir/nested.txt create mode 100644 pkg/mnt/testdata/template/README.md.tmpl create mode 100644 pkg/mnt/testdata/template/go.mod.tmpl create mode 100644 pkg/mnt/testdata/template/main.go diff --git a/pkg/mnt/extract.go b/pkg/mnt/extract.go new file mode 100644 index 0000000..797f983 --- /dev/null +++ b/pkg/mnt/extract.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mnt + +import ( + "bytes" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "text/template" +) + +// ExtractOptions configures template extraction. +type ExtractOptions struct { + // TemplateFilters identifies template files by substring match. + // Default: [".tmpl"] + TemplateFilters []string + + // IgnoreFiles is a set of filenames to skip during extraction. + IgnoreFiles map[string]struct{} + + // RenameFiles maps original filenames to new names. + RenameFiles map[string]string +} + +// Extract copies a template directory from an fs.FS to targetDir, +// processing Go text/template in filenames and file contents. +// +// Files containing a template filter substring (default: ".tmpl") have +// their contents processed through text/template with the given data. +// The filter is stripped from the output filename. +// +// Directory and file names can contain Go template expressions: +// {{.Name}}/main.go → myproject/main.go +// +// Data can be any struct or map[string]string for template substitution. +func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error { + opt := ExtractOptions{ + TemplateFilters: []string{".tmpl"}, + IgnoreFiles: make(map[string]struct{}), + RenameFiles: make(map[string]string), + } + if len(opts) > 0 { + if len(opts[0].TemplateFilters) > 0 { + opt.TemplateFilters = opts[0].TemplateFilters + } + if opts[0].IgnoreFiles != nil { + opt.IgnoreFiles = opts[0].IgnoreFiles + } + if opts[0].RenameFiles != nil { + opt.RenameFiles = opts[0].RenameFiles + } + } + + // Ensure target directory exists + targetDir, err := filepath.Abs(targetDir) + if err != nil { + return err + } + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + // Categorise files + var dirs []string + var templateFiles []string + var standardFiles []string + + err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." { + return nil + } + if d.IsDir() { + dirs = append(dirs, path) + return nil + } + filename := filepath.Base(path) + if _, ignored := opt.IgnoreFiles[filename]; ignored { + return nil + } + if isTemplate(filename, opt.TemplateFilters) { + templateFiles = append(templateFiles, path) + } else { + standardFiles = append(standardFiles, path) + } + return nil + }) + if err != nil { + return err + } + + // Create directories (names may contain templates) + for _, dir := range dirs { + target := renderPath(filepath.Join(targetDir, dir), data) + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + } + + // Process template files + for _, path := range templateFiles { + tmpl, err := template.ParseFS(fsys, path) + if err != nil { + return err + } + + targetFile := renderPath(filepath.Join(targetDir, path), data) + + // Strip template filters from filename + dir := filepath.Dir(targetFile) + name := filepath.Base(targetFile) + for _, filter := range opt.TemplateFilters { + name = strings.ReplaceAll(name, filter, "") + } + if renamed := opt.RenameFiles[name]; renamed != "" { + name = renamed + } + targetFile = filepath.Join(dir, name) + + f, err := os.Create(targetFile) + if err != nil { + return err + } + if err := tmpl.Execute(f, data); err != nil { + f.Close() + return err + } + f.Close() + } + + // Copy standard files + for _, path := range standardFiles { + name := filepath.Base(path) + if renamed := opt.RenameFiles[name]; renamed != "" { + path = filepath.Join(filepath.Dir(path), renamed) + } + target := renderPath(filepath.Join(targetDir, path), data) + if err := copyFile(fsys, path, target); err != nil { + return err + } + } + + return nil +} + +func isTemplate(filename string, filters []string) bool { + for _, f := range filters { + if strings.Contains(filename, f) { + return true + } + } + return false +} + +func renderPath(path string, data any) string { + if data == nil { + return path + } + tmpl, err := template.New("path").Parse(path) + if err != nil { + return path + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return path + } + return buf.String() +} + +func copyFile(fsys fs.FS, source, target string) error { + s, err := fsys.Open(source) + if err != nil { + return err + } + defer s.Close() + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + d, err := os.Create(target) + if err != nil { + return err + } + defer d.Close() + + _, err = io.Copy(d, s) + return err +} diff --git a/pkg/mnt/mnt.go b/pkg/mnt/mnt.go new file mode 100644 index 0000000..5acb4f6 --- /dev/null +++ b/pkg/mnt/mnt.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package mnt provides mount operations for the Core framework. +// +// Mount operations attach data to/from binaries and watch live filesystems: +// +// - FS: mount an embed.FS subdirectory for scoped access +// - Extract: extract a template directory with variable substitution +// - Watch: observe filesystem changes (file watcher) +// +// Zero external dependencies. All operations use stdlib only. +// +// Usage: +// +// sub, _ := mnt.FS(myEmbed, "lib/persona") +// content, _ := sub.ReadFile("secops/developer.md") +// +// mnt.Extract(sub, "/tmp/workspace", map[string]string{"Name": "myproject"}) +package mnt + +import ( + "embed" + "io/fs" + "path/filepath" +) + +// Sub wraps an embed.FS with a basedir for scoped access. +// All paths are relative to basedir. +type Sub struct { + basedir string + fs embed.FS +} + +// FS creates a scoped view of an embed.FS anchored at basedir. +// Returns error if basedir doesn't exist in the embedded filesystem. +func FS(efs embed.FS, basedir string) (*Sub, error) { + s := &Sub{fs: efs, basedir: basedir} + // Verify the basedir exists + if _, err := s.ReadDir("."); err != nil { + return nil, err + } + return s, nil +} + +func (s *Sub) path(name string) string { + return filepath.ToSlash(filepath.Join(s.basedir, name)) +} + +// Open opens the named file for reading. +func (s *Sub) Open(name string) (fs.File, error) { + return s.fs.Open(s.path(name)) +} + +// ReadDir reads the named directory. +func (s *Sub) ReadDir(name string) ([]fs.DirEntry, error) { + return s.fs.ReadDir(s.path(name)) +} + +// ReadFile reads the named file. +func (s *Sub) ReadFile(name string) ([]byte, error) { + return s.fs.ReadFile(s.path(name)) +} + +// ReadString reads the named file as a string. +func (s *Sub) ReadString(name string) (string, error) { + data, err := s.ReadFile(name) + if err != nil { + return "", err + } + return string(data), nil +} + +// Sub returns a new Sub anchored at a subdirectory within this Sub. +func (s *Sub) Sub(subDir string) (*Sub, error) { + return FS(s.fs, s.path(subDir)) +} + +// Embed returns the underlying embed.FS. +func (s *Sub) Embed() embed.FS { + return s.fs +} + +// BaseDir returns the basedir this Sub is anchored at. +func (s *Sub) BaseDir() string { + return s.basedir +} diff --git a/pkg/mnt/mnt_test.go b/pkg/mnt/mnt_test.go new file mode 100644 index 0000000..0d65e38 --- /dev/null +++ b/pkg/mnt/mnt_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mnt_test + +import ( + "embed" + "os" + "testing" + + "forge.lthn.ai/core/go/pkg/mnt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed testdata +var testFS embed.FS + +func TestFS_Good(t *testing.T) { + sub, err := mnt.FS(testFS, "testdata") + require.NoError(t, err) + assert.Equal(t, "testdata", sub.BaseDir()) +} + +func TestFS_Bad_InvalidDir(t *testing.T) { + _, err := mnt.FS(testFS, "nonexistent") + assert.Error(t, err) +} + +func TestSub_ReadFile_Good(t *testing.T) { + sub, err := mnt.FS(testFS, "testdata") + require.NoError(t, err) + + data, err := sub.ReadFile("hello.txt") + require.NoError(t, err) + assert.Equal(t, "hello world\n", string(data)) +} + +func TestSub_ReadString_Good(t *testing.T) { + sub, err := mnt.FS(testFS, "testdata") + require.NoError(t, err) + + content, err := sub.ReadString("hello.txt") + require.NoError(t, err) + assert.Equal(t, "hello world\n", content) +} + +func TestSub_ReadDir_Good(t *testing.T) { + sub, err := mnt.FS(testFS, "testdata") + require.NoError(t, err) + + entries, err := sub.ReadDir(".") + require.NoError(t, err) + assert.True(t, len(entries) >= 1) +} + +func TestSub_Sub_Good(t *testing.T) { + sub, err := mnt.FS(testFS, "testdata") + require.NoError(t, err) + + nested, err := sub.Sub("subdir") + require.NoError(t, err) + + content, err := nested.ReadString("nested.txt") + require.NoError(t, err) + assert.Equal(t, "nested content\n", content) +} + +func TestExtract_Good(t *testing.T) { + sub, err := mnt.FS(testFS, "testdata/template") + require.NoError(t, err) + + targetDir := t.TempDir() + err = mnt.Extract(sub, targetDir, map[string]string{ + "Name": "myproject", + "Module": "forge.lthn.ai/core/myproject", + }) + require.NoError(t, err) + + // Check template was processed + readme, err := os.ReadFile(targetDir + "/README.md") + require.NoError(t, err) + assert.Contains(t, string(readme), "myproject project") + + // Check go.mod template was processed + gomod, err := os.ReadFile(targetDir + "/go.mod") + require.NoError(t, err) + assert.Contains(t, string(gomod), "module forge.lthn.ai/core/myproject") + + // Check non-template was copied as-is + main, err := os.ReadFile(targetDir + "/main.go") + require.NoError(t, err) + assert.Equal(t, "package main\n", string(main)) +} + +func TestExtract_Good_NoData(t *testing.T) { + sub, err := mnt.FS(testFS, "testdata") + require.NoError(t, err) + + targetDir := t.TempDir() + err = mnt.Extract(sub, targetDir, nil) + require.NoError(t, err) + + data, err := os.ReadFile(targetDir + "/hello.txt") + require.NoError(t, err) + assert.Equal(t, "hello world\n", string(data)) +} diff --git a/pkg/mnt/testdata/hello.txt b/pkg/mnt/testdata/hello.txt new file mode 100644 index 0000000..3b18e51 --- /dev/null +++ b/pkg/mnt/testdata/hello.txt @@ -0,0 +1 @@ +hello world diff --git a/pkg/mnt/testdata/subdir/nested.txt b/pkg/mnt/testdata/subdir/nested.txt new file mode 100644 index 0000000..ca281f5 --- /dev/null +++ b/pkg/mnt/testdata/subdir/nested.txt @@ -0,0 +1 @@ +nested content diff --git a/pkg/mnt/testdata/template/README.md.tmpl b/pkg/mnt/testdata/template/README.md.tmpl new file mode 100644 index 0000000..fdc89c8 --- /dev/null +++ b/pkg/mnt/testdata/template/README.md.tmpl @@ -0,0 +1 @@ +{{.Name}} project diff --git a/pkg/mnt/testdata/template/go.mod.tmpl b/pkg/mnt/testdata/template/go.mod.tmpl new file mode 100644 index 0000000..9f840df --- /dev/null +++ b/pkg/mnt/testdata/template/go.mod.tmpl @@ -0,0 +1 @@ +module {{.Module}} diff --git a/pkg/mnt/testdata/template/main.go b/pkg/mnt/testdata/template/main.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/pkg/mnt/testdata/template/main.go @@ -0,0 +1 @@ +package main From c0d50bdf92f8122ebfa013656ac2aa8153470760 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 23:39:33 +0000 Subject: [PATCH 08/31] =?UTF-8?q?feat:=20add=20top-level=20core.go=20?= =?UTF-8?q?=E2=80=94=20re-exports=20DI=20container=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now: import core "forge.lthn.ai/core/go" c, _ := core.New(core.WithService(factory)) svc, _ := core.ServiceFor[*MyService](c, "name") Re-exports: New, WithService, WithName, WithServiceLock, WithAssets, ServiceFor, Core, Option, Message, Startable, Stoppable, LocaleProvider, ServiceRuntime. Sub-packages imported directly: pkg/mnt, pkg/log, etc. Co-Authored-By: Virgil --- core.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 core.go diff --git a/core.go b/core.go new file mode 100644 index 0000000..1c77f43 --- /dev/null +++ b/core.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package core is the Core framework for Go. +// +// Import this package to access the full framework surface: +// +// import core "forge.lthn.ai/core/go" +// +// c, _ := core.New(core.WithService(myFactory)) +// svc, _ := core.ServiceFor[*MyService](c, "name") +// +// Sub-packages provide domain-specific capabilities: +// +// core/pkg/core — DI container, ServiceRuntime, lifecycle +// core/pkg/mnt — mount operations (embed FS, template extraction) +// core/pkg/log — structured logging (re-exported from go-log) +// +// The framework object is designed for zero transitive dependencies. +// Each pkg/ uses stdlib only. +package core + +import ( + "forge.lthn.ai/core/go/pkg/core" +) + +// Re-export the DI container API at the top level. +// This lets users write core.New() instead of core.Core.New(). + +// Core is the central application container. +type Core = core.Core + +// New creates a new Core instance with the given options. +var New = core.New + +// Option configures a Core instance. +type Option = core.Option + +// WithService registers a service factory. +var WithService = core.WithService + +// WithName registers a named service factory. +var WithName = core.WithName + +// WithServiceLock prevents late service registration. +var WithServiceLock = core.WithServiceLock + +// WithAssets registers an embedded filesystem. +var WithAssets = core.WithAssets + +// ServiceFor retrieves a typed service by name. +func ServiceFor[T any](c *Core, name string) (T, error) { + return core.ServiceFor[T](c, name) +} + +// ServiceRuntime is the base for services with typed options. +type ServiceRuntime[T any] = core.ServiceRuntime[T] + +// NewServiceRuntime creates a ServiceRuntime — use pkg/core.NewServiceRuntime[T] directly. +// Cannot re-export generic functions at the package level. + +// Message is the IPC message type. +type Message = core.Message + +// Startable is implemented by services with startup logic. +type Startable = core.Startable + +// Stoppable is implemented by services with shutdown logic. +type Stoppable = core.Stoppable + +// LocaleProvider is implemented by services that provide locale filesystems. +type LocaleProvider = core.LocaleProvider From 9a57a7bc884dc5bde45c1a89326d3a21f3a42b9e Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 00:06:29 +0000 Subject: [PATCH 09/31] =?UTF-8?q?feat:=20integrate=20mnt=20into=20Core=20s?= =?UTF-8?q?truct=20=E2=80=94=20c.Mnt()=20for=20mount=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mnt is now a built-in capability of the Core struct, not a service: c.Mnt().ReadString("persona/secops/developer.md") c.Mnt().Extract(targetDir, data) Changes: - Move mnt.go + mnt_extract.go into pkg/core/ (same package) - Core struct: replace `assets embed.FS` with `mnt *Sub` - WithAssets now creates a Sub mount (backwards compatible) - Add WithMount(embed, "basedir") for subdirectory mounting - Assets() deprecated, delegates to c.Mnt().Embed() - Top-level core.go re-exports Mount, WithMount, Sub, ExtractOptions - pkg/mnt still exists independently for standalone use One import, one struct, methods on the struct: import core "forge.lthn.ai/core/go" c, _ := core.New(core.WithAssets(myEmbed)) c.Mnt().ReadString("templates/coding.md") Co-Authored-By: Virgil --- CLAUDE.md | 4 + core.go | 101 ++++++++++++--------- pkg/core/core.go | 30 +++++-- pkg/core/interfaces.go | 13 ++- pkg/core/mnt.go | 86 ++++++++++++++++++ pkg/core/mnt_extract.go | 194 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 379 insertions(+), 49 deletions(-) create mode 100644 pkg/core/mnt.go create mode 100644 pkg/core/mnt_extract.go diff --git a/CLAUDE.md b/CLAUDE.md index 243018c..4c12f27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Session Context + +Running on **Claude Max20 plan** with **1M context window** (Opus 4.6). This enables marathon sessions — use the full context for complex multi-repo work, dispatch coordination, and ecosystem-wide operations. Compact when needed, but don't be afraid of long sessions. + ## Project Overview Core (`forge.lthn.ai/core/go`) is a **dependency injection and service lifecycle framework** for Go. It provides a typed service registry, lifecycle hooks, and a message-passing bus for decoupled communication between services. diff --git a/core.go b/core.go index 1c77f43..42dd759 100644 --- a/core.go +++ b/core.go @@ -2,70 +2,91 @@ // Package core is the Core framework for Go. // -// Import this package to access the full framework surface: +// Single import, single struct, everything accessible: // // import core "forge.lthn.ai/core/go" // -// c, _ := core.New(core.WithService(myFactory)) +// c, _ := core.New( +// core.WithAssets(myEmbed), +// core.WithService(myFactory), +// ) +// +// // DI // svc, _ := core.ServiceFor[*MyService](c, "name") // -// Sub-packages provide domain-specific capabilities: +// // Mount +// content, _ := c.Mnt().ReadString("persona/secops/developer.md") +// c.Mnt().Extract(targetDir, data) // -// core/pkg/core — DI container, ServiceRuntime, lifecycle -// core/pkg/mnt — mount operations (embed FS, template extraction) -// core/pkg/log — structured logging (re-exported from go-log) -// -// The framework object is designed for zero transitive dependencies. -// Each pkg/ uses stdlib only. +// // IPC +// c.ACTION(msg) package core import ( - "forge.lthn.ai/core/go/pkg/core" + di "forge.lthn.ai/core/go/pkg/core" ) -// Re-export the DI container API at the top level. -// This lets users write core.New() instead of core.Core.New(). +// --- Types --- // Core is the central application container. -type Core = core.Core - -// New creates a new Core instance with the given options. -var New = core.New +type Core = di.Core // Option configures a Core instance. -type Option = core.Option +type Option = di.Option + +// Message is the IPC message type. +type Message = di.Message + +// Sub is a scoped view of an embedded filesystem. +type Sub = di.Sub + +// ExtractOptions configures template extraction. +type ExtractOptions = di.ExtractOptions + +// Startable is implemented by services with startup logic. +type Startable = di.Startable + +// Stoppable is implemented by services with shutdown logic. +type Stoppable = di.Stoppable + +// LocaleProvider provides locale filesystems for i18n. +type LocaleProvider = di.LocaleProvider + +// ServiceRuntime is the base for services with typed options. +type ServiceRuntime[T any] = di.ServiceRuntime[T] + +// --- Constructor + Options --- + +// New creates a new Core instance. +var New = di.New // WithService registers a service factory. -var WithService = core.WithService +var WithService = di.WithService // WithName registers a named service factory. -var WithName = core.WithName +var WithName = di.WithName + +// WithAssets mounts an embedded filesystem. +var WithAssets = di.WithAssets + +// WithMount mounts an embedded filesystem at a subdirectory. +var WithMount = di.WithMount // WithServiceLock prevents late service registration. -var WithServiceLock = core.WithServiceLock +var WithServiceLock = di.WithServiceLock -// WithAssets registers an embedded filesystem. -var WithAssets = core.WithAssets +// WithApp sets the GUI runtime. +var WithApp = di.WithApp + +// Mount creates a scoped view of an embed.FS at basedir. +var Mount = di.Mount + +// --- Generic Functions --- // ServiceFor retrieves a typed service by name. func ServiceFor[T any](c *Core, name string) (T, error) { - return core.ServiceFor[T](c, name) + return di.ServiceFor[T](c, name) } -// ServiceRuntime is the base for services with typed options. -type ServiceRuntime[T any] = core.ServiceRuntime[T] - -// NewServiceRuntime creates a ServiceRuntime — use pkg/core.NewServiceRuntime[T] directly. -// Cannot re-export generic functions at the package level. - -// Message is the IPC message type. -type Message = core.Message - -// Startable is implemented by services with startup logic. -type Startable = core.Startable - -// Stoppable is implemented by services with shutdown logic. -type Stoppable = core.Stoppable - -// LocaleProvider is implemented by services that provide locale filesystems. -type LocaleProvider = core.LocaleProvider +// E creates a structured error. +var E = di.E diff --git a/pkg/core/core.go b/pkg/core/core.go index 52041cf..7aee91a 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -122,11 +122,27 @@ func WithApp(app any) Option { } } -// WithAssets creates an Option that registers the application's embedded assets. -// This is necessary for the application to be able to serve its frontend. -func WithAssets(fs embed.FS) Option { +// WithAssets creates an Option that mounts the application's embedded assets. +// The assets are accessible via c.Mnt(). +func WithAssets(efs embed.FS) Option { return func(c *Core) error { - c.assets = fs + sub, err := Mount(efs, ".") + if err != nil { + return E("core.WithAssets", "failed to mount assets", err) + } + c.mnt = sub + return nil + } +} + +// WithMount creates an Option that mounts an embedded FS at a specific subdirectory. +func WithMount(efs embed.FS, basedir string) Option { + return func(c *Core) error { + sub, err := Mount(efs, basedir) + if err != nil { + return E("core.WithMount", "failed to mount "+basedir, err) + } + c.mnt = sub return nil } } @@ -397,6 +413,10 @@ func (c *Core) Crypt() Crypt { func (c *Core) Core() *Core { return c } // Assets returns the embedded filesystem containing the application's assets. +// Deprecated: use c.Mnt().Embed() instead. func (c *Core) Assets() embed.FS { - return c.assets + if c.mnt != nil { + return c.mnt.Embed() + } + return embed.FS{} } diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go index f8b4ad4..79de2c5 100644 --- a/pkg/core/interfaces.go +++ b/pkg/core/interfaces.go @@ -2,7 +2,6 @@ package core import ( "context" - "embed" goio "io" "io/fs" "slices" @@ -94,9 +93,9 @@ type LocaleProvider interface { // Core is the central application object that manages services, assets, and communication. type Core struct { - App any // GUI runtime (e.g., Wails App) - set by WithApp option - assets embed.FS - Features *Features + App any // GUI runtime (e.g., Wails App) - set by WithApp option + Features *Features // Feature flags + mnt *Sub // Mount point for embedded assets svc *serviceManager bus *messageBus locales []fs.FS // collected from LocaleProvider services @@ -106,6 +105,12 @@ type Core struct { shutdown atomic.Bool } +// Mnt returns the mount point for embedded assets. +// Use this to read embedded files and extract template directories. +func (c *Core) Mnt() *Sub { + return c.mnt +} + // Locales returns all locale filesystems collected from registered services. // The i18n service uses this during startup to load translations. func (c *Core) Locales() []fs.FS { diff --git a/pkg/core/mnt.go b/pkg/core/mnt.go new file mode 100644 index 0000000..538dc54 --- /dev/null +++ b/pkg/core/mnt.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Mount operations for the Core framework. mount operations for the Core framework. +// +// Mount operations attach data to/from binaries and watch live filesystems: +// +// - FS: mount an embed.FS subdirectory for scoped access +// - Extract: extract a template directory with variable substitution +// - Watch: observe filesystem changes (file watcher) +// +// Zero external dependencies. All operations use stdlib only. +// +// Usage: +// +// sub, _ := mnt.FS(myEmbed, "lib/persona") +// content, _ := sub.ReadFile("secops/developer.md") +// +// mnt.Extract(sub, "/tmp/workspace", map[string]string{"Name": "myproject"}) +package core + +import ( + "embed" + "io/fs" + "path/filepath" +) + +// Sub wraps an embed.FS with a basedir for scoped access. +// All paths are relative to basedir. +type Sub struct { + basedir string + fs embed.FS +} + +// FS creates a scoped view of an embed.FS anchored at basedir. +// Returns error if basedir doesn't exist in the embedded filesystem. +func Mount(efs embed.FS, basedir string) (*Sub, error) { + s := &Sub{fs: efs, basedir: basedir} + // Verify the basedir exists + if _, err := s.ReadDir("."); err != nil { + return nil, err + } + return s, nil +} + +func (s *Sub) path(name string) string { + return filepath.ToSlash(filepath.Join(s.basedir, name)) +} + +// Open opens the named file for reading. +func (s *Sub) Open(name string) (fs.File, error) { + return s.fs.Open(s.path(name)) +} + +// ReadDir reads the named directory. +func (s *Sub) ReadDir(name string) ([]fs.DirEntry, error) { + return s.fs.ReadDir(s.path(name)) +} + +// ReadFile reads the named file. +func (s *Sub) ReadFile(name string) ([]byte, error) { + return s.fs.ReadFile(s.path(name)) +} + +// ReadString reads the named file as a string. +func (s *Sub) ReadString(name string) (string, error) { + data, err := s.ReadFile(name) + if err != nil { + return "", err + } + return string(data), nil +} + +// Sub returns a new Sub anchored at a subdirectory within this Sub. +func (s *Sub) Sub(subDir string) (*Sub, error) { + return Mount(s.fs, s.path(subDir)) +} + +// Embed returns the underlying embed.FS. +func (s *Sub) Embed() embed.FS { + return s.fs +} + +// BaseDir returns the basedir this Sub is anchored at. +func (s *Sub) BaseDir() string { + return s.basedir +} diff --git a/pkg/core/mnt_extract.go b/pkg/core/mnt_extract.go new file mode 100644 index 0000000..21188e3 --- /dev/null +++ b/pkg/core/mnt_extract.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package core + +import ( + "bytes" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "text/template" +) + +// ExtractOptions configures template extraction. +type ExtractOptions struct { + // TemplateFilters identifies template files by substring match. + // Default: [".tmpl"] + TemplateFilters []string + + // IgnoreFiles is a set of filenames to skip during extraction. + IgnoreFiles map[string]struct{} + + // RenameFiles maps original filenames to new names. + RenameFiles map[string]string +} + +// Extract copies a template directory from an fs.FS to targetDir, +// processing Go text/template in filenames and file contents. +// +// Files containing a template filter substring (default: ".tmpl") have +// their contents processed through text/template with the given data. +// The filter is stripped from the output filename. +// +// Directory and file names can contain Go template expressions: +// {{.Name}}/main.go → myproject/main.go +// +// Data can be any struct or map[string]string for template substitution. +func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error { + opt := ExtractOptions{ + TemplateFilters: []string{".tmpl"}, + IgnoreFiles: make(map[string]struct{}), + RenameFiles: make(map[string]string), + } + if len(opts) > 0 { + if len(opts[0].TemplateFilters) > 0 { + opt.TemplateFilters = opts[0].TemplateFilters + } + if opts[0].IgnoreFiles != nil { + opt.IgnoreFiles = opts[0].IgnoreFiles + } + if opts[0].RenameFiles != nil { + opt.RenameFiles = opts[0].RenameFiles + } + } + + // Ensure target directory exists + targetDir, err := filepath.Abs(targetDir) + if err != nil { + return err + } + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + // Categorise files + var dirs []string + var templateFiles []string + var standardFiles []string + + err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." { + return nil + } + if d.IsDir() { + dirs = append(dirs, path) + return nil + } + filename := filepath.Base(path) + if _, ignored := opt.IgnoreFiles[filename]; ignored { + return nil + } + if isTemplate(filename, opt.TemplateFilters) { + templateFiles = append(templateFiles, path) + } else { + standardFiles = append(standardFiles, path) + } + return nil + }) + if err != nil { + return err + } + + // Create directories (names may contain templates) + for _, dir := range dirs { + target := renderPath(filepath.Join(targetDir, dir), data) + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + } + + // Process template files + for _, path := range templateFiles { + tmpl, err := template.ParseFS(fsys, path) + if err != nil { + return err + } + + targetFile := renderPath(filepath.Join(targetDir, path), data) + + // Strip template filters from filename + dir := filepath.Dir(targetFile) + name := filepath.Base(targetFile) + for _, filter := range opt.TemplateFilters { + name = strings.ReplaceAll(name, filter, "") + } + if renamed := opt.RenameFiles[name]; renamed != "" { + name = renamed + } + targetFile = filepath.Join(dir, name) + + f, err := os.Create(targetFile) + if err != nil { + return err + } + if err := tmpl.Execute(f, data); err != nil { + f.Close() + return err + } + f.Close() + } + + // Copy standard files + for _, path := range standardFiles { + name := filepath.Base(path) + if renamed := opt.RenameFiles[name]; renamed != "" { + path = filepath.Join(filepath.Dir(path), renamed) + } + target := renderPath(filepath.Join(targetDir, path), data) + if err := copyFile(fsys, path, target); err != nil { + return err + } + } + + return nil +} + +func isTemplate(filename string, filters []string) bool { + for _, f := range filters { + if strings.Contains(filename, f) { + return true + } + } + return false +} + +func renderPath(path string, data any) string { + if data == nil { + return path + } + tmpl, err := template.New("path").Parse(path) + if err != nil { + return path + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return path + } + return buf.String() +} + +func copyFile(fsys fs.FS, source, target string) error { + s, err := fsys.Open(source) + if err != nil { + return err + } + defer s.Close() + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + d, err := os.Create(target) + if err != nil { + return err + } + defer d.Close() + + _, err = io.Copy(d, s) + return err +} From 66b4b086008349f298664344b2b26c58190377d8 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 00:14:44 +0000 Subject: [PATCH 10/31] =?UTF-8?q?feat:=20add=20core.Etc()=20=E2=80=94=20co?= =?UTF-8?q?nfiguration,=20settings,=20and=20feature=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old Features struct with Etc on the Core struct: c.Etc().Set("api_url", "https://api.lthn.sh") c.Etc().Enable("coderabbit") c.Etc().Enabled("coderabbit") // true c.Etc().GetString("api_url") // "https://api.lthn.sh" Also adds Var[T] — generic optional variable (from leaanthony/u): v := core.NewVar("hello") v.Get() // "hello" v.IsSet() // true v.Unset() // zero value, IsSet() = false Removes Features struct from Core (replaced by Etc). Thread-safe via sync.RWMutex. Zero external dependencies. Co-Authored-By: Virgil --- core.go | 16 +++++ pkg/core/core.go | 4 +- pkg/core/core_test.go | 35 +++++++---- pkg/core/etc.go | 136 +++++++++++++++++++++++++++++++++++++++++ pkg/core/fuzz_test.go | 1 - pkg/core/interfaces.go | 28 ++++----- 6 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 pkg/core/etc.go diff --git a/core.go b/core.go index 42dd759..96b3bb1 100644 --- a/core.go +++ b/core.go @@ -90,3 +90,19 @@ func ServiceFor[T any](c *Core, name string) (T, error) { // E creates a structured error. var E = di.E + +// --- Configuration (core.Etc) --- + +// Etc is the configuration and feature flags store. +type Etc = di.Etc + +// NewEtc creates a standalone configuration store. +var NewEtc = di.NewEtc + +// Var is a typed optional variable (set/unset/get). +type Var[T any] = di.Var[T] + +// NewVar creates a Var with the given value. +func NewVar[T any](val T) Var[T] { + return di.NewVar(val) +} diff --git a/pkg/core/core.go b/pkg/core/core.go index 7aee91a..896f427 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -27,8 +27,8 @@ var ( // ) func New(opts ...Option) (*Core, error) { c := &Core{ - Features: &Features{}, - svc: newServiceManager(), + etc: NewEtc(), + svc: newServiceManager(), } c.bus = newMessageBus(c) diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go index 07c43cf..d666ce9 100644 --- a/pkg/core/core_test.go +++ b/pkg/core/core_test.go @@ -119,24 +119,37 @@ func TestCore_Core_Good(t *testing.T) { assert.Equal(t, c, c.Core()) } -func TestFeatures_IsEnabled_Good(t *testing.T) { +func TestEtc_Features_Good(t *testing.T) { c, err := New() assert.NoError(t, err) - c.Features.Flags = []string{"feature1", "feature2"} + c.Etc().Enable("feature1") + c.Etc().Enable("feature2") - assert.True(t, c.Features.IsEnabled("feature1")) - assert.True(t, c.Features.IsEnabled("feature2")) - assert.False(t, c.Features.IsEnabled("feature3")) - assert.False(t, c.Features.IsEnabled("")) + assert.True(t, c.Etc().Enabled("feature1")) + assert.True(t, c.Etc().Enabled("feature2")) + assert.False(t, c.Etc().Enabled("feature3")) + assert.False(t, c.Etc().Enabled("")) } -func TestFeatures_IsEnabled_Edge(t *testing.T) { +func TestEtc_Settings_Good(t *testing.T) { c, _ := New() - c.Features.Flags = []string{" ", "foo"} - assert.True(t, c.Features.IsEnabled(" ")) - assert.True(t, c.Features.IsEnabled("foo")) - assert.False(t, c.Features.IsEnabled("FOO")) // Case sensitive check + c.Etc().Set("api_url", "https://api.lthn.sh") + c.Etc().Set("max_agents", 5) + + assert.Equal(t, "https://api.lthn.sh", c.Etc().GetString("api_url")) + assert.Equal(t, 5, c.Etc().GetInt("max_agents")) + assert.Equal(t, "", c.Etc().GetString("missing")) +} + +func TestEtc_Features_Edge(t *testing.T) { + c, _ := New() + c.Etc().Enable("foo") + assert.True(t, c.Etc().Enabled("foo")) + assert.False(t, c.Etc().Enabled("FOO")) // Case sensitive + + c.Etc().Disable("foo") + assert.False(t, c.Etc().Enabled("foo")) } func TestCore_ServiceLifecycle_Good(t *testing.T) { diff --git a/pkg/core/etc.go b/pkg/core/etc.go new file mode 100644 index 0000000..136caa5 --- /dev/null +++ b/pkg/core/etc.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Settings, feature flags, and typed configuration for the Core framework. +// Named after /etc — the configuration directory. + +package core + +import ( + "sync" +) + +// Var is a variable that can be set, unset, and queried for its state. +// Zero value is unset. +type Var[T any] struct { + val T + set bool +} + +// Get returns the value, or the zero value if unset. +func (v *Var[T]) Get() T { return v.val } + +// Set sets the value and marks it as set. +func (v *Var[T]) Set(val T) { v.val = val; v.set = true } + +// IsSet returns true when a value has been set. +func (v *Var[T]) IsSet() bool { return v.set } + +// Unset resets to zero value and marks as unset. +func (v *Var[T]) Unset() { + v.set = false + var zero T + v.val = zero +} + +// NewVar creates a Var with the given value (marked as set). +func NewVar[T any](val T) Var[T] { + return Var[T]{val: val, set: true} +} + +// Etc holds configuration settings and feature flags. +type Etc struct { + mu sync.RWMutex + settings map[string]any + features map[string]bool +} + +// NewEtc creates a new configuration store. +func NewEtc() *Etc { + return &Etc{ + settings: make(map[string]any), + features: make(map[string]bool), + } +} + +// Set stores a configuration value by key. +func (e *Etc) Set(key string, val any) { + e.mu.Lock() + e.settings[key] = val + e.mu.Unlock() +} + +// Get retrieves a configuration value by key. +// Returns (value, true) if found, (zero, false) if not. +func (e *Etc) Get(key string) (any, bool) { + e.mu.RLock() + val, ok := e.settings[key] + e.mu.RUnlock() + return val, ok +} + +// GetString retrieves a string configuration value. +func (e *Etc) GetString(key string) string { + val, ok := e.Get(key) + if !ok { + return "" + } + s, _ := val.(string) + return s +} + +// GetInt retrieves an int configuration value. +func (e *Etc) GetInt(key string) int { + val, ok := e.Get(key) + if !ok { + return 0 + } + i, _ := val.(int) + return i +} + +// GetBool retrieves a bool configuration value. +func (e *Etc) GetBool(key string) bool { + val, ok := e.Get(key) + if !ok { + return false + } + b, _ := val.(bool) + return b +} + +// --- Feature Flags --- + +// Enable enables a feature flag. +func (e *Etc) Enable(feature string) { + e.mu.Lock() + e.features[feature] = true + e.mu.Unlock() +} + +// Disable disables a feature flag. +func (e *Etc) Disable(feature string) { + e.mu.Lock() + e.features[feature] = false + e.mu.Unlock() +} + +// Enabled returns true if the feature is enabled. +func (e *Etc) Enabled(feature string) bool { + e.mu.RLock() + v := e.features[feature] + e.mu.RUnlock() + return v +} + +// Features returns all enabled feature names. +func (e *Etc) EnabledFeatures() []string { + e.mu.RLock() + defer e.mu.RUnlock() + var result []string + for k, v := range e.features { + if v { + result = append(result, k) + } + } + return result +} diff --git a/pkg/core/fuzz_test.go b/pkg/core/fuzz_test.go index 8bbee0e..f084151 100644 --- a/pkg/core/fuzz_test.go +++ b/pkg/core/fuzz_test.go @@ -85,7 +85,6 @@ func FuzzMessageDispatch(f *testing.F) { f.Fuzz(func(t *testing.T, payload string) { c := &Core{ - Features: &Features{}, svc: newServiceManager(), } c.bus = newMessageBus(c) diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go index 79de2c5..47288dd 100644 --- a/pkg/core/interfaces.go +++ b/pkg/core/interfaces.go @@ -4,7 +4,6 @@ import ( "context" goio "io" "io/fs" - "slices" "sync" "sync/atomic" ) @@ -22,18 +21,6 @@ type Contract struct { DisableLogging bool } -// Features provides a way to check if a feature is enabled. -// This is used for feature flagging and conditional logic. -type Features struct { - // Flags is a list of enabled feature flags. - Flags []string -} - -// IsEnabled returns true if the given feature is enabled. -func (f *Features) IsEnabled(feature string) bool { - return slices.Contains(f.Flags, feature) -} - // Option is a function that configures the Core. // This is used to apply settings and register services during initialization. type Option func(*Core) error @@ -93,9 +80,9 @@ type LocaleProvider interface { // Core is the central application object that manages services, assets, and communication. type Core struct { - App any // GUI runtime (e.g., Wails App) - set by WithApp option - Features *Features // Feature flags - mnt *Sub // Mount point for embedded assets + App any // GUI runtime (e.g., Wails App) - set by WithApp option + mnt *Sub // Mount point for embedded assets + etc *Etc // Configuration, settings, and feature flags svc *serviceManager bus *messageBus locales []fs.FS // collected from LocaleProvider services @@ -111,6 +98,15 @@ func (c *Core) Mnt() *Sub { return c.mnt } +// Etc returns the configuration and feature flags store. +// +// c.Etc().Set("api_url", "https://api.lthn.sh") +// c.Etc().Enable("coderabbit") +// c.Etc().Enabled("coderabbit") // true +func (c *Core) Etc() *Etc { + return c.etc +} + // Locales returns all locale filesystems collected from registered services. // The i18n service uses this during startup to load translations. func (c *Core) Locales() []fs.FS { From 8765458bc6c15e236858c54f3365364488d10dca Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 00:17:19 +0000 Subject: [PATCH 11/31] =?UTF-8?q?feat:=20add=20core.Crash()=20=E2=80=94=20?= =?UTF-8?q?panic=20recovery=20and=20crash=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adfer (Welsh: recover). Built into the Core struct: defer c.Crash().Recover() // capture panics c.Crash().SafeGo(fn) // safe goroutine c.Crash().Reports(5) // last 5 crash reports CrashReport includes: timestamp, error, stack trace, system info (OS/arch/Go version), custom metadata. Optional file output: JSON array of crash reports. Zero external dependencies. Based on leaanthony/adfer (168 lines), integrated into pkg/core. Co-Authored-By: Virgil --- pkg/core/core.go | 5 +- pkg/core/crash.go | 142 +++++++++++++++++++++++++++++++++++++++++ pkg/core/interfaces.go | 15 ++++- 3 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 pkg/core/crash.go diff --git a/pkg/core/core.go b/pkg/core/core.go index 896f427..24c8bfe 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -27,8 +27,9 @@ var ( // ) func New(opts ...Option) (*Core, error) { c := &Core{ - etc: NewEtc(), - svc: newServiceManager(), + etc: NewEtc(), + crash: NewCrashHandler(), + svc: newServiceManager(), } c.bus = newMessageBus(c) diff --git a/pkg/core/crash.go b/pkg/core/crash.go new file mode 100644 index 0000000..bf1fb94 --- /dev/null +++ b/pkg/core/crash.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Crash recovery and reporting for the Core framework. +// Named after adfer (Welsh for "recover"). Captures panics, +// writes JSON crash reports, and provides safe goroutine wrappers. + +package core + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "runtime/debug" + "time" +) + +// CrashReport represents a single crash event. +type CrashReport struct { + Timestamp time.Time `json:"timestamp"` + Error string `json:"error"` + Stack string `json:"stack"` + System CrashSystem `json:"system,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +// CrashSystem holds system information at crash time. +type CrashSystem struct { + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"go_version"` +} + +// CrashHandler manages panic recovery and crash reporting. +type CrashHandler struct { + filePath string + meta map[string]string + onCrash func(CrashReport) +} + +// CrashOption configures a CrashHandler. +type CrashOption func(*CrashHandler) + +// WithCrashFile sets the path for crash report JSON output. +func WithCrashFile(path string) CrashOption { + return func(h *CrashHandler) { h.filePath = path } +} + +// WithCrashMeta adds metadata included in every crash report. +func WithCrashMeta(meta map[string]string) CrashOption { + return func(h *CrashHandler) { h.meta = meta } +} + +// WithCrashHandler sets a callback invoked on every crash. +func WithCrashHandler(fn func(CrashReport)) CrashOption { + return func(h *CrashHandler) { h.onCrash = fn } +} + +// NewCrashHandler creates a crash handler with the given options. +func NewCrashHandler(opts ...CrashOption) *CrashHandler { + h := &CrashHandler{} + for _, o := range opts { + o(h) + } + return h +} + +// Recover captures a panic and creates a crash report. +// Use as: defer c.Crash().Recover() +func (h *CrashHandler) Recover() { + if h == nil { + return + } + r := recover() + if r == nil { + return + } + + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + + report := CrashReport{ + Timestamp: time.Now(), + Error: err.Error(), + Stack: string(debug.Stack()), + System: CrashSystem{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Version: runtime.Version(), + }, + Meta: h.meta, + } + + if h.onCrash != nil { + h.onCrash(report) + } + + if h.filePath != "" { + h.appendReport(report) + } +} + +// SafeGo runs a function in a goroutine with panic recovery. +func (h *CrashHandler) SafeGo(fn func()) { + go func() { + defer h.Recover() + fn() + }() +} + +// Reports returns the last n crash reports from the file. +func (h *CrashHandler) Reports(n int) ([]CrashReport, error) { + if h.filePath == "" { + return nil, nil + } + data, err := os.ReadFile(h.filePath) + if err != nil { + return nil, err + } + var reports []CrashReport + if err := json.Unmarshal(data, &reports); err != nil { + return nil, err + } + if len(reports) <= n { + return reports, nil + } + return reports[len(reports)-n:], nil +} + +func (h *CrashHandler) appendReport(report CrashReport) { + var reports []CrashReport + + if data, err := os.ReadFile(h.filePath); err == nil { + json.Unmarshal(data, &reports) + } + + reports = append(reports, report) + data, _ := json.MarshalIndent(reports, "", " ") + os.WriteFile(h.filePath, data, 0644) +} diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go index 47288dd..e3be378 100644 --- a/pkg/core/interfaces.go +++ b/pkg/core/interfaces.go @@ -80,9 +80,10 @@ type LocaleProvider interface { // Core is the central application object that manages services, assets, and communication. type Core struct { - App any // GUI runtime (e.g., Wails App) - set by WithApp option - mnt *Sub // Mount point for embedded assets - etc *Etc // Configuration, settings, and feature flags + App any // GUI runtime (e.g., Wails App) - set by WithApp option + mnt *Sub // Mount point for embedded assets + etc *Etc // Configuration, settings, and feature flags + crash *CrashHandler // Panic recovery and crash reporting svc *serviceManager bus *messageBus locales []fs.FS // collected from LocaleProvider services @@ -107,6 +108,14 @@ func (c *Core) Etc() *Etc { return c.etc } +// Crash returns the crash handler for panic recovery. +// +// defer c.Crash().Recover() +// c.Crash().SafeGo(func() { ... }) +func (c *Core) Crash() *CrashHandler { + return c.crash +} + // Locales returns all locale filesystems collected from registered services. // The i18n service uses this during startup to load translations. func (c *Core) Locales() []fs.FS { From 9331f5067caa772377ffd1b8ab549bdfa80a18c7 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 00:21:08 +0000 Subject: [PATCH 12/31] feat: add Slicer[T] generics + Pack (asset packing without go:embed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slicer[T] — generic typed slice operations (leaanthony/slicer rewrite): s := core.NewSlicer("a", "b", "c") s.AddUnique("d") s.Contains("a") // true s.Filter(fn) // new filtered slicer s.Deduplicate() // remove dupes s.Each(fn) // iterate Pack — build-time asset packing (leaanthony/mewn pattern): Build tool: core.ScanAssets(files) → core.GeneratePack(pkg) Runtime: core.AddAsset(group, name, data) / core.GetAsset(group, name) Scans Go AST for core.GetAsset() calls, reads referenced files, gzip+base64 compresses, generates Go source with init(). Works without go:embed — language-agnostic pattern for CoreTS bridge. Both zero external dependencies. Co-Authored-By: Virgil --- pkg/core/pack.go | 291 +++++++++++++++++++++++++++++++++++++++++++++ pkg/core/slicer.go | 96 +++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 pkg/core/pack.go create mode 100644 pkg/core/slicer.go diff --git a/pkg/core/pack.go b/pkg/core/pack.go new file mode 100644 index 0000000..33d5a89 --- /dev/null +++ b/pkg/core/pack.go @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Build-time asset packing for the Core framework. +// Based on leaanthony/mewn — scans Go source AST for asset references, +// reads files, compresses, and generates Go source with embedded data. +// +// This enables asset embedding WITHOUT go:embed — the packer runs at +// build time and generates a .go file with init() that registers assets. +// This pattern works cross-language (Go, TypeScript, etc). +// +// Usage (build tool): +// +// refs, _ := core.ScanAssets([]string{"main.go", "app.go"}) +// source, _ := core.GeneratePack(refs) +// os.WriteFile("pack.go", []byte(source), 0644) +// +// Usage (runtime): +// +// core.AddAsset(".", "template.html", compressedData) +// content := core.GetAsset(".", "template.html") +package core + +import ( + "compress/gzip" + "encoding/base64" + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +// --- Runtime: Asset Registry --- + +// AssetGroup holds a named collection of packed assets. +type AssetGroup struct { + name string + assets map[string]string // name → compressed data +} + +var ( + assetGroups = make(map[string]*AssetGroup) + assetGroupsMu sync.RWMutex +) + +// AddAsset registers a packed asset at runtime (called from generated init()). +func AddAsset(group, name, data string) { + assetGroupsMu.Lock() + defer assetGroupsMu.Unlock() + + g, ok := assetGroups[group] + if !ok { + g = &AssetGroup{name: group, assets: make(map[string]string)} + assetGroups[group] = g + } + g.assets[name] = data +} + +// GetAsset retrieves and decompresses a packed asset. +func GetAsset(group, name string) (string, error) { + assetGroupsMu.RLock() + g, ok := assetGroups[group] + assetGroupsMu.RUnlock() + if !ok { + return "", fmt.Errorf("asset group %q not found", group) + } + data, ok := g.assets[name] + if !ok { + return "", fmt.Errorf("asset %q not found in group %q", name, group) + } + return decompress(data) +} + +// GetAssetBytes retrieves a packed asset as bytes. +func GetAssetBytes(group, name string) ([]byte, error) { + s, err := GetAsset(group, name) + return []byte(s), err +} + +// --- Build-time: AST Scanner --- + +// AssetRef is a reference to an asset found in source code. +type AssetRef struct { + Name string + Path string + Group string + FullPath string +} + +// ScannedPackage holds all asset references from a set of source files. +type ScannedPackage struct { + PackageName string + BaseDir string + Groups []string + Assets []AssetRef +} + +// ScanAssets parses Go source files and finds asset references. +// Looks for calls to: core.GetAsset("group", "name"), core.AddAsset, etc. +func ScanAssets(filenames []string) ([]ScannedPackage, error) { + packageMap := make(map[string]*ScannedPackage) + groupPaths := make(map[string]string) // variable name → path + + for _, filename := range filenames { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filename, nil, parser.AllErrors) + if err != nil { + return nil, err + } + + baseDir := filepath.Dir(filename) + pkg, ok := packageMap[baseDir] + if !ok { + pkg = &ScannedPackage{BaseDir: baseDir} + packageMap[baseDir] = pkg + } + pkg.PackageName = node.Name.Name + + ast.Inspect(node, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + + // Look for core.GetAsset or mewn.String patterns + if ident.Name == "core" || ident.Name == "mewn" { + switch sel.Sel.Name { + case "GetAsset", "GetAssetBytes", "String", "MustString", "Bytes", "MustBytes": + if len(call.Args) >= 1 { + if lit, ok := call.Args[len(call.Args)-1].(*ast.BasicLit); ok { + path := strings.Trim(lit.Value, "\"") + group := "." + if len(call.Args) >= 2 { + if glit, ok := call.Args[0].(*ast.BasicLit); ok { + group = strings.Trim(glit.Value, "\"") + } + } + fullPath, _ := filepath.Abs(filepath.Join(baseDir, group, path)) + pkg.Assets = append(pkg.Assets, AssetRef{ + Name: path, + Path: path, + Group: group, + FullPath: fullPath, + }) + } + } + case "Group": + // Variable assignment: g := core.Group("./assets") + if len(call.Args) == 1 { + if lit, ok := call.Args[0].(*ast.BasicLit); ok { + path := strings.Trim(lit.Value, "\"") + fullPath, _ := filepath.Abs(filepath.Join(baseDir, path)) + pkg.Groups = append(pkg.Groups, fullPath) + // Track for variable resolution + groupPaths[path] = fullPath + } + } + } + } + + return true + }) + } + + var result []ScannedPackage + for _, pkg := range packageMap { + result = append(result, *pkg) + } + return result, nil +} + +// GeneratePack creates Go source code that embeds the scanned assets. +func GeneratePack(pkg ScannedPackage) (string, error) { + var b strings.Builder + + b.WriteString(fmt.Sprintf("package %s\n\n", pkg.PackageName)) + b.WriteString("// Code generated by core pack. DO NOT EDIT.\n\n") + + if len(pkg.Assets) == 0 && len(pkg.Groups) == 0 { + return b.String(), nil + } + + b.WriteString("import \"forge.lthn.ai/core/go/pkg/core\"\n\n") + b.WriteString("func init() {\n") + + // Pack groups (entire directories) + packed := make(map[string]bool) + for _, groupPath := range pkg.Groups { + files, err := getAllFiles(groupPath) + if err != nil { + continue + } + for _, file := range files { + if packed[file] { + continue + } + data, err := compressFile(file) + if err != nil { + continue + } + localPath := strings.TrimPrefix(file, groupPath+"/") + relGroup, _ := filepath.Rel(pkg.BaseDir, groupPath) + b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data)) + packed[file] = true + } + } + + // Pack individual assets + for _, asset := range pkg.Assets { + if packed[asset.FullPath] { + continue + } + data, err := compressFile(asset.FullPath) + if err != nil { + continue + } + b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data)) + packed[asset.FullPath] = true + } + + b.WriteString("}\n") + return b.String(), nil +} + +// --- Compression --- + +func compressFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return compress(string(data)) +} + +func compress(input string) (string, error) { + var buf bytes.Buffer + b64 := base64.NewEncoder(base64.StdEncoding, &buf) + gz, err := gzip.NewWriterLevel(b64, gzip.BestCompression) + if err != nil { + return "", err + } + if _, err := gz.Write([]byte(input)); err != nil { + return "", err + } + gz.Close() + b64.Close() + return buf.String(), nil +} + +func decompress(input string) (string, error) { + b64 := base64.NewDecoder(base64.StdEncoding, strings.NewReader(input)) + gz, err := gzip.NewReader(b64) + if err != nil { + return "", err + } + defer gz.Close() + data, err := io.ReadAll(gz) + if err != nil { + return "", err + } + return string(data), nil +} + +func getAllFiles(dir string) ([]string, error) { + var result []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode().IsRegular() { + result = append(result, path) + } + return nil + }) + return result, err +} diff --git a/pkg/core/slicer.go b/pkg/core/slicer.go new file mode 100644 index 0000000..a690ea4 --- /dev/null +++ b/pkg/core/slicer.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Generic slice operations for the Core framework. +// Based on leaanthony/slicer, rewritten with Go 1.18+ generics. + +package core + +// Slicer is a typed slice with common operations. +type Slicer[T comparable] struct { + items []T +} + +// NewSlicer creates an empty Slicer. +func NewSlicer[T comparable](items ...T) *Slicer[T] { + return &Slicer[T]{items: items} +} + +// Add appends values. +func (s *Slicer[T]) Add(values ...T) { + s.items = append(s.items, values...) +} + +// AddUnique appends values only if not already present. +func (s *Slicer[T]) AddUnique(values ...T) { + for _, v := range values { + if !s.Contains(v) { + s.items = append(s.items, v) + } + } +} + +// Contains returns true if the value is in the slice. +func (s *Slicer[T]) Contains(val T) bool { + for _, v := range s.items { + if v == val { + return true + } + } + return false +} + +// Filter returns a new Slicer with elements matching the predicate. +func (s *Slicer[T]) Filter(fn func(T) bool) *Slicer[T] { + result := &Slicer[T]{} + for _, v := range s.items { + if fn(v) { + result.items = append(result.items, v) + } + } + return result +} + +// Each runs a function on every element. +func (s *Slicer[T]) Each(fn func(T)) { + for _, v := range s.items { + fn(v) + } +} + +// Remove removes the first occurrence of a value. +func (s *Slicer[T]) Remove(val T) { + for i, v := range s.items { + if v == val { + s.items = append(s.items[:i], s.items[i+1:]...) + return + } + } +} + +// Deduplicate removes duplicate values, preserving order. +func (s *Slicer[T]) Deduplicate() { + seen := make(map[T]struct{}) + result := make([]T, 0, len(s.items)) + for _, v := range s.items { + if _, exists := seen[v]; !exists { + seen[v] = struct{}{} + result = append(result, v) + } + } + s.items = result +} + +// Len returns the number of elements. +func (s *Slicer[T]) Len() int { + return len(s.items) +} + +// Clear removes all elements. +func (s *Slicer[T]) Clear() { + s.items = nil +} + +// AsSlice returns the underlying slice. +func (s *Slicer[T]) AsSlice() []T { + return s.items +} From 077fde95160995243904c5119b6c91b1a0cc619f Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 00:23:13 +0000 Subject: [PATCH 13/31] =?UTF-8?q?rename:=20pack.go=20=E2=86=92=20embed.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It embeds assets into binaries. Pack is what bundlers do. Co-Authored-By: Virgil --- pkg/core/{pack.go => embed.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pkg/core/{pack.go => embed.go} (100%) diff --git a/pkg/core/pack.go b/pkg/core/embed.go similarity index 100% rename from pkg/core/pack.go rename to pkg/core/embed.go From d7f9447e7a0a41a02397df69acb6c9b874288434 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 00:42:41 +0000 Subject: [PATCH 14/31] =?UTF-8?q?feat:=20add=20core.Io()=20=E2=80=94=20loc?= =?UTF-8?q?al=20filesystem=20I/O=20on=20Core=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings go-io/local into Core as c.Io(): c.Io().Read("config.yaml") c.Io().Write("output.txt", content) c.Io().WriteMode("key.pem", data, 0600) c.Io().IsFile("go.mod") c.Io().List(".") c.Io().Delete("temp.txt") Default: rooted at "/" (full access like os package). Sandbox: core.WithIO("./data") restricts all operations. c.Mnt() stays for embedded/mounted assets (read-only). c.Io() is for local filesystem (read/write/delete). WithMount stays for mounting fs.FS subdirectories. WithIO added for sandboxing local I/O. Based on go-io/local/client.go (~300 lines), zero external deps. Co-Authored-By: Virgil --- pkg/core/core.go | 34 +++-- pkg/core/core_test.go | 3 +- pkg/core/interfaces.go | 17 ++- pkg/core/io.go | 307 +++++++++++++++++++++++++++++++++++++++++ pkg/core/mnt.go | 70 ++++++---- 5 files changed, 390 insertions(+), 41 deletions(-) create mode 100644 pkg/core/io.go diff --git a/pkg/core/core.go b/pkg/core/core.go index 24c8bfe..3c5c181 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -4,6 +4,7 @@ import ( "context" "embed" "errors" + "io/fs" "fmt" "reflect" "slices" @@ -26,7 +27,10 @@ var ( // core.WithAssets(assets), // ) func New(opts ...Option) (*Core, error) { + // Default IO rooted at "/" (full access, like os package) + defaultIO, _ := NewIO("/") c := &Core{ + io: defaultIO, etc: NewEtc(), crash: NewCrashHandler(), svc: newServiceManager(), @@ -123,7 +127,7 @@ func WithApp(app any) Option { } } -// WithAssets creates an Option that mounts the application's embedded assets. +// WithAssets creates an Option that mounts embedded assets. // The assets are accessible via c.Mnt(). func WithAssets(efs embed.FS) Option { return func(c *Core) error { @@ -136,10 +140,24 @@ func WithAssets(efs embed.FS) Option { } } -// WithMount creates an Option that mounts an embedded FS at a specific subdirectory. -func WithMount(efs embed.FS, basedir string) Option { +// WithIO creates an Option that sandboxes filesystem I/O to a root path. +// Default is "/" (full access). Use this to restrict c.Io() operations. +func WithIO(root string) Option { return func(c *Core) error { - sub, err := Mount(efs, basedir) + io, err := NewIO(root) + if err != nil { + return E("core.WithIO", "failed to create IO at "+root, err) + } + c.io = io + return nil + } +} + +// WithMount creates an Option that mounts an fs.FS at a specific subdirectory. +// The mounted assets are accessible via c.Mnt(). +func WithMount(fsys fs.FS, basedir string) Option { + return func(c *Core) error { + sub, err := Mount(fsys, basedir) if err != nil { return E("core.WithMount", "failed to mount "+basedir, err) } @@ -413,11 +431,3 @@ func (c *Core) Crypt() Crypt { // Core returns self, implementing the CoreProvider interface. func (c *Core) Core() *Core { return c } -// Assets returns the embedded filesystem containing the application's assets. -// Deprecated: use c.Mnt().Embed() instead. -func (c *Core) Assets() embed.FS { - if c.mnt != nil { - return c.mnt.Embed() - } - return embed.FS{} -} diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go index d666ce9..3532101 100644 --- a/pkg/core/core_test.go +++ b/pkg/core/core_test.go @@ -187,8 +187,7 @@ var testFS embed.FS func TestCore_WithAssets_Good(t *testing.T) { c, err := New(WithAssets(testFS)) assert.NoError(t, err) - assets := c.Assets() - file, err := assets.Open("testdata/test.txt") + file, err := c.Mnt().Open("testdata/test.txt") assert.NoError(t, err) defer func() { _ = file.Close() }() content, err := io.ReadAll(file) diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go index e3be378..8406b73 100644 --- a/pkg/core/interfaces.go +++ b/pkg/core/interfaces.go @@ -81,7 +81,8 @@ type LocaleProvider interface { // Core is the central application object that manages services, assets, and communication. type Core struct { App any // GUI runtime (e.g., Wails App) - set by WithApp option - mnt *Sub // Mount point for embedded assets + mnt *Sub // Mounted embedded assets (read-only) + io *IO // Local filesystem I/O (read/write, sandboxable) etc *Etc // Configuration, settings, and feature flags crash *CrashHandler // Panic recovery and crash reporting svc *serviceManager @@ -93,12 +94,22 @@ type Core struct { shutdown atomic.Bool } -// Mnt returns the mount point for embedded assets. -// Use this to read embedded files and extract template directories. +// Mnt returns the mounted embedded assets (read-only). +// +// c.Mnt().ReadString("persona/secops/developer.md") func (c *Core) Mnt() *Sub { return c.mnt } +// Io returns the local filesystem I/O layer. +// Default: rooted at "/". Sandboxable via WithIO("./data"). +// +// c.Io().Read("config.yaml") +// c.Io().Write("output.txt", content) +func (c *Core) Io() *IO { + return c.io +} + // Etc returns the configuration and feature flags store. // // c.Etc().Set("api_url", "https://api.lthn.sh") diff --git a/pkg/core/io.go b/pkg/core/io.go new file mode 100644 index 0000000..394385c --- /dev/null +++ b/pkg/core/io.go @@ -0,0 +1,307 @@ +// Package local provides a local filesystem implementation of the io.Medium interface. +package core + +import ( + "fmt" + goio "io" + "io/fs" + "os" + "os/user" + "path/filepath" + "strings" + "time" + + +) + +// Medium is a local filesystem storage backend. +type IO struct { + root string +} + +// New creates a new local Medium rooted at the given directory. +// Pass "/" for full filesystem access, or a specific path to sandbox. +func NewIO(root string) (*IO, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + // Resolve symlinks so sandbox checks compare like-for-like. + // On macOS, /var is a symlink to /private/var — without this, + // EvalSymlinks on child paths resolves to /private/var/... while + // root stays /var/..., causing false sandbox escape detections. + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + return &IO{root: abs}, nil +} + +// path sanitises and returns the full path. +// Absolute paths are sandboxed under root (unless root is "/"). +func (m *IO) path(p string) string { + if p == "" { + return m.root + } + + // If the path is relative and the medium is rooted at "/", + // treat it as relative to the current working directory. + // This makes io.Local behave more like the standard 'os' package. + if m.root == "/" && !filepath.IsAbs(p) { + cwd, _ := os.Getwd() + return filepath.Join(cwd, p) + } + + // Use filepath.Clean with a leading slash to resolve all .. and . internally + // before joining with the root. This is a standard way to sandbox paths. + clean := filepath.Clean("/" + p) + + // If root is "/", allow absolute paths through + if m.root == "/" { + return clean + } + + // Join cleaned relative path with root + return filepath.Join(m.root, clean) +} + +// validatePath ensures the path is within the sandbox, following symlinks if they exist. +func (m *IO) validatePath(p string) (string, error) { + if m.root == "/" { + return m.path(p), nil + } + + // Split the cleaned path into components + parts := strings.Split(filepath.Clean("/"+p), string(os.PathSeparator)) + current := m.root + + for _, part := range parts { + if part == "" { + continue + } + + next := filepath.Join(current, part) + realNext, err := filepath.EvalSymlinks(next) + if err != nil { + if os.IsNotExist(err) { + // Part doesn't exist, we can't follow symlinks anymore. + // Since the path is already Cleaned and current is safe, + // appending a component to current will not escape. + current = next + continue + } + return "", err + } + + // Verify the resolved part is still within the root + rel, err := filepath.Rel(m.root, realNext) + if err != nil || strings.HasPrefix(rel, "..") { + // Security event: sandbox escape attempt + username := "unknown" + if u, err := user.Current(); err == nil { + username = u.Username + } + fmt.Fprintf(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s\n", + time.Now().Format(time.RFC3339), m.root, p, realNext, username) + return "", os.ErrPermission // Path escapes sandbox + } + current = realNext + } + + return current, nil +} + +// Read returns file contents as string. +func (m *IO) Read(p string) (string, error) { + full, err := m.validatePath(p) + if err != nil { + return "", err + } + data, err := os.ReadFile(full) + if err != nil { + return "", err + } + return string(data), nil +} + +// Write saves content to file, creating parent directories as needed. +// Files are created with mode 0644. For sensitive files (keys, secrets), +// use WriteMode with 0600. +func (m *IO) Write(p, content string) error { + return m.WriteMode(p, content, 0644) +} + +// WriteMode saves content to file with explicit permissions. +// Use 0600 for sensitive files (encryption output, private keys, auth hashes). +func (m *IO) WriteMode(p, content string, mode os.FileMode) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return err + } + return os.WriteFile(full, []byte(content), mode) +} + +// EnsureDir creates directory if it doesn't exist. +func (m *IO) EnsureDir(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + return os.MkdirAll(full, 0755) +} + +// IsDir returns true if path is a directory. +func (m *IO) IsDir(p string) bool { + if p == "" { + return false + } + full, err := m.validatePath(p) + if err != nil { + return false + } + info, err := os.Stat(full) + return err == nil && info.IsDir() +} + +// IsFile returns true if path is a regular file. +func (m *IO) IsFile(p string) bool { + if p == "" { + return false + } + full, err := m.validatePath(p) + if err != nil { + return false + } + info, err := os.Stat(full) + return err == nil && info.Mode().IsRegular() +} + +// Exists returns true if path exists. +func (m *IO) Exists(p string) bool { + full, err := m.validatePath(p) + if err != nil { + return false + } + _, err = os.Stat(full) + return err == nil +} + +// List returns directory entries. +func (m *IO) List(p string) ([]fs.DirEntry, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.ReadDir(full) +} + +// Stat returns file info. +func (m *IO) Stat(p string) (fs.FileInfo, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.Stat(full) +} + +// Open opens the named file for reading. +func (m *IO) Open(p string) (fs.File, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.Open(full) +} + +// Create creates or truncates the named file. +func (m *IO) Create(p string) (goio.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.Create(full) +} + +// Append opens the named file for appending, creating it if it doesn't exist. +func (m *IO) Append(p string) (goio.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) +} + +// ReadStream returns a reader for the file content. +// +// This is a convenience wrapper around Open that exposes a streaming-oriented +// API, as required by the io.Medium interface, while Open provides the more +// general filesystem-level operation. Both methods are kept for semantic +// clarity and backward compatibility. +func (m *IO) ReadStream(path string) (goio.ReadCloser, error) { + return m.Open(path) +} + +// WriteStream returns a writer for the file content. +// +// This is a convenience wrapper around Create that exposes a streaming-oriented +// API, as required by the io.Medium interface, while Create provides the more +// general filesystem-level operation. Both methods are kept for semantic +// clarity and backward compatibility. +func (m *IO) WriteStream(path string) (goio.WriteCloser, error) { + return m.Create(path) +} + +// Delete removes a file or empty directory. +func (m *IO) Delete(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if full == "/" || full == os.Getenv("HOME") { + return E("local.Delete", "refusing to delete protected path: "+full, nil) + } + return os.Remove(full) +} + +// DeleteAll removes a file or directory recursively. +func (m *IO) DeleteAll(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if full == "/" || full == os.Getenv("HOME") { + return E("local.DeleteAll", "refusing to delete protected path: "+full, nil) + } + return os.RemoveAll(full) +} + +// Rename moves a file or directory. +func (m *IO) Rename(oldPath, newPath string) error { + oldFull, err := m.validatePath(oldPath) + if err != nil { + return err + } + newFull, err := m.validatePath(newPath) + if err != nil { + return err + } + return os.Rename(oldFull, newFull) +} + +// FileGet is an alias for Read. +func (m *IO) FileGet(p string) (string, error) { + return m.Read(p) +} + +// FileSet is an alias for Write. +func (m *IO) FileSet(p, content string) error { + return m.Write(p, content) +} diff --git a/pkg/core/mnt.go b/pkg/core/mnt.go index 538dc54..3f60749 100644 --- a/pkg/core/mnt.go +++ b/pkg/core/mnt.go @@ -1,21 +1,17 @@ // SPDX-License-Identifier: EUPL-1.2 -// Mount operations for the Core framework. mount operations for the Core framework. +// Mount operations for the Core framework. // -// Mount operations attach data to/from binaries and watch live filesystems: -// -// - FS: mount an embed.FS subdirectory for scoped access -// - Extract: extract a template directory with variable substitution -// - Watch: observe filesystem changes (file watcher) -// -// Zero external dependencies. All operations use stdlib only. +// Sub provides scoped filesystem access that works with: +// - go:embed (embed.FS) +// - any fs.FS implementation +// - the Core asset registry (AddAsset/GetAsset from embed.go) // // Usage: // -// sub, _ := mnt.FS(myEmbed, "lib/persona") -// content, _ := sub.ReadFile("secops/developer.md") -// -// mnt.Extract(sub, "/tmp/workspace", map[string]string{"Name": "myproject"}) +// sub, _ := core.Mount(myFS, "lib/persona") +// content, _ := sub.ReadString("secops/developer.md") +// sub.Extract("/tmp/workspace", data) package core import ( @@ -24,17 +20,24 @@ import ( "path/filepath" ) -// Sub wraps an embed.FS with a basedir for scoped access. +// Sub wraps an fs.FS with a basedir for scoped access. // All paths are relative to basedir. type Sub struct { basedir string - fs embed.FS + fsys fs.FS + embedFS *embed.FS // kept for Embed() backwards compat } -// FS creates a scoped view of an embed.FS anchored at basedir. -// Returns error if basedir doesn't exist in the embedded filesystem. -func Mount(efs embed.FS, basedir string) (*Sub, error) { - s := &Sub{fs: efs, basedir: basedir} +// Mount creates a scoped view of an fs.FS anchored at basedir. +// Works with embed.FS, os.DirFS, or any fs.FS implementation. +func Mount(fsys fs.FS, basedir string) (*Sub, error) { + s := &Sub{fsys: fsys, basedir: basedir} + + // If it's an embed.FS, keep a reference for Embed() + if efs, ok := fsys.(embed.FS); ok { + s.embedFS = &efs + } + // Verify the basedir exists if _, err := s.ReadDir("."); err != nil { return nil, err @@ -42,23 +45,29 @@ func Mount(efs embed.FS, basedir string) (*Sub, error) { return s, nil } +// MountEmbed creates a scoped view of an embed.FS. +// Convenience wrapper that preserves the embed.FS type for Embed(). +func MountEmbed(efs embed.FS, basedir string) (*Sub, error) { + return Mount(efs, basedir) +} + func (s *Sub) path(name string) string { return filepath.ToSlash(filepath.Join(s.basedir, name)) } // Open opens the named file for reading. func (s *Sub) Open(name string) (fs.File, error) { - return s.fs.Open(s.path(name)) + return s.fsys.Open(s.path(name)) } // ReadDir reads the named directory. func (s *Sub) ReadDir(name string) ([]fs.DirEntry, error) { - return s.fs.ReadDir(s.path(name)) + return fs.ReadDir(s.fsys, s.path(name)) } // ReadFile reads the named file. func (s *Sub) ReadFile(name string) ([]byte, error) { - return s.fs.ReadFile(s.path(name)) + return fs.ReadFile(s.fsys, s.path(name)) } // ReadString reads the named file as a string. @@ -72,12 +81,25 @@ func (s *Sub) ReadString(name string) (string, error) { // Sub returns a new Sub anchored at a subdirectory within this Sub. func (s *Sub) Sub(subDir string) (*Sub, error) { - return Mount(s.fs, s.path(subDir)) + sub, err := fs.Sub(s.fsys, s.path(subDir)) + if err != nil { + return nil, err + } + return &Sub{fsys: sub, basedir: "."}, nil } -// Embed returns the underlying embed.FS. +// FS returns the underlying fs.FS. +func (s *Sub) FS() fs.FS { + return s.fsys +} + +// Embed returns the underlying embed.FS if mounted from one. +// Returns zero embed.FS if mounted from a non-embed source. func (s *Sub) Embed() embed.FS { - return s.fs + if s.embedFS != nil { + return *s.embedFS + } + return embed.FS{} } // BaseDir returns the basedir this Sub is anchored at. From 8935905ac9599c49e483a6259f280ab65cb5b1ac Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 00:45:17 +0000 Subject: [PATCH 15/31] fix: remove goio alias, use io directly Co-Authored-By: Virgil --- pkg/core/io.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/core/io.go b/pkg/core/io.go index 394385c..dac67df 100644 --- a/pkg/core/io.go +++ b/pkg/core/io.go @@ -3,7 +3,7 @@ package core import ( "fmt" - goio "io" + "io" "io/fs" "os" "os/user" @@ -216,7 +216,7 @@ func (m *IO) Open(p string) (fs.File, error) { } // Create creates or truncates the named file. -func (m *IO) Create(p string) (goio.WriteCloser, error) { +func (m *IO) Create(p string) (io.WriteCloser, error) { full, err := m.validatePath(p) if err != nil { return nil, err @@ -228,7 +228,7 @@ func (m *IO) Create(p string) (goio.WriteCloser, error) { } // Append opens the named file for appending, creating it if it doesn't exist. -func (m *IO) Append(p string) (goio.WriteCloser, error) { +func (m *IO) Append(p string) (io.WriteCloser, error) { full, err := m.validatePath(p) if err != nil { return nil, err @@ -245,7 +245,7 @@ func (m *IO) Append(p string) (goio.WriteCloser, error) { // API, as required by the io.Medium interface, while Open provides the more // general filesystem-level operation. Both methods are kept for semantic // clarity and backward compatibility. -func (m *IO) ReadStream(path string) (goio.ReadCloser, error) { +func (m *IO) ReadStream(path string) (io.ReadCloser, error) { return m.Open(path) } @@ -255,7 +255,7 @@ func (m *IO) ReadStream(path string) (goio.ReadCloser, error) { // API, as required by the io.Medium interface, while Create provides the more // general filesystem-level operation. Both methods are kept for semantic // clarity and backward compatibility. -func (m *IO) WriteStream(path string) (goio.WriteCloser, error) { +func (m *IO) WriteStream(path string) (io.WriteCloser, error) { return m.Create(path) } From d1c9d4e4ad8070ed86885ca058a61eab5cbb0eb2 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:00:47 +0000 Subject: [PATCH 16/31] refactor: generic EtcGet[T] replaces typed getter boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetString/GetInt/GetBool now delegate to EtcGet[T]. Gemini Pro review finding — three identical functions collapsed to one generic. Co-Authored-By: Virgil --- pkg/core/etc.go | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/pkg/core/etc.go b/pkg/core/etc.go index 136caa5..3a58016 100644 --- a/pkg/core/etc.go +++ b/pkg/core/etc.go @@ -69,33 +69,24 @@ func (e *Etc) Get(key string) (any, bool) { } // GetString retrieves a string configuration value. -func (e *Etc) GetString(key string) string { - val, ok := e.Get(key) - if !ok { - return "" - } - s, _ := val.(string) - return s -} +func (e *Etc) GetString(key string) string { return EtcGet[string](e, key) } // GetInt retrieves an int configuration value. -func (e *Etc) GetInt(key string) int { - val, ok := e.Get(key) - if !ok { - return 0 - } - i, _ := val.(int) - return i -} +func (e *Etc) GetInt(key string) int { return EtcGet[int](e, key) } // GetBool retrieves a bool configuration value. -func (e *Etc) GetBool(key string) bool { +func (e *Etc) GetBool(key string) bool { return EtcGet[bool](e, key) } + +// EtcGet retrieves a typed configuration value. +// Returns zero value if key is missing or type doesn't match. +func EtcGet[T any](e *Etc, key string) T { val, ok := e.Get(key) if !ok { - return false + var zero T + return zero } - b, _ := val.(bool) - return b + typed, _ := val.(T) + return typed } // --- Feature Flags --- From 81eba2777a3d384d42e789b0317976fb9edc7158 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:02:48 +0000 Subject: [PATCH 17/31] =?UTF-8?q?fix:=20apply=20Gemini=20Pro=20review=20?= =?UTF-8?q?=E2=80=94=20maps.Clone=20for=20crash=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents external mutation of crash handler metadata after construction. Uses maps.Clone (Go 1.21+) as suggested by Gemini Pro review. Co-Authored-By: Virgil --- pkg/core/crash.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/core/crash.go b/pkg/core/crash.go index bf1fb94..6a9ed87 100644 --- a/pkg/core/crash.go +++ b/pkg/core/crash.go @@ -9,6 +9,7 @@ package core import ( "encoding/json" "fmt" + "maps" "os" "runtime" "runtime/debug" @@ -48,7 +49,8 @@ func WithCrashFile(path string) CrashOption { // WithCrashMeta adds metadata included in every crash report. func WithCrashMeta(meta map[string]string) CrashOption { - return func(h *CrashHandler) { h.meta = meta } + cloned := maps.Clone(meta) + return func(h *CrashHandler) { h.meta = cloned } } // WithCrashHandler sets a callback invoked on every crash. From 55cbfea7ca4069ea862f262b01a1e94d05f906f5 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:12:10 +0000 Subject: [PATCH 18/31] fix: apply Gemini review findings on embed.go - Fix decompress: check gz.Close() error (checksum verification) - Remove dead groupPaths variable (never read) - Remove redundant AssetRef.Path (duplicate of Name) - Remove redundant AssetGroup.name (key in map is the name) Gemini found 8 issues, 4 were real bugs/dead code. Co-Authored-By: Virgil --- pkg/core/embed.go | 69 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/pkg/core/embed.go b/pkg/core/embed.go index 33d5a89..dd09c43 100644 --- a/pkg/core/embed.go +++ b/pkg/core/embed.go @@ -21,14 +21,15 @@ package core import ( + "bytes" "compress/gzip" "encoding/base64" - "bytes" "fmt" "go/ast" "go/parser" "go/token" "io" + "io/fs" "os" "path/filepath" "strings" @@ -39,7 +40,7 @@ import ( // AssetGroup holds a named collection of packed assets. type AssetGroup struct { - name string + assets map[string]string // name → compressed data } @@ -55,7 +56,7 @@ func AddAsset(group, name, data string) { g, ok := assetGroups[group] if !ok { - g = &AssetGroup{name: group, assets: make(map[string]string)} + g = &AssetGroup{assets: make(map[string]string)} assetGroups[group] = g } g.assets[name] = data @@ -86,10 +87,10 @@ func GetAssetBytes(group, name string) ([]byte, error) { // AssetRef is a reference to an asset found in source code. type AssetRef struct { - Name string - Path string - Group string - FullPath string + Name string + Path string + Group string + FullPath string } // ScannedPackage holds all asset references from a set of source files. @@ -104,7 +105,7 @@ type ScannedPackage struct { // Looks for calls to: core.GetAsset("group", "name"), core.AddAsset, etc. func ScanAssets(filenames []string) ([]ScannedPackage, error) { packageMap := make(map[string]*ScannedPackage) - groupPaths := make(map[string]string) // variable name → path + var scanErr error for _, filename := range filenames { fset := token.NewFileSet() @@ -122,6 +123,9 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) { pkg.PackageName = node.Name.Name ast.Inspect(node, func(n ast.Node) bool { + if scanErr != nil { + return false + } call, ok := n.(*ast.CallExpr) if !ok { return true @@ -150,10 +154,14 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) { group = strings.Trim(glit.Value, "\"") } } - fullPath, _ := filepath.Abs(filepath.Join(baseDir, group, path)) + fullPath, err := filepath.Abs(filepath.Join(baseDir, group, path)) + if err != nil { + scanErr = fmt.Errorf("could not determine absolute path for asset %q in group %q: %w", path, group, err) + return false + } pkg.Assets = append(pkg.Assets, AssetRef{ Name: path, - Path: path, + Group: group, FullPath: fullPath, }) @@ -164,10 +172,13 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) { if len(call.Args) == 1 { if lit, ok := call.Args[0].(*ast.BasicLit); ok { path := strings.Trim(lit.Value, "\"") - fullPath, _ := filepath.Abs(filepath.Join(baseDir, path)) + fullPath, err := filepath.Abs(filepath.Join(baseDir, path)) + if err != nil { + scanErr = fmt.Errorf("could not determine absolute path for group %q: %w", path, err) + return false + } pkg.Groups = append(pkg.Groups, fullPath) // Track for variable resolution - groupPaths[path] = fullPath } } } @@ -175,6 +186,9 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) { return true }) + if scanErr != nil { + return nil, scanErr + } } var result []ScannedPackage @@ -203,7 +217,7 @@ func GeneratePack(pkg ScannedPackage) (string, error) { for _, groupPath := range pkg.Groups { files, err := getAllFiles(groupPath) if err != nil { - continue + return "", fmt.Errorf("failed to scan asset group %q: %w", groupPath, err) } for _, file := range files { if packed[file] { @@ -211,10 +225,13 @@ func GeneratePack(pkg ScannedPackage) (string, error) { } data, err := compressFile(file) if err != nil { - continue + return "", fmt.Errorf("failed to compress asset %q in group %q: %w", file, groupPath, err) } localPath := strings.TrimPrefix(file, groupPath+"/") - relGroup, _ := filepath.Rel(pkg.BaseDir, groupPath) + relGroup, err := filepath.Rel(pkg.BaseDir, groupPath) + if err != nil { + return "", fmt.Errorf("could not determine relative path for group %q (base %q): %w", groupPath, pkg.BaseDir, err) + } b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data)) packed[file] = true } @@ -227,7 +244,7 @@ func GeneratePack(pkg ScannedPackage) (string, error) { } data, err := compressFile(asset.FullPath) if err != nil { - continue + return "", fmt.Errorf("failed to compress asset %q: %w", asset.FullPath, err) } b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data)) packed[asset.FullPath] = true @@ -255,10 +272,17 @@ func compress(input string) (string, error) { return "", err } if _, err := gz.Write([]byte(input)); err != nil { + _ = gz.Close() + _ = b64.Close() + return "", err + } + if err := gz.Close(); err != nil { + _ = b64.Close() + return "", err + } + if err := b64.Close(); err != nil { return "", err } - gz.Close() - b64.Close() return buf.String(), nil } @@ -268,21 +292,24 @@ func decompress(input string) (string, error) { if err != nil { return "", err } - defer gz.Close() + data, err := io.ReadAll(gz) if err != nil { return "", err } + if err := gz.Close(); err != nil { + return "", err + } return string(data), nil } func getAllFiles(dir string) ([]string, error) { var result []string - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } - if info.Mode().IsRegular() { + if !d.IsDir() { result = append(result, path) } return nil From dd6803df10894a74f95066977e0dd8bd76779f8f Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:16:30 +0000 Subject: [PATCH 19/31] fix(security): fix latent sandbox escape in IO.path() filepath.Clean("/"+p) returns absolute path, filepath.Join(root, "/abs") drops root on Linux. Strip leading "/" before joining with sandbox root. Currently not exploitable (validatePath handles it), but any future caller of path() with active sandbox would escape. Defensive fix. Found by Gemini Pro security review. Co-Authored-By: Virgil --- pkg/core/io.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/core/io.go b/pkg/core/io.go index dac67df..bc7b84b 100644 --- a/pkg/core/io.go +++ b/pkg/core/io.go @@ -60,8 +60,8 @@ func (m *IO) path(p string) string { return clean } - // Join cleaned relative path with root - return filepath.Join(m.root, clean) + // Strip leading "/" so Join works correctly with root + return filepath.Join(m.root, clean[1:]) } // validatePath ensures the path is within the sandbox, following symlinks if they exist. From 16a985ad5c15e7e12cd31c029bbd73b05bfd028d Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:23:02 +0000 Subject: [PATCH 20/31] =?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) -} From 8f2e3d94571c8bfcb06210fb751ae89da816292e Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:28:14 +0000 Subject: [PATCH 21/31] =?UTF-8?q?chore:=20clean=20up=20=E2=80=94=20remove?= =?UTF-8?q?=20core.go=20re-export,=20pkg/mnt,=20go-io/go-log=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed: - core.go (top-level re-export layer, no longer needed) - pkg/mnt/ (absorbed into pkg/core/mnt.go) - pkg/log/ (absorbed into pkg/core/log.go) - go-io dependency (absorbed into pkg/core/io.go) - go-log dependency (absorbed into pkg/core/error.go + log.go) Remaining: single package pkg/core/ with 14 source files. Only dependency: testify (test-only). Production code: zero external dependencies. Co-Authored-By: Virgil --- core.go | 108 ------------- go.mod | 9 +- go.sum | 10 +- pkg/mnt/extract.go | 194 ----------------------- pkg/mnt/mnt.go | 86 ---------- pkg/mnt/mnt_test.go | 106 ------------- pkg/mnt/testdata/hello.txt | 1 - pkg/mnt/testdata/subdir/nested.txt | 1 - pkg/mnt/testdata/template/README.md.tmpl | 1 - pkg/mnt/testdata/template/go.mod.tmpl | 1 - pkg/mnt/testdata/template/main.go | 1 - 11 files changed, 10 insertions(+), 508 deletions(-) delete mode 100644 core.go delete mode 100644 pkg/mnt/extract.go delete mode 100644 pkg/mnt/mnt.go delete mode 100644 pkg/mnt/mnt_test.go delete mode 100644 pkg/mnt/testdata/hello.txt delete mode 100644 pkg/mnt/testdata/subdir/nested.txt delete mode 100644 pkg/mnt/testdata/template/README.md.tmpl delete mode 100644 pkg/mnt/testdata/template/go.mod.tmpl delete mode 100644 pkg/mnt/testdata/template/main.go diff --git a/core.go b/core.go deleted file mode 100644 index 96b3bb1..0000000 --- a/core.go +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -// Package core is the Core framework for Go. -// -// Single import, single struct, everything accessible: -// -// import core "forge.lthn.ai/core/go" -// -// c, _ := core.New( -// core.WithAssets(myEmbed), -// core.WithService(myFactory), -// ) -// -// // DI -// svc, _ := core.ServiceFor[*MyService](c, "name") -// -// // Mount -// content, _ := c.Mnt().ReadString("persona/secops/developer.md") -// c.Mnt().Extract(targetDir, data) -// -// // IPC -// c.ACTION(msg) -package core - -import ( - di "forge.lthn.ai/core/go/pkg/core" -) - -// --- Types --- - -// Core is the central application container. -type Core = di.Core - -// Option configures a Core instance. -type Option = di.Option - -// Message is the IPC message type. -type Message = di.Message - -// Sub is a scoped view of an embedded filesystem. -type Sub = di.Sub - -// ExtractOptions configures template extraction. -type ExtractOptions = di.ExtractOptions - -// Startable is implemented by services with startup logic. -type Startable = di.Startable - -// Stoppable is implemented by services with shutdown logic. -type Stoppable = di.Stoppable - -// LocaleProvider provides locale filesystems for i18n. -type LocaleProvider = di.LocaleProvider - -// ServiceRuntime is the base for services with typed options. -type ServiceRuntime[T any] = di.ServiceRuntime[T] - -// --- Constructor + Options --- - -// New creates a new Core instance. -var New = di.New - -// WithService registers a service factory. -var WithService = di.WithService - -// WithName registers a named service factory. -var WithName = di.WithName - -// WithAssets mounts an embedded filesystem. -var WithAssets = di.WithAssets - -// WithMount mounts an embedded filesystem at a subdirectory. -var WithMount = di.WithMount - -// WithServiceLock prevents late service registration. -var WithServiceLock = di.WithServiceLock - -// WithApp sets the GUI runtime. -var WithApp = di.WithApp - -// Mount creates a scoped view of an embed.FS at basedir. -var Mount = di.Mount - -// --- Generic Functions --- - -// ServiceFor retrieves a typed service by name. -func ServiceFor[T any](c *Core, name string) (T, error) { - return di.ServiceFor[T](c, name) -} - -// E creates a structured error. -var E = di.E - -// --- Configuration (core.Etc) --- - -// Etc is the configuration and feature flags store. -type Etc = di.Etc - -// NewEtc creates a standalone configuration store. -var NewEtc = di.NewEtc - -// Var is a typed optional variable (set/unset/get). -type Var[T any] = di.Var[T] - -// NewVar creates a Var with the given value. -func NewVar[T any](val T) Var[T] { - return di.NewVar(val) -} diff --git a/go.mod b/go.mod index 2620c9e..e6140ad 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,13 @@ module forge.lthn.ai/core/go go 1.26.0 -require ( - forge.lthn.ai/core/go-io v0.1.6 - forge.lthn.ai/core/go-log v0.0.4 - github.com/stretchr/testify v1.11.1 -) +require github.com/stretchr/testify v1.11.1 require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b83e5f7..5a10c39 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,17 @@ -forge.lthn.ai/core/go-io v0.1.6 h1:RByYeP829HFqR2yLg5iBM5dGHKzPFYc+udl/Y1DZIRs= -forge.lthn.ai/core/go-io v0.1.6/go.mod h1:3MSuQZuzhCi6aefECQ/LxhM8ooVLam1KgEvgeEjYZVc= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/pkg/mnt/extract.go b/pkg/mnt/extract.go deleted file mode 100644 index 797f983..0000000 --- a/pkg/mnt/extract.go +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -package mnt - -import ( - "bytes" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - "text/template" -) - -// ExtractOptions configures template extraction. -type ExtractOptions struct { - // TemplateFilters identifies template files by substring match. - // Default: [".tmpl"] - TemplateFilters []string - - // IgnoreFiles is a set of filenames to skip during extraction. - IgnoreFiles map[string]struct{} - - // RenameFiles maps original filenames to new names. - RenameFiles map[string]string -} - -// Extract copies a template directory from an fs.FS to targetDir, -// processing Go text/template in filenames and file contents. -// -// Files containing a template filter substring (default: ".tmpl") have -// their contents processed through text/template with the given data. -// The filter is stripped from the output filename. -// -// Directory and file names can contain Go template expressions: -// {{.Name}}/main.go → myproject/main.go -// -// Data can be any struct or map[string]string for template substitution. -func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error { - opt := ExtractOptions{ - TemplateFilters: []string{".tmpl"}, - IgnoreFiles: make(map[string]struct{}), - RenameFiles: make(map[string]string), - } - if len(opts) > 0 { - if len(opts[0].TemplateFilters) > 0 { - opt.TemplateFilters = opts[0].TemplateFilters - } - if opts[0].IgnoreFiles != nil { - opt.IgnoreFiles = opts[0].IgnoreFiles - } - if opts[0].RenameFiles != nil { - opt.RenameFiles = opts[0].RenameFiles - } - } - - // Ensure target directory exists - targetDir, err := filepath.Abs(targetDir) - if err != nil { - return err - } - if err := os.MkdirAll(targetDir, 0755); err != nil { - return err - } - - // Categorise files - var dirs []string - var templateFiles []string - var standardFiles []string - - err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == "." { - return nil - } - if d.IsDir() { - dirs = append(dirs, path) - return nil - } - filename := filepath.Base(path) - if _, ignored := opt.IgnoreFiles[filename]; ignored { - return nil - } - if isTemplate(filename, opt.TemplateFilters) { - templateFiles = append(templateFiles, path) - } else { - standardFiles = append(standardFiles, path) - } - return nil - }) - if err != nil { - return err - } - - // Create directories (names may contain templates) - for _, dir := range dirs { - target := renderPath(filepath.Join(targetDir, dir), data) - if err := os.MkdirAll(target, 0755); err != nil { - return err - } - } - - // Process template files - for _, path := range templateFiles { - tmpl, err := template.ParseFS(fsys, path) - if err != nil { - return err - } - - targetFile := renderPath(filepath.Join(targetDir, path), data) - - // Strip template filters from filename - dir := filepath.Dir(targetFile) - name := filepath.Base(targetFile) - for _, filter := range opt.TemplateFilters { - name = strings.ReplaceAll(name, filter, "") - } - if renamed := opt.RenameFiles[name]; renamed != "" { - name = renamed - } - targetFile = filepath.Join(dir, name) - - f, err := os.Create(targetFile) - if err != nil { - return err - } - if err := tmpl.Execute(f, data); err != nil { - f.Close() - return err - } - f.Close() - } - - // Copy standard files - for _, path := range standardFiles { - name := filepath.Base(path) - if renamed := opt.RenameFiles[name]; renamed != "" { - path = filepath.Join(filepath.Dir(path), renamed) - } - target := renderPath(filepath.Join(targetDir, path), data) - if err := copyFile(fsys, path, target); err != nil { - return err - } - } - - return nil -} - -func isTemplate(filename string, filters []string) bool { - for _, f := range filters { - if strings.Contains(filename, f) { - return true - } - } - return false -} - -func renderPath(path string, data any) string { - if data == nil { - return path - } - tmpl, err := template.New("path").Parse(path) - if err != nil { - return path - } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return path - } - return buf.String() -} - -func copyFile(fsys fs.FS, source, target string) error { - s, err := fsys.Open(source) - if err != nil { - return err - } - defer s.Close() - - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - - d, err := os.Create(target) - if err != nil { - return err - } - defer d.Close() - - _, err = io.Copy(d, s) - return err -} diff --git a/pkg/mnt/mnt.go b/pkg/mnt/mnt.go deleted file mode 100644 index 5acb4f6..0000000 --- a/pkg/mnt/mnt.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -// Package mnt provides mount operations for the Core framework. -// -// Mount operations attach data to/from binaries and watch live filesystems: -// -// - FS: mount an embed.FS subdirectory for scoped access -// - Extract: extract a template directory with variable substitution -// - Watch: observe filesystem changes (file watcher) -// -// Zero external dependencies. All operations use stdlib only. -// -// Usage: -// -// sub, _ := mnt.FS(myEmbed, "lib/persona") -// content, _ := sub.ReadFile("secops/developer.md") -// -// mnt.Extract(sub, "/tmp/workspace", map[string]string{"Name": "myproject"}) -package mnt - -import ( - "embed" - "io/fs" - "path/filepath" -) - -// Sub wraps an embed.FS with a basedir for scoped access. -// All paths are relative to basedir. -type Sub struct { - basedir string - fs embed.FS -} - -// FS creates a scoped view of an embed.FS anchored at basedir. -// Returns error if basedir doesn't exist in the embedded filesystem. -func FS(efs embed.FS, basedir string) (*Sub, error) { - s := &Sub{fs: efs, basedir: basedir} - // Verify the basedir exists - if _, err := s.ReadDir("."); err != nil { - return nil, err - } - return s, nil -} - -func (s *Sub) path(name string) string { - return filepath.ToSlash(filepath.Join(s.basedir, name)) -} - -// Open opens the named file for reading. -func (s *Sub) Open(name string) (fs.File, error) { - return s.fs.Open(s.path(name)) -} - -// ReadDir reads the named directory. -func (s *Sub) ReadDir(name string) ([]fs.DirEntry, error) { - return s.fs.ReadDir(s.path(name)) -} - -// ReadFile reads the named file. -func (s *Sub) ReadFile(name string) ([]byte, error) { - return s.fs.ReadFile(s.path(name)) -} - -// ReadString reads the named file as a string. -func (s *Sub) ReadString(name string) (string, error) { - data, err := s.ReadFile(name) - if err != nil { - return "", err - } - return string(data), nil -} - -// Sub returns a new Sub anchored at a subdirectory within this Sub. -func (s *Sub) Sub(subDir string) (*Sub, error) { - return FS(s.fs, s.path(subDir)) -} - -// Embed returns the underlying embed.FS. -func (s *Sub) Embed() embed.FS { - return s.fs -} - -// BaseDir returns the basedir this Sub is anchored at. -func (s *Sub) BaseDir() string { - return s.basedir -} diff --git a/pkg/mnt/mnt_test.go b/pkg/mnt/mnt_test.go deleted file mode 100644 index 0d65e38..0000000 --- a/pkg/mnt/mnt_test.go +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -package mnt_test - -import ( - "embed" - "os" - "testing" - - "forge.lthn.ai/core/go/pkg/mnt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -//go:embed testdata -var testFS embed.FS - -func TestFS_Good(t *testing.T) { - sub, err := mnt.FS(testFS, "testdata") - require.NoError(t, err) - assert.Equal(t, "testdata", sub.BaseDir()) -} - -func TestFS_Bad_InvalidDir(t *testing.T) { - _, err := mnt.FS(testFS, "nonexistent") - assert.Error(t, err) -} - -func TestSub_ReadFile_Good(t *testing.T) { - sub, err := mnt.FS(testFS, "testdata") - require.NoError(t, err) - - data, err := sub.ReadFile("hello.txt") - require.NoError(t, err) - assert.Equal(t, "hello world\n", string(data)) -} - -func TestSub_ReadString_Good(t *testing.T) { - sub, err := mnt.FS(testFS, "testdata") - require.NoError(t, err) - - content, err := sub.ReadString("hello.txt") - require.NoError(t, err) - assert.Equal(t, "hello world\n", content) -} - -func TestSub_ReadDir_Good(t *testing.T) { - sub, err := mnt.FS(testFS, "testdata") - require.NoError(t, err) - - entries, err := sub.ReadDir(".") - require.NoError(t, err) - assert.True(t, len(entries) >= 1) -} - -func TestSub_Sub_Good(t *testing.T) { - sub, err := mnt.FS(testFS, "testdata") - require.NoError(t, err) - - nested, err := sub.Sub("subdir") - require.NoError(t, err) - - content, err := nested.ReadString("nested.txt") - require.NoError(t, err) - assert.Equal(t, "nested content\n", content) -} - -func TestExtract_Good(t *testing.T) { - sub, err := mnt.FS(testFS, "testdata/template") - require.NoError(t, err) - - targetDir := t.TempDir() - err = mnt.Extract(sub, targetDir, map[string]string{ - "Name": "myproject", - "Module": "forge.lthn.ai/core/myproject", - }) - require.NoError(t, err) - - // Check template was processed - readme, err := os.ReadFile(targetDir + "/README.md") - require.NoError(t, err) - assert.Contains(t, string(readme), "myproject project") - - // Check go.mod template was processed - gomod, err := os.ReadFile(targetDir + "/go.mod") - require.NoError(t, err) - assert.Contains(t, string(gomod), "module forge.lthn.ai/core/myproject") - - // Check non-template was copied as-is - main, err := os.ReadFile(targetDir + "/main.go") - require.NoError(t, err) - assert.Equal(t, "package main\n", string(main)) -} - -func TestExtract_Good_NoData(t *testing.T) { - sub, err := mnt.FS(testFS, "testdata") - require.NoError(t, err) - - targetDir := t.TempDir() - err = mnt.Extract(sub, targetDir, nil) - require.NoError(t, err) - - data, err := os.ReadFile(targetDir + "/hello.txt") - require.NoError(t, err) - assert.Equal(t, "hello world\n", string(data)) -} diff --git a/pkg/mnt/testdata/hello.txt b/pkg/mnt/testdata/hello.txt deleted file mode 100644 index 3b18e51..0000000 --- a/pkg/mnt/testdata/hello.txt +++ /dev/null @@ -1 +0,0 @@ -hello world diff --git a/pkg/mnt/testdata/subdir/nested.txt b/pkg/mnt/testdata/subdir/nested.txt deleted file mode 100644 index ca281f5..0000000 --- a/pkg/mnt/testdata/subdir/nested.txt +++ /dev/null @@ -1 +0,0 @@ -nested content diff --git a/pkg/mnt/testdata/template/README.md.tmpl b/pkg/mnt/testdata/template/README.md.tmpl deleted file mode 100644 index fdc89c8..0000000 --- a/pkg/mnt/testdata/template/README.md.tmpl +++ /dev/null @@ -1 +0,0 @@ -{{.Name}} project diff --git a/pkg/mnt/testdata/template/go.mod.tmpl b/pkg/mnt/testdata/template/go.mod.tmpl deleted file mode 100644 index 9f840df..0000000 --- a/pkg/mnt/testdata/template/go.mod.tmpl +++ /dev/null @@ -1 +0,0 @@ -module {{.Module}} diff --git a/pkg/mnt/testdata/template/main.go b/pkg/mnt/testdata/template/main.go deleted file mode 100644 index 06ab7d0..0000000 --- a/pkg/mnt/testdata/template/main.go +++ /dev/null @@ -1 +0,0 @@ -package main From 3b3b0425092e34f125d8dd75a59459f2fbb9a5e5 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 01:43:03 +0000 Subject: [PATCH 22/31] =?UTF-8?q?feat:=20add=20c.Cli()=20=E2=80=94=20zero-?= =?UTF-8?q?dep=20CLI=20framework=20on=20Core=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absorbs leaanthony/clir (1526 lines, 0 deps) into pkg/core: cli.go — NewCliApp constructor cli_app.go — CliApp struct (commands, flags, run) cli_action.go — CliAction type cli_command.go — Command (subcommands, flags, help, run) Any CoreGO package can declare CLI commands without importing a CLI package: c.Cli().NewSubCommand("health", "Check status").Action(func() error { return c.Io().Read("status.json") }) Uses stdlib flag package only. Zero external dependencies. core/cli becomes the rich TUI/polish layer on top. Based on leaanthony/clir — zero-dep CLI, 0 byte go.sum. Co-Authored-By: Virgil --- pkg/core/cli.go | 28 + pkg/core/cli_action.go | 5 + pkg/core/cli_app.go | 159 +++++ pkg/core/cli_command.go | 1334 +++++++++++++++++++++++++++++++++++++++ pkg/core/interfaces.go | 9 + 5 files changed, 1535 insertions(+) create mode 100644 pkg/core/cli.go create mode 100644 pkg/core/cli_action.go create mode 100644 pkg/core/cli_app.go create mode 100644 pkg/core/cli_command.go diff --git a/pkg/core/cli.go b/pkg/core/cli.go new file mode 100644 index 0000000..bb76ccd --- /dev/null +++ b/pkg/core/cli.go @@ -0,0 +1,28 @@ +// Package clir provides a simple API for creating command line apps +package core + +import ( + "fmt" +) + +// defaultBannerFunction prints a banner for the application. +// If version is a blank string, it is ignored. +func defaultBannerFunction(c *CliApp) string { + version := "" + if len(c.Version()) > 0 { + version = " " + c.Version() + } + return fmt.Sprintf("%s%s - %s", c.Name(), version, c.ShortDescription()) +} + +// NewCli - Creates a new Cli application object +func NewCliApp(name, description, version string) *CliApp { + result := &CliApp{ + version: version, + bannerFunction: defaultBannerFunction, + } + result.rootCommand = NewCommand(name, description) + result.rootCommand.setApp(result) + result.rootCommand.setParentCommandPath("") + return result +} diff --git a/pkg/core/cli_action.go b/pkg/core/cli_action.go new file mode 100644 index 0000000..2e6dbd9 --- /dev/null +++ b/pkg/core/cli_action.go @@ -0,0 +1,5 @@ +package core + +// Action represents a function that gets calls when the command is called by +// the user +type CliAction func() error diff --git a/pkg/core/cli_app.go b/pkg/core/cli_app.go new file mode 100644 index 0000000..063af3e --- /dev/null +++ b/pkg/core/cli_app.go @@ -0,0 +1,159 @@ +package core + +import ( + "fmt" + "os" +) + +// Cli - The main application object. +type CliApp struct { + version string + rootCommand *Command + defaultCommand *Command + preRunCommand func(*CliApp) error + postRunCommand func(*CliApp) error + bannerFunction func(*CliApp) string + errorHandler func(string, error) error +} + +// Version - Get the Application version string. +func (c *CliApp) Version() string { + return c.version +} + +// Name - Get the Application Name +func (c *CliApp) Name() string { + return c.rootCommand.name +} + +// ShortDescription - Get the Application short description. +func (c *CliApp) ShortDescription() string { + return c.rootCommand.shortdescription +} + +// SetBannerFunction - Set the function that is called +// to get the banner string. +func (c *CliApp) SetBannerFunction(fn func(*CliApp) string) { + c.bannerFunction = fn +} + +// SetErrorFunction - Set custom error message when undefined +// flags are used by the user. First argument is a string containing +// the command path used. Second argument is the undefined flag error. +func (c *CliApp) SetErrorFunction(fn func(string, error) error) { + c.errorHandler = fn +} + +// AddCommand - Adds a command to the application. +func (c *CliApp) AddCommand(command *Command) { + c.rootCommand.AddCommand(command) +} + +// PrintBanner - Prints the application banner! +func (c *CliApp) PrintBanner() { + fmt.Println(c.bannerFunction(c)) + fmt.Println("") +} + +// PrintHelp - Prints the application's help. +func (c *CliApp) PrintHelp() { + c.rootCommand.PrintHelp() +} + +// Run - Runs the application with the given arguments. +func (c *CliApp) Run(args ...string) error { + if c.preRunCommand != nil { + err := c.preRunCommand(c) + if err != nil { + return err + } + } + if len(args) == 0 { + args = os.Args[1:] + } + if err := c.rootCommand.run(args); err != nil { + return err + } + + if c.postRunCommand != nil { + err := c.postRunCommand(c) + if err != nil { + return err + } + } + + return nil +} + +// DefaultCommand - Sets the given command as the command to run when +// no other commands given. +func (c *CliApp) DefaultCommand(defaultCommand *Command) *CliApp { + c.defaultCommand = defaultCommand + return c +} + +// NewSubCommand - Creates a new SubCommand for the application. +func (c *CliApp) NewSubCommand(name, description string) *Command { + return c.rootCommand.NewSubCommand(name, description) +} + +// NewSubCommandInheritFlags - Creates a new SubCommand for the application, inherit flags from parent Command +func (c *CliApp) NewSubCommandInheritFlags(name, description string) *Command { + return c.rootCommand.NewSubCommandInheritFlags(name, description) +} + +// PreRun - Calls the given function before running the specific command. +func (c *CliApp) PreRun(callback func(*CliApp) error) { + c.preRunCommand = callback +} + +// PostRun - Calls the given function after running the specific command. +func (c *CliApp) PostRun(callback func(*CliApp) error) { + c.postRunCommand = callback +} + +// BoolFlag - Adds a boolean flag to the root command. +func (c *CliApp) BoolFlag(name, description string, variable *bool) *CliApp { + c.rootCommand.BoolFlag(name, description, variable) + return c +} + +// StringFlag - Adds a string flag to the root command. +func (c *CliApp) StringFlag(name, description string, variable *string) *CliApp { + c.rootCommand.StringFlag(name, description, variable) + return c +} + +// IntFlag - Adds an int flag to the root command. +func (c *CliApp) IntFlag(name, description string, variable *int) *CliApp { + c.rootCommand.IntFlag(name, description, variable) + return c +} + +func (c *CliApp) AddFlags(flags interface{}) *CliApp { + c.rootCommand.AddFlags(flags) + return c +} + +// Action - Define an action from this command. +func (c *CliApp) Action(callback CliAction) *CliApp { + c.rootCommand.Action(callback) + return c +} + +// LongDescription - Sets the long description for the command. +func (c *CliApp) LongDescription(longdescription string) *CliApp { + c.rootCommand.LongDescription(longdescription) + return c +} + +// OtherArgs - Returns the non-flag arguments passed to the cli. +// NOTE: This should only be called within the context of an action. +func (c *CliApp) OtherArgs() []string { + return c.rootCommand.flags.Args() +} + +func (c *CliApp) NewSubCommandFunction(name string, description string, test interface{}) *CliApp { + c.rootCommand.NewSubCommandFunction(name, description, test) + return c +} diff --git a/pkg/core/cli_command.go b/pkg/core/cli_command.go new file mode 100644 index 0000000..b50b0e0 --- /dev/null +++ b/pkg/core/cli_command.go @@ -0,0 +1,1334 @@ +package core + +import ( + "errors" + "flag" + "fmt" + "os" + "reflect" + "strconv" + "strings" +) + +// Command represents a command that may be run by the user +type Command struct { + name string + commandPath string + shortdescription string + longdescription string + subCommands []*Command + subCommandsMap map[string]*Command + longestSubcommand int + actionCallback CliAction + app *CliApp + flags *flag.FlagSet + flagCount int + helpFlag bool + hidden bool + positionalArgsMap map[string]reflect.Value + sliceSeparator map[string]string +} + +// NewCommand creates a new Command +// func NewCommand(name string, description string, app *Cli, parentCommandPath string) *Command { +func NewCommand(name string, description string) *Command { + result := &Command{ + name: name, + shortdescription: description, + subCommandsMap: make(map[string]*Command), + hidden: false, + positionalArgsMap: make(map[string]reflect.Value), + sliceSeparator: make(map[string]string), + } + + return result +} + +func (c *Command) setParentCommandPath(parentCommandPath string) { + // Set up command path + if parentCommandPath != "" { + c.commandPath += parentCommandPath + " " + } + c.commandPath += c.name + + // Set up flag set + c.flags = flag.NewFlagSet(c.commandPath, flag.ContinueOnError) + c.BoolFlag("help", "Get help on the '"+strings.ToLower(c.commandPath)+"' command.", &c.helpFlag) + + // result.Flags.Usage = result.PrintHelp + +} + +func (c *Command) inheritFlags(inheritFlags *flag.FlagSet) { + // inherit flags + inheritFlags.VisitAll(func(f *flag.Flag) { + if f.Name != "help" { + c.flags.Var(f.Value, f.Name, f.Usage) + } + }) +} + +func (c *Command) setApp(app *CliApp) { + c.app = app +} + +// parseFlags parses the given flags +func (c *Command) parseFlags(args []string) error { + // Parse flags + tmp := os.Stderr + os.Stderr = nil + defer func() { + os.Stderr = tmp + }() + + // Credit: https://stackoverflow.com/a/74146375 + var positionalArgs []string + for { + if err := c.flags.Parse(args); err != nil { + return err + } + // Consume all the flags that were parsed as flags. + args = args[len(args)-c.flags.NArg():] + if len(args) == 0 { + break + } + // There's at least one flag remaining and it must be a positional arg since + // we consumed all args that were parsed as flags. Consume just the first + // one, and retry parsing, since subsequent args may be flags. + positionalArgs = append(positionalArgs, args[0]) + args = args[1:] + } + + // Parse just the positional args so that flagset.Args()/flagset.NArgs() + // return the expected value. + // Note: This should never return an error. + err := c.flags.Parse(positionalArgs) + if err != nil { + return err + } + + if len(positionalArgs) > 0 { + return c.parsePositionalArgs(positionalArgs) + } + return nil +} + +// Run - Runs the Command with the given arguments +func (c *Command) run(args []string) error { + + // If we have arguments, process them + if len(args) > 0 { + // Check for subcommand + subcommand := c.subCommandsMap[args[0]] + if subcommand != nil { + return subcommand.run(args[1:]) + } + + // Parse flags + err := c.parseFlags(args) + if err != nil { + if c.app.errorHandler != nil { + return c.app.errorHandler(c.commandPath, err) + } + return fmt.Errorf("Error: %s\nSee '%s --help' for usage", err, c.commandPath) + } + + // Help takes precedence + if c.helpFlag { + c.PrintHelp() + return nil + } + } + + // Do we have an action? + if c.actionCallback != nil { + return c.actionCallback() + } + + // If we haven't specified a subcommand + // check for an app level default command + if c.app.defaultCommand != nil { + // Prevent recursion! + if c.app.defaultCommand != c { + // only run default command if no args passed + if len(args) == 0 { + return c.app.defaultCommand.run(args) + } + } + } + + // Nothing left we can do + c.PrintHelp() + + return nil +} + +// Action - Define an action from this command +func (c *Command) Action(callback CliAction) *Command { + c.actionCallback = callback + return c +} + +// PrintHelp - Output the help text for this command +func (c *Command) PrintHelp() { + c.app.PrintBanner() + + commandTitle := c.commandPath + if c.shortdescription != "" { + commandTitle += " - " + c.shortdescription + } + // Ignore root command + if c.commandPath != c.name { + fmt.Println(commandTitle) + } + if c.longdescription != "" { + fmt.Println(c.longdescription + "\n") + } + if len(c.subCommands) > 0 { + fmt.Println("Available commands:") + fmt.Println("") + for _, subcommand := range c.subCommands { + if subcommand.isHidden() { + continue + } + spacer := strings.Repeat(" ", 3+c.longestSubcommand-len(subcommand.name)) + isDefault := "" + if subcommand.isDefaultCommand() { + isDefault = "[default]" + } + fmt.Printf(" %s%s%s %s\n", subcommand.name, spacer, subcommand.shortdescription, isDefault) + } + fmt.Println("") + } + if c.flagCount > 0 { + fmt.Println("Flags:") + fmt.Println() + c.flags.SetOutput(os.Stdout) + c.flags.PrintDefaults() + c.flags.SetOutput(os.Stderr) + + } + fmt.Println() +} + +// isDefaultCommand returns true if called on the default command +func (c *Command) isDefaultCommand() bool { + return c.app.defaultCommand == c +} + +// isHidden returns true if the command is a hidden command +func (c *Command) isHidden() bool { + return c.hidden +} + +// Hidden hides the command from the Help system +func (c *Command) Hidden() { + c.hidden = true +} + +// NewSubCommand - Creates a new subcommand +func (c *Command) NewSubCommand(name, description string) *Command { + result := NewCommand(name, description) + c.AddCommand(result) + return result +} + +// AddCommand - Adds a subcommand +func (c *Command) AddCommand(command *Command) { + command.setApp(c.app) + command.setParentCommandPath(c.commandPath) + name := command.name + c.subCommands = append(c.subCommands, command) + c.subCommandsMap[name] = command + if len(name) > c.longestSubcommand { + c.longestSubcommand = len(name) + } +} + +// NewSubCommandInheritFlags - Creates a new subcommand, inherits flags from command +func (c *Command) NewSubCommandInheritFlags(name, description string) *Command { + result := c.NewSubCommand(name, description) + result.inheritFlags(c.flags) + return result +} + +func (c *Command) AddFlags(optionStruct interface{}) *Command { + // use reflection to determine if this is a pointer to a struct + // if not, panic + + t := reflect.TypeOf(optionStruct) + + // Check for a pointer to a struct + if t.Kind() != reflect.Ptr { + panic("AddFlags() requires a pointer to a struct") + } + if t.Elem().Kind() != reflect.Struct { + panic("AddFlags() requires a pointer to a struct") + } + + // Iterate through the fields of the struct reading the struct tags + // and adding the flags + v := reflect.ValueOf(optionStruct).Elem() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Elem().Field(i) + if !fieldType.IsExported() { + continue + } + // If this is an embedded struct, recurse + if fieldType.Type.Kind() == reflect.Struct { + c.AddFlags(field.Addr().Interface()) + continue + } + + tag := t.Elem().Field(i).Tag + name := tag.Get("name") + description := tag.Get("description") + defaultValue := tag.Get("default") + pos := tag.Get("pos") + sep := tag.Get("sep") + c.positionalArgsMap[pos] = field + if sep != "" { + c.sliceSeparator[pos] = sep + } + if name == "" { + name = strings.ToLower(t.Elem().Field(i).Name) + } + switch field.Kind() { + case reflect.Bool: + var defaultValueBool bool + if defaultValue != "" { + var err error + defaultValueBool, err = strconv.ParseBool(defaultValue) + if err != nil { + panic("Invalid default value for bool flag") + } + } + field.SetBool(defaultValueBool) + c.BoolFlag(name, description, field.Addr().Interface().(*bool)) + case reflect.String: + if defaultValue != "" { + // set value of field to default value + field.SetString(defaultValue) + } + c.StringFlag(name, description, field.Addr().Interface().(*string)) + case reflect.Int: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int flag") + } + field.SetInt(int64(value)) + } + c.IntFlag(name, description, field.Addr().Interface().(*int)) + case reflect.Int8: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int8 flag") + } + field.SetInt(int64(value)) + } + c.Int8Flag(name, description, field.Addr().Interface().(*int8)) + case reflect.Int16: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int16 flag") + } + field.SetInt(int64(value)) + } + c.Int16Flag(name, description, field.Addr().Interface().(*int16)) + case reflect.Int32: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int32 flag") + } + field.SetInt(int64(value)) + } + c.Int32Flag(name, description, field.Addr().Interface().(*int32)) + case reflect.Int64: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int64 flag") + } + field.SetInt(int64(value)) + } + c.Int64Flag(name, description, field.Addr().Interface().(*int64)) + case reflect.Uint: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint flag") + } + field.SetUint(uint64(value)) + } + c.UintFlag(name, description, field.Addr().Interface().(*uint)) + case reflect.Uint8: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint8 flag") + } + field.SetUint(uint64(value)) + } + c.Uint8Flag(name, description, field.Addr().Interface().(*uint8)) + case reflect.Uint16: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint16 flag") + } + field.SetUint(uint64(value)) + } + c.Uint16Flag(name, description, field.Addr().Interface().(*uint16)) + case reflect.Uint32: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint32 flag") + } + field.SetUint(uint64(value)) + } + c.Uint32Flag(name, description, field.Addr().Interface().(*uint32)) + case reflect.Uint64: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint64 flag") + } + field.SetUint(uint64(value)) + } + c.UInt64Flag(name, description, field.Addr().Interface().(*uint64)) + case reflect.Float32: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.ParseFloat(defaultValue, 64) + if err != nil { + panic("Invalid default value for float32 flag") + } + field.SetFloat(value) + } + c.Float32Flag(name, description, field.Addr().Interface().(*float32)) + case reflect.Float64: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.ParseFloat(defaultValue, 64) + if err != nil { + panic("Invalid default value for float64 flag") + } + field.SetFloat(value) + } + c.Float64Flag(name, description, field.Addr().Interface().(*float64)) + case reflect.Slice: + c.addSliceField(field, defaultValue, sep) + c.addSliceFlags(name, description, field) + default: + if pos != "" { + println("WARNING: Unsupported type for flag: ", fieldType.Type.Kind(), name) + } + } + } + + return c +} + +func (c *Command) addSliceFlags(name, description string, field reflect.Value) *Command { + if field.Kind() != reflect.Slice { + panic("addSliceFlags() requires a pointer to a slice") + } + t := reflect.TypeOf(field.Addr().Interface()) + if t.Kind() != reflect.Ptr { + panic("addSliceFlags() requires a pointer to a slice") + } + if t.Elem().Kind() != reflect.Slice { + panic("addSliceFlags() requires a pointer to a slice") + } + switch t.Elem().Elem().Kind() { + case reflect.Bool: + c.BoolsFlag(name, description, field.Addr().Interface().(*[]bool)) + case reflect.String: + c.StringsFlag(name, description, field.Addr().Interface().(*[]string)) + case reflect.Int: + c.IntsFlag(name, description, field.Addr().Interface().(*[]int)) + case reflect.Int8: + c.Int8sFlag(name, description, field.Addr().Interface().(*[]int8)) + case reflect.Int16: + c.Int16sFlag(name, description, field.Addr().Interface().(*[]int16)) + case reflect.Int32: + c.Int32sFlag(name, description, field.Addr().Interface().(*[]int32)) + case reflect.Int64: + c.Int64sFlag(name, description, field.Addr().Interface().(*[]int64)) + case reflect.Uint: + c.UintsFlag(name, description, field.Addr().Interface().(*[]uint)) + case reflect.Uint8: + c.Uint8sFlag(name, description, field.Addr().Interface().(*[]uint8)) + case reflect.Uint16: + c.Uint16sFlag(name, description, field.Addr().Interface().(*[]uint16)) + case reflect.Uint32: + c.Uint32sFlag(name, description, field.Addr().Interface().(*[]uint32)) + case reflect.Uint64: + c.Uint64sFlag(name, description, field.Addr().Interface().(*[]uint64)) + case reflect.Float32: + c.Float32sFlag(name, description, field.Addr().Interface().(*[]float32)) + case reflect.Float64: + c.Float64sFlag(name, description, field.Addr().Interface().(*[]float64)) + default: + panic(fmt.Sprintf("addSliceFlags() not supported slice type %s", t.Elem().Elem().Kind().String())) + } + return c +} + +func (c *Command) addSliceField(field reflect.Value, defaultValue, separator string) *Command { + if defaultValue == "" { + return c + } + if field.Kind() != reflect.Slice { + panic("addSliceField() requires a pointer to a slice") + } + t := reflect.TypeOf(field.Addr().Interface()) + if t.Kind() != reflect.Ptr { + panic("addSliceField() requires a pointer to a slice") + } + if t.Elem().Kind() != reflect.Slice { + panic("addSliceField() requires a pointer to a slice") + } + defaultSlice := []string{defaultValue} + if separator != "" { + defaultSlice = strings.Split(defaultValue, separator) + } + switch t.Elem().Elem().Kind() { + case reflect.Bool: + defaultValues := make([]bool, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseBool(value) + if err != nil { + panic("Invalid default value for bool flag") + } + defaultValues = append(defaultValues, val) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.String: + field.Set(reflect.ValueOf(defaultSlice)) + case reflect.Int: + defaultValues := make([]int, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for int flag") + } + defaultValues = append(defaultValues, val) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int8: + defaultValues := make([]int8, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for int8 flag") + } + defaultValues = append(defaultValues, int8(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int16: + defaultValues := make([]int16, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for int16 flag") + } + defaultValues = append(defaultValues, int16(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int32: + defaultValues := make([]int32, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseInt(value, 10, 32) + if err != nil { + panic("Invalid default value for int32 flag") + } + defaultValues = append(defaultValues, int32(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int64: + defaultValues := make([]int64, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseInt(value, 10, 64) + if err != nil { + panic("Invalid default value for int64 flag") + } + defaultValues = append(defaultValues, val) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint: + defaultValues := make([]uint, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint flag") + } + defaultValues = append(defaultValues, uint(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint8: + defaultValues := make([]uint8, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint8 flag") + } + defaultValues = append(defaultValues, uint8(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint16: + defaultValues := make([]uint16, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint16 flag") + } + defaultValues = append(defaultValues, uint16(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint32: + defaultValues := make([]uint32, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint32 flag") + } + defaultValues = append(defaultValues, uint32(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint64: + defaultValues := make([]uint64, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint64 flag") + } + defaultValues = append(defaultValues, uint64(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Float32: + defaultValues := make([]float32, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for float32 flag") + } + defaultValues = append(defaultValues, float32(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Float64: + defaultValues := make([]float64, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for float64 flag") + } + defaultValues = append(defaultValues, float64(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + default: + panic(fmt.Sprintf("addSliceField() not supported slice type %s", t.Elem().Elem().Kind().String())) + } + return c +} + +// BoolFlag - Adds a boolean flag to the command +func (c *Command) BoolFlag(name, description string, variable *bool) *Command { + c.flags.BoolVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// BoolsFlag - Adds a booleans flag to the command +func (c *Command) BoolsFlag(name, description string, variable *[]bool) *Command { + c.flags.Var(newBoolsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// StringFlag - Adds a string flag to the command +func (c *Command) StringFlag(name, description string, variable *string) *Command { + c.flags.StringVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// StringsFlag - Adds a strings flag to the command +func (c *Command) StringsFlag(name, description string, variable *[]string) *Command { + c.flags.Var(newStringsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// IntFlag - Adds an int flag to the command +func (c *Command) IntFlag(name, description string, variable *int) *Command { + c.flags.IntVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// IntsFlag - Adds an ints flag to the command +func (c *Command) IntsFlag(name, description string, variable *[]int) *Command { + c.flags.Var(newIntsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int8Flag - Adds an int8 flag to the command +func (c *Command) Int8Flag(name, description string, variable *int8) *Command { + c.flags.Var(newInt8Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int8sFlag - Adds an int8 s flag to the command +func (c *Command) Int8sFlag(name, description string, variable *[]int8) *Command { + c.flags.Var(newInt8sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int16Flag - Adds an int16 flag to the command +func (c *Command) Int16Flag(name, description string, variable *int16) *Command { + c.flags.Var(newInt16Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int16sFlag - Adds an int16s flag to the command +func (c *Command) Int16sFlag(name, description string, variable *[]int16) *Command { + c.flags.Var(newInt16sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int32Flag - Adds an int32 flag to the command +func (c *Command) Int32Flag(name, description string, variable *int32) *Command { + c.flags.Var(newInt32Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int32sFlag - Adds an int32s flag to the command +func (c *Command) Int32sFlag(name, description string, variable *[]int32) *Command { + c.flags.Var(newInt32sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int64Flag - Adds an int64 flag to the command +func (c *Command) Int64Flag(name, description string, variable *int64) *Command { + c.flags.Int64Var(variable, name, *variable, description) + c.flagCount++ + return c +} + +// Int64sFlag - Adds an int64s flag to the command +func (c *Command) Int64sFlag(name, description string, variable *[]int64) *Command { + c.flags.Var(newInt64sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// UintFlag - Adds an uint flag to the command +func (c *Command) UintFlag(name, description string, variable *uint) *Command { + c.flags.UintVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// UintsFlag - Adds an uints flag to the command +func (c *Command) UintsFlag(name, description string, variable *[]uint) *Command { + c.flags.Var(newUintsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint8Flag - Adds an uint8 flag to the command +func (c *Command) Uint8Flag(name, description string, variable *uint8) *Command { + c.flags.Var(newUint8Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint8sFlag - Adds an uint8 s flag to the command +func (c *Command) Uint8sFlag(name, description string, variable *[]uint8) *Command { + c.flags.Var(newUint8sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint16Flag - Adds an uint16 flag to the command +func (c *Command) Uint16Flag(name, description string, variable *uint16) *Command { + c.flags.Var(newUint16Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint16sFlag - Adds an uint16s flag to the command +func (c *Command) Uint16sFlag(name, description string, variable *[]uint16) *Command { + c.flags.Var(newUint16sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint32Flag - Adds an uint32 flag to the command +func (c *Command) Uint32Flag(name, description string, variable *uint32) *Command { + c.flags.Var(newUint32Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint32sFlag - Adds an uint32s flag to the command +func (c *Command) Uint32sFlag(name, description string, variable *[]uint32) *Command { + c.flags.Var(newUint32sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// UInt64Flag - Adds an uint64 flag to the command +func (c *Command) UInt64Flag(name, description string, variable *uint64) *Command { + c.flags.Uint64Var(variable, name, *variable, description) + c.flagCount++ + return c +} + +// Uint64sFlag - Adds an uint64s flag to the command +func (c *Command) Uint64sFlag(name, description string, variable *[]uint64) *Command { + c.flags.Var(newUint64sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Float64Flag - Adds a float64 flag to the command +func (c *Command) Float64Flag(name, description string, variable *float64) *Command { + c.flags.Float64Var(variable, name, *variable, description) + c.flagCount++ + return c +} + +// Float32Flag - Adds a float32 flag to the command +func (c *Command) Float32Flag(name, description string, variable *float32) *Command { + c.flags.Var(newFloat32Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Float32sFlag - Adds a float32s flag to the command +func (c *Command) Float32sFlag(name, description string, variable *[]float32) *Command { + c.flags.Var(newFloat32sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Float64sFlag - Adds a float64s flag to the command +func (c *Command) Float64sFlag(name, description string, variable *[]float64) *Command { + c.flags.Var(newFloat64sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +type boolsFlagVar []bool + +func (f *boolsFlagVar) String() string { return fmt.Sprint([]bool(*f)) } + +func (f *boolsFlagVar) Set(value string) error { + if value == "" { + *f = append(*f, false) + return nil + } + b, err := strconv.ParseBool(value) + if err != nil { + return err + } + *f = append(*f, b) + return nil +} + +func (f *boolsFlagVar) IsBoolFlag() bool { + return true +} + +func newBoolsValue(val []bool, p *[]bool) *boolsFlagVar { + *p = val + return (*boolsFlagVar)(p) +} + +type stringsFlagVar []string + +func (f *stringsFlagVar) String() string { return fmt.Sprint([]string(*f)) } + +func (f *stringsFlagVar) Set(value string) error { + *f = append(*f, value) + return nil +} + +func newStringsValue(val []string, p *[]string) *stringsFlagVar { + *p = val + return (*stringsFlagVar)(p) +} + +type intsFlagVar []int + +func (f *intsFlagVar) String() string { return fmt.Sprint([]int(*f)) } + +func (f *intsFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newIntsValue(val []int, p *[]int) *intsFlagVar { + *p = val + return (*intsFlagVar)(p) +} + +type int8Value int8 + +func newInt8Value(val int8, p *int8) *int8Value { + *p = val + return (*int8Value)(p) +} + +func (f *int8Value) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = int8Value(i) + return nil +} + +func (f *int8Value) String() string { return fmt.Sprint(int8(*f)) } + +type int8sFlagVar []int8 + +func (f *int8sFlagVar) String() string { return fmt.Sprint([]int8(*f)) } + +func (f *int8sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, int8(i)) + return nil +} + +func newInt8sValue(val []int8, p *[]int8) *int8sFlagVar { + *p = val + return (*int8sFlagVar)(p) +} + +type int16Value int16 + +func newInt16Value(val int16, p *int16) *int16Value { + *p = val + return (*int16Value)(p) +} + +func (f *int16Value) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = int16Value(i) + return nil +} + +func (f *int16Value) String() string { return fmt.Sprint(int16(*f)) } + +type int16sFlagVar []int16 + +func (f *int16sFlagVar) String() string { return fmt.Sprint([]int16(*f)) } + +func (f *int16sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, int16(i)) + return nil +} + +func newInt16sValue(val []int16, p *[]int16) *int16sFlagVar { + *p = val + return (*int16sFlagVar)(p) +} + +type int32Value int32 + +func newInt32Value(val int32, p *int32) *int32Value { + *p = val + return (*int32Value)(p) +} + +func (f *int32Value) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = int32Value(i) + return nil +} + +func (f *int32Value) String() string { return fmt.Sprint(int32(*f)) } + +type int32sFlagVar []int32 + +func (f *int32sFlagVar) String() string { return fmt.Sprint([]int32(*f)) } + +func (f *int32sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, int32(i)) + return nil +} + +func newInt32sValue(val []int32, p *[]int32) *int32sFlagVar { + *p = val + return (*int32sFlagVar)(p) +} + +type int64sFlagVar []int64 + +func (f *int64sFlagVar) String() string { return fmt.Sprint([]int64(*f)) } + +func (f *int64sFlagVar) Set(value string) error { + i, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newInt64sValue(val []int64, p *[]int64) *int64sFlagVar { + *p = val + return (*int64sFlagVar)(p) +} + +type uintsFlagVar []uint + +func (f *uintsFlagVar) String() string { + return fmt.Sprint([]uint(*f)) +} + +func (f *uintsFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint(i)) + return nil +} + +func newUintsValue(val []uint, p *[]uint) *uintsFlagVar { + *p = val + return (*uintsFlagVar)(p) +} + +type uint8FlagVar uint8 + +func newUint8Value(val uint8, p *uint8) *uint8FlagVar { + *p = val + return (*uint8FlagVar)(p) +} + +func (f *uint8FlagVar) String() string { + return fmt.Sprint(uint8(*f)) +} + +func (f *uint8FlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = uint8FlagVar(i) + return nil +} + +type uint8sFlagVar []uint8 + +func (f *uint8sFlagVar) String() string { + return fmt.Sprint([]uint8(*f)) +} + +func (f *uint8sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint8(i)) + return nil +} + +func newUint8sValue(val []uint8, p *[]uint8) *uint8sFlagVar { + *p = val + return (*uint8sFlagVar)(p) +} + +type uint16FlagVar uint16 + +func newUint16Value(val uint16, p *uint16) *uint16FlagVar { + *p = val + return (*uint16FlagVar)(p) +} + +func (f *uint16FlagVar) String() string { + return fmt.Sprint(uint16(*f)) +} + +func (f *uint16FlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = uint16FlagVar(i) + return nil +} + +type uint16sFlagVar []uint16 + +func (f *uint16sFlagVar) String() string { + return fmt.Sprint([]uint16(*f)) +} + +func (f *uint16sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint16(i)) + return nil +} + +func newUint16sValue(val []uint16, p *[]uint16) *uint16sFlagVar { + *p = val + return (*uint16sFlagVar)(p) +} + +type uint32FlagVar uint32 + +func newUint32Value(val uint32, p *uint32) *uint32FlagVar { + *p = val + return (*uint32FlagVar)(p) +} + +func (f *uint32FlagVar) String() string { + return fmt.Sprint(uint32(*f)) +} + +func (f *uint32FlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = uint32FlagVar(i) + return nil +} + +type uint32sFlagVar []uint32 + +func (f *uint32sFlagVar) String() string { + return fmt.Sprint([]uint32(*f)) +} + +func (f *uint32sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint32(i)) + return nil +} + +func newUint32sValue(val []uint32, p *[]uint32) *uint32sFlagVar { + *p = val + return (*uint32sFlagVar)(p) +} + +type uint64sFlagVar []uint64 + +func (f *uint64sFlagVar) String() string { return fmt.Sprint([]uint64(*f)) } + +func (f *uint64sFlagVar) Set(value string) error { + i, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newUint64sValue(val []uint64, p *[]uint64) *uint64sFlagVar { + *p = val + return (*uint64sFlagVar)(p) +} + +type float32sFlagVar []float32 + +func (f *float32sFlagVar) String() string { return fmt.Sprint([]float32(*f)) } + +func (f *float32sFlagVar) Set(value string) error { + i, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + *f = append(*f, float32(i)) + return nil +} + +func newFloat32sValue(val []float32, p *[]float32) *float32sFlagVar { + *p = val + return (*float32sFlagVar)(p) +} + +type float32FlagVar float32 + +func (f *float32FlagVar) String() string { return fmt.Sprint(float32(*f)) } + +func (f *float32FlagVar) Set(value string) error { + i, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + *f = float32FlagVar(i) + return nil +} + +func newFloat32Value(val float32, p *float32) *float32FlagVar { + *p = val + return (*float32FlagVar)(p) +} + +type float64sFlagVar []float64 + +func (f *float64sFlagVar) String() string { return fmt.Sprint([]float64(*f)) } + +func (f *float64sFlagVar) Set(value string) error { + i, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newFloat64sValue(val []float64, p *[]float64) *float64sFlagVar { + *p = val + return (*float64sFlagVar)(p) +} + +// LongDescription - Sets the long description for the command +func (c *Command) LongDescription(longdescription string) *Command { + c.longdescription = longdescription + return c +} + +// OtherArgs - Returns the non-flag arguments passed to the subcommand. NOTE: This should only be called within the context of an action. +func (c *Command) OtherArgs() []string { + return c.flags.Args() +} + +func (c *Command) NewSubCommandFunction(name string, description string, fn interface{}) *Command { + result := c.NewSubCommand(name, description) + // use reflection to determine if this is a function + // if not, panic + t := reflect.TypeOf(fn) + if t.Kind() != reflect.Func { + panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + + // Check the function has 1 input ant it's a struct pointer + fnValue := reflect.ValueOf(fn) + if t.NumIn() != 1 { + panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + // Check the input is a struct pointer + if t.In(0).Kind() != reflect.Ptr { + panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + if t.In(0).Elem().Kind() != reflect.Struct { + panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + // Check only 1 output and it's an error + if t.NumOut() != 1 { + panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + if t.Out(0) != reflect.TypeOf((*error)(nil)).Elem() { + panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + flags := reflect.New(t.In(0).Elem()) + result.Action(func() error { + result := fnValue.Call([]reflect.Value{flags})[0].Interface() + if result != nil { + return result.(error) + } + return nil + }) + result.AddFlags(flags.Interface()) + return result +} + +func (c *Command) parsePositionalArgs(args []string) error { + for index, posArg := range args { + // Check the map for a field for this arg + key := strconv.Itoa(index + 1) + field, ok := c.positionalArgsMap[key] + if !ok { + continue + } + fieldType := field.Type() + switch fieldType.Kind() { + case reflect.Bool: + // set value of field to true + field.SetBool(true) + case reflect.String: + field.SetString(posArg) + case reflect.Int64, reflect.Int32, reflect.Int16, reflect.Int8, reflect.Int: + value, err := strconv.ParseInt(posArg, 10, 64) + if err != nil { + return err + } + field.SetInt(value) + case reflect.Uint64, reflect.Uint32, reflect.Uint16, reflect.Uint8, reflect.Uint: + value, err := strconv.ParseUint(posArg, 10, 64) + if err != nil { + return err + } + field.SetUint(value) + case reflect.Float64, reflect.Float32: + value, err := strconv.ParseFloat(posArg, 64) + if err != nil { + return err + } + field.SetFloat(value) + case reflect.Slice: + c.addSliceField(field, posArg, c.sliceSeparator[key]) + default: + return errors.New("Unsupported type for positional argument: " + fieldType.Name()) + } + } + return nil +} diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go index 8406b73..033038a 100644 --- a/pkg/core/interfaces.go +++ b/pkg/core/interfaces.go @@ -85,6 +85,7 @@ type Core struct { io *IO // Local filesystem I/O (read/write, sandboxable) etc *Etc // Configuration, settings, and feature flags crash *CrashHandler // Panic recovery and crash reporting + cli *CliApp // CLI command registration and execution svc *serviceManager bus *messageBus locales []fs.FS // collected from LocaleProvider services @@ -127,6 +128,14 @@ func (c *Core) Crash() *CrashHandler { return c.crash } +// Cli returns the CLI command framework. +// Register commands without importing any CLI package. +// +// c.Cli().NewSubCommand("health", "Check service health").Action(func() error { ... }) +func (c *Core) Cli() *CliApp { + return c.cli +} + // Locales returns all locale filesystems collected from registered services. // The i18n service uses this during startup to load translations. func (c *Core) Locales() []fs.FS { From bcaf1554f8df4008793046da312c8bb03dc7d15b Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 02:40:00 +0000 Subject: [PATCH 23/31] fix: resolve 3 critical review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: mnt_extract.go rename bug — source path was mutated before reading from fs.FS. Now uses separate sourcePath/targetPath. C2: cli_command.go os.Stderr = nil — replaced with flags.SetOutput(io.Discard). No more global nil stderr. C3: Cli() returned nil — now initialised in New() with NewCliApp("", "", ""). Found by opus code-reviewer agent (final review pass). Co-Authored-By: Virgil --- pkg/core/cli_command.go | 9 ++++----- pkg/core/core.go | 1 + pkg/core/mnt_extract.go | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/core/cli_command.go b/pkg/core/cli_command.go index b50b0e0..eeee533 100644 --- a/pkg/core/cli_command.go +++ b/pkg/core/cli_command.go @@ -3,6 +3,7 @@ package core import ( "errors" "flag" + "io" "fmt" "os" "reflect" @@ -75,11 +76,9 @@ func (c *Command) setApp(app *CliApp) { // parseFlags parses the given flags func (c *Command) parseFlags(args []string) error { // Parse flags - tmp := os.Stderr - os.Stderr = nil - defer func() { - os.Stderr = tmp - }() + // Suppress flag parse errors to stderr + + c.flags.SetOutput(io.Discard) // Credit: https://stackoverflow.com/a/74146375 var positionalArgs []string diff --git a/pkg/core/core.go b/pkg/core/core.go index 3c5c181..ae2abca 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -33,6 +33,7 @@ func New(opts ...Option) (*Core, error) { io: defaultIO, etc: NewEtc(), crash: NewCrashHandler(), + cli: NewCliApp("", "", ""), svc: newServiceManager(), } c.bus = newMessageBus(c) diff --git a/pkg/core/mnt_extract.go b/pkg/core/mnt_extract.go index 21188e3..e882bb4 100644 --- a/pkg/core/mnt_extract.go +++ b/pkg/core/mnt_extract.go @@ -135,11 +135,12 @@ func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) err // Copy standard files for _, path := range standardFiles { + targetPath := path name := filepath.Base(path) if renamed := opt.RenameFiles[name]; renamed != "" { - path = filepath.Join(filepath.Dir(path), renamed) + targetPath = filepath.Join(filepath.Dir(path), renamed) } - target := renderPath(filepath.Join(targetDir, path), data) + target := renderPath(filepath.Join(targetDir, targetPath), data) if err := copyFile(fsys, path, target); err != nil { return err } From 8199727537aa22677ea8673c87c23132e53653fc Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 09:12:29 +0000 Subject: [PATCH 24/31] feat: restructure Core as unified struct with DTO pattern Complete architectural overhaul of pkg/core: - All subsystem types renamed to idiomatic Go (no stutter) - Core struct: App, Embed, Fs, Config, ErrPan, ErrLog, Cli, Service, Lock, Ipc, I18n - Exports consolidated in core.go, contracts/options in contract.go - Service() unified get/register: c.Service(), c.Service("name"), c.Service("name", svc) - Lock() named mutex map: c.Lock("srv"), c.Lock("ipc") - Error system: Err/ErrLog/ErrPan + Log/LogErr/LogPan (shared ErrSink interface) - CoreCommand with optional description (i18n resolves from command path) - Tests moved to tests/ directory (black-box package core_test) - Removed: ServiceFor/MustServiceFor, global instance, Display/Workspace/Crypt interfaces - New files: app.go, fs.go, ipc.go, lock.go, i18n.go, task.go, runtime.go, contract.go Co-Authored-By: Virgil --- pkg/core/app.go | 72 +++ pkg/core/{slicer.go => array.go} | 34 +- pkg/core/cli.go | 209 +++++++- pkg/core/cli_action.go | 5 - pkg/core/cli_app.go | 159 ------ pkg/core/{cli_command.go => command.go} | 49 +- pkg/core/{etc.go => config.go} | 49 +- pkg/core/contract.go | 223 +++++++++ pkg/core/core.go | 468 ++---------------- pkg/core/crash.go | 144 ------ pkg/core/embed.go | 326 +++++++++++- pkg/core/error.go | 209 ++++++-- pkg/core/{io.go => fs.go} | 72 +-- pkg/core/i18n.go | 129 +++++ pkg/core/interfaces.go | 214 -------- pkg/core/{message_bus.go => ipc.go} | 49 +- pkg/core/lock.go | 74 +++ pkg/core/log.go | 128 +++-- pkg/core/message_bus_test.go | 176 ------- pkg/core/mnt.go | 108 ---- pkg/core/mnt_extract.go | 195 -------- pkg/core/runtime.go | 132 +++++ pkg/core/runtime_pkg.go | 113 ----- pkg/core/service.go | 74 +++ pkg/core/service_manager.go | 96 ---- pkg/core/service_manager_test.go | 132 ----- pkg/core/task.go | 40 ++ {pkg/core => tests}/async_test.go | 54 +- {pkg/core => tests}/bench_test.go | 4 +- {pkg/core => tests}/core_extra_test.go | 4 +- {pkg/core => tests}/core_lifecycle_test.go | 4 +- {pkg/core => tests}/core_test.go | 76 ++- {pkg/core => tests}/e_test.go | 4 +- {pkg/core => tests}/fuzz_test.go | 22 +- {pkg/core => tests}/ipc_test.go | 4 +- tests/message_bus_test.go | 176 +++++++ {pkg/core => tests}/query_test.go | 4 +- {pkg/core => tests}/runtime_pkg_extra_test.go | 4 +- {pkg/core => tests}/runtime_pkg_test.go | 5 +- tests/service_manager_test.go | 116 +++++ {pkg/core => tests}/testdata/test.txt | 0 41 files changed, 2055 insertions(+), 2101 deletions(-) create mode 100644 pkg/core/app.go rename pkg/core/{slicer.go => array.go} (65%) delete mode 100644 pkg/core/cli_action.go delete mode 100644 pkg/core/cli_app.go rename pkg/core/{cli_command.go => command.go} (96%) rename pkg/core/{etc.go => config.go} (62%) create mode 100644 pkg/core/contract.go delete mode 100644 pkg/core/crash.go rename pkg/core/{io.go => fs.go} (75%) create mode 100644 pkg/core/i18n.go delete mode 100644 pkg/core/interfaces.go rename pkg/core/{message_bus.go => ipc.go} (56%) create mode 100644 pkg/core/lock.go delete mode 100644 pkg/core/message_bus_test.go delete mode 100644 pkg/core/mnt.go delete mode 100644 pkg/core/mnt_extract.go create mode 100644 pkg/core/runtime.go delete mode 100644 pkg/core/runtime_pkg.go create mode 100644 pkg/core/service.go delete mode 100644 pkg/core/service_manager.go delete mode 100644 pkg/core/service_manager_test.go create mode 100644 pkg/core/task.go rename {pkg/core => tests}/async_test.go (90%) rename {pkg/core => tests}/bench_test.go (92%) rename {pkg/core => tests}/core_extra_test.go (94%) rename {pkg/core => tests}/core_lifecycle_test.go (98%) rename {pkg/core => tests}/core_test.go (82%) rename {pkg/core => tests}/e_test.go (92%) rename {pkg/core => tests}/fuzz_test.go (83%) rename {pkg/core => tests}/ipc_test.go (97%) create mode 100644 tests/message_bus_test.go rename {pkg/core => tests}/query_test.go (98%) rename {pkg/core => tests}/runtime_pkg_extra_test.go (86%) rename {pkg/core => tests}/runtime_pkg_test.go (95%) create mode 100644 tests/service_manager_test.go rename {pkg/core => tests}/testdata/test.txt (100%) diff --git a/pkg/core/app.go b/pkg/core/app.go new file mode 100644 index 0000000..05f3236 --- /dev/null +++ b/pkg/core/app.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Application identity for the Core framework. +// Based on leaanthony/sail — Name, Filename, Path. + +package core + +import ( + "os" + "os/exec" + "path/filepath" +) + +// App holds the application identity and optional GUI runtime. +type App struct { + // Name is the human-readable application name (e.g., "Core CLI"). + Name string + + // Version is the application version string (e.g., "1.2.3"). + Version string + + // Description is a short description of the application. + Description string + + // Filename is the executable filename (e.g., "core"). + Filename string + + // Path is the absolute path to the executable. + Path string + + // Runtime is the GUI runtime (e.g., Wails App). + // Nil for CLI-only applications. + Runtime any +} + +// NewApp creates a App with the given identity. +// Filename and Path are auto-detected from the running binary. +func NewApp(name, description, version string) *App { + app := &App{ + Name: name, + Version: version, + Description: description, + } + + // Auto-detect executable identity + if exe, err := os.Executable(); err == nil { + if abs, err := filepath.Abs(exe); err == nil { + app.Path = abs + app.Filename = filepath.Base(abs) + } + } + + return app +} + +// Find locates a program on PATH and returns a App for it. +// Returns nil if not found. +func Find(filename, name string) *App { + path, err := exec.LookPath(filename) + if err != nil { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return nil + } + return &App{ + Name: name, + Filename: filename, + Path: abs, + } +} diff --git a/pkg/core/slicer.go b/pkg/core/array.go similarity index 65% rename from pkg/core/slicer.go rename to pkg/core/array.go index a690ea4..887bee9 100644 --- a/pkg/core/slicer.go +++ b/pkg/core/array.go @@ -5,23 +5,23 @@ package core -// Slicer is a typed slice with common operations. -type Slicer[T comparable] struct { +// Array is a typed slice with common operations. +type Array[T comparable] struct { items []T } -// NewSlicer creates an empty Slicer. -func NewSlicer[T comparable](items ...T) *Slicer[T] { - return &Slicer[T]{items: items} +// NewArray creates an empty Array. +func NewArray[T comparable](items ...T) *Array[T] { + return &Array[T]{items: items} } // Add appends values. -func (s *Slicer[T]) Add(values ...T) { +func (s *Array[T]) Add(values ...T) { s.items = append(s.items, values...) } // AddUnique appends values only if not already present. -func (s *Slicer[T]) AddUnique(values ...T) { +func (s *Array[T]) AddUnique(values ...T) { for _, v := range values { if !s.Contains(v) { s.items = append(s.items, v) @@ -30,7 +30,7 @@ func (s *Slicer[T]) AddUnique(values ...T) { } // Contains returns true if the value is in the slice. -func (s *Slicer[T]) Contains(val T) bool { +func (s *Array[T]) Contains(val T) bool { for _, v := range s.items { if v == val { return true @@ -39,9 +39,9 @@ func (s *Slicer[T]) Contains(val T) bool { return false } -// Filter returns a new Slicer with elements matching the predicate. -func (s *Slicer[T]) Filter(fn func(T) bool) *Slicer[T] { - result := &Slicer[T]{} +// Filter returns a new Array with elements matching the predicate. +func (s *Array[T]) Filter(fn func(T) bool) *Array[T] { + result := &Array[T]{} for _, v := range s.items { if fn(v) { result.items = append(result.items, v) @@ -51,14 +51,14 @@ func (s *Slicer[T]) Filter(fn func(T) bool) *Slicer[T] { } // Each runs a function on every element. -func (s *Slicer[T]) Each(fn func(T)) { +func (s *Array[T]) Each(fn func(T)) { for _, v := range s.items { fn(v) } } // Remove removes the first occurrence of a value. -func (s *Slicer[T]) Remove(val T) { +func (s *Array[T]) Remove(val T) { for i, v := range s.items { if v == val { s.items = append(s.items[:i], s.items[i+1:]...) @@ -68,7 +68,7 @@ func (s *Slicer[T]) Remove(val T) { } // Deduplicate removes duplicate values, preserving order. -func (s *Slicer[T]) Deduplicate() { +func (s *Array[T]) Deduplicate() { seen := make(map[T]struct{}) result := make([]T, 0, len(s.items)) for _, v := range s.items { @@ -81,16 +81,16 @@ func (s *Slicer[T]) Deduplicate() { } // Len returns the number of elements. -func (s *Slicer[T]) Len() int { +func (s *Array[T]) Len() int { return len(s.items) } // Clear removes all elements. -func (s *Slicer[T]) Clear() { +func (s *Array[T]) Clear() { s.items = nil } // AsSlice returns the underlying slice. -func (s *Slicer[T]) AsSlice() []T { +func (s *Array[T]) AsSlice() []T { return s.items } diff --git a/pkg/core/cli.go b/pkg/core/cli.go index bb76ccd..b117254 100644 --- a/pkg/core/cli.go +++ b/pkg/core/cli.go @@ -1,24 +1,58 @@ -// Package clir provides a simple API for creating command line apps +// SPDX-License-Identifier: EUPL-1.2 + +// CLI command framework for the Core framework. +// Based on leaanthony/clir — zero-dependency command line interface. + package core import ( "fmt" + "os" ) -// defaultBannerFunction prints a banner for the application. -// If version is a blank string, it is ignored. -func defaultBannerFunction(c *CliApp) string { - version := "" - if len(c.Version()) > 0 { - version = " " + c.Version() - } - return fmt.Sprintf("%s%s - %s", c.Name(), version, c.ShortDescription()) +// CliAction represents a function called when a command is invoked. +type CliAction func() error + +// Cli is the CLI command framework. +type Cli struct { + app *App + rootCommand *Command + defaultCommand *Command + preRunCommand func(*Cli) error + postRunCommand func(*Cli) error + bannerFunction func(*Cli) string + errorHandler func(string, error) error } -// NewCli - Creates a new Cli application object -func NewCliApp(name, description, version string) *CliApp { - result := &CliApp{ - version: version, +// defaultBannerFunction prints a banner for the application. +func defaultBannerFunction(c *Cli) string { + version := "" + if c.app != nil && c.app.Version != "" { + version = " " + c.app.Version + } + name := "" + description := "" + if c.app != nil { + name = c.app.Name + description = c.app.Description + } + if description != "" { + return fmt.Sprintf("%s%s - %s", name, version, description) + } + return fmt.Sprintf("%s%s", name, version) +} + +// NewCoreCli creates a new CLI bound to the given App identity. +func NewCoreCli(app *App) *Cli { + name := "" + description := "" + if app != nil { + name = app.Name + description = app.Description + } + + result := &Cli{ + app: app, bannerFunction: defaultBannerFunction, } result.rootCommand = NewCommand(name, description) @@ -26,3 +60,152 @@ func NewCliApp(name, description, version string) *CliApp { result.rootCommand.setParentCommandPath("") return result } + +// Command returns the root command. +func (c *Cli) Command() *Command { + return c.rootCommand +} + +// Version returns the application version string. +func (c *Cli) Version() string { + if c.app != nil { + return c.app.Version + } + return "" +} + +// Name returns the application name. +func (c *Cli) Name() string { + if c.app != nil { + return c.app.Name + } + return c.rootCommand.name +} + +// ShortDescription returns the application short description. +func (c *Cli) ShortDescription() string { + if c.app != nil { + return c.app.Description + } + return c.rootCommand.shortdescription +} + +// SetBannerFunction sets the function that generates the banner string. +func (c *Cli) SetBannerFunction(fn func(*Cli) string) { + c.bannerFunction = fn +} + +// SetErrorFunction sets a custom error handler for undefined flags. +func (c *Cli) SetErrorFunction(fn func(string, error) error) { + c.errorHandler = fn +} + +// AddCommand adds a command to the application. +func (c *Cli) AddCommand(command *Command) { + c.rootCommand.AddCommand(command) +} + +// PrintBanner prints the application banner. +func (c *Cli) PrintBanner() { + fmt.Println(c.bannerFunction(c)) + fmt.Println("") +} + +// PrintHelp prints the application help. +func (c *Cli) PrintHelp() { + c.rootCommand.PrintHelp() +} + +// Run runs the application with the given arguments. +func (c *Cli) Run(args ...string) error { + if c.preRunCommand != nil { + if err := c.preRunCommand(c); err != nil { + return err + } + } + if len(args) == 0 { + args = os.Args[1:] + } + if err := c.rootCommand.run(args); err != nil { + return err + } + if c.postRunCommand != nil { + if err := c.postRunCommand(c); err != nil { + return err + } + } + return nil +} + +// DefaultCommand sets the command to run when no other commands are given. +func (c *Cli) DefaultCommand(defaultCommand *Command) *Cli { + c.defaultCommand = defaultCommand + return c +} + +// NewChildCommand creates a new subcommand. +func (c *Cli) NewChildCommand(name string, description ...string) *Command { + return c.rootCommand.NewChildCommand(name, description...) +} + +// NewChildCommandInheritFlags creates a new subcommand that inherits parent flags. +func (c *Cli) NewChildCommandInheritFlags(name string, description ...string) *Command { + return c.rootCommand.NewChildCommandInheritFlags(name, description...) +} + +// PreRun sets a function to call before running the command. +func (c *Cli) PreRun(callback func(*Cli) error) { + c.preRunCommand = callback +} + +// PostRun sets a function to call after running the command. +func (c *Cli) PostRun(callback func(*Cli) error) { + c.postRunCommand = callback +} + +// BoolFlag adds a boolean flag to the root command. +func (c *Cli) BoolFlag(name, description string, variable *bool) *Cli { + c.rootCommand.BoolFlag(name, description, variable) + return c +} + +// StringFlag adds a string flag to the root command. +func (c *Cli) StringFlag(name, description string, variable *string) *Cli { + c.rootCommand.StringFlag(name, description, variable) + return c +} + +// IntFlag adds an int flag to the root command. +func (c *Cli) IntFlag(name, description string, variable *int) *Cli { + c.rootCommand.IntFlag(name, description, variable) + return c +} + +// AddFlags adds struct-tagged flags to the root command. +func (c *Cli) AddFlags(flags any) *Cli { + c.rootCommand.AddFlags(flags) + return c +} + +// Action defines an action for the root command. +func (c *Cli) Action(callback CliAction) *Cli { + c.rootCommand.Action(callback) + return c +} + +// LongDescription sets the long description for the root command. +func (c *Cli) LongDescription(longdescription string) *Cli { + c.rootCommand.LongDescription(longdescription) + return c +} + +// OtherArgs returns the non-flag arguments passed to the CLI. +func (c *Cli) OtherArgs() []string { + return c.rootCommand.flags.Args() +} + +// NewChildCommandFunction creates a subcommand from a function with struct flags. +func (c *Cli) NewChildCommandFunction(name string, description string, fn any) *Cli { + c.rootCommand.NewChildCommandFunction(name, description, fn) + return c +} diff --git a/pkg/core/cli_action.go b/pkg/core/cli_action.go deleted file mode 100644 index 2e6dbd9..0000000 --- a/pkg/core/cli_action.go +++ /dev/null @@ -1,5 +0,0 @@ -package core - -// Action represents a function that gets calls when the command is called by -// the user -type CliAction func() error diff --git a/pkg/core/cli_app.go b/pkg/core/cli_app.go deleted file mode 100644 index 063af3e..0000000 --- a/pkg/core/cli_app.go +++ /dev/null @@ -1,159 +0,0 @@ -package core - -import ( - "fmt" - "os" -) - -// Cli - The main application object. -type CliApp struct { - version string - rootCommand *Command - defaultCommand *Command - preRunCommand func(*CliApp) error - postRunCommand func(*CliApp) error - bannerFunction func(*CliApp) string - errorHandler func(string, error) error -} - -// Version - Get the Application version string. -func (c *CliApp) Version() string { - return c.version -} - -// Name - Get the Application Name -func (c *CliApp) Name() string { - return c.rootCommand.name -} - -// ShortDescription - Get the Application short description. -func (c *CliApp) ShortDescription() string { - return c.rootCommand.shortdescription -} - -// SetBannerFunction - Set the function that is called -// to get the banner string. -func (c *CliApp) SetBannerFunction(fn func(*CliApp) string) { - c.bannerFunction = fn -} - -// SetErrorFunction - Set custom error message when undefined -// flags are used by the user. First argument is a string containing -// the command path used. Second argument is the undefined flag error. -func (c *CliApp) SetErrorFunction(fn func(string, error) error) { - c.errorHandler = fn -} - -// AddCommand - Adds a command to the application. -func (c *CliApp) AddCommand(command *Command) { - c.rootCommand.AddCommand(command) -} - -// PrintBanner - Prints the application banner! -func (c *CliApp) PrintBanner() { - fmt.Println(c.bannerFunction(c)) - fmt.Println("") -} - -// PrintHelp - Prints the application's help. -func (c *CliApp) PrintHelp() { - c.rootCommand.PrintHelp() -} - -// Run - Runs the application with the given arguments. -func (c *CliApp) Run(args ...string) error { - if c.preRunCommand != nil { - err := c.preRunCommand(c) - if err != nil { - return err - } - } - if len(args) == 0 { - args = os.Args[1:] - } - if err := c.rootCommand.run(args); err != nil { - return err - } - - if c.postRunCommand != nil { - err := c.postRunCommand(c) - if err != nil { - return err - } - } - - return nil -} - -// DefaultCommand - Sets the given command as the command to run when -// no other commands given. -func (c *CliApp) DefaultCommand(defaultCommand *Command) *CliApp { - c.defaultCommand = defaultCommand - return c -} - -// NewSubCommand - Creates a new SubCommand for the application. -func (c *CliApp) NewSubCommand(name, description string) *Command { - return c.rootCommand.NewSubCommand(name, description) -} - -// NewSubCommandInheritFlags - Creates a new SubCommand for the application, inherit flags from parent Command -func (c *CliApp) NewSubCommandInheritFlags(name, description string) *Command { - return c.rootCommand.NewSubCommandInheritFlags(name, description) -} - -// PreRun - Calls the given function before running the specific command. -func (c *CliApp) PreRun(callback func(*CliApp) error) { - c.preRunCommand = callback -} - -// PostRun - Calls the given function after running the specific command. -func (c *CliApp) PostRun(callback func(*CliApp) error) { - c.postRunCommand = callback -} - -// BoolFlag - Adds a boolean flag to the root command. -func (c *CliApp) BoolFlag(name, description string, variable *bool) *CliApp { - c.rootCommand.BoolFlag(name, description, variable) - return c -} - -// StringFlag - Adds a string flag to the root command. -func (c *CliApp) StringFlag(name, description string, variable *string) *CliApp { - c.rootCommand.StringFlag(name, description, variable) - return c -} - -// IntFlag - Adds an int flag to the root command. -func (c *CliApp) IntFlag(name, description string, variable *int) *CliApp { - c.rootCommand.IntFlag(name, description, variable) - return c -} - -func (c *CliApp) AddFlags(flags interface{}) *CliApp { - c.rootCommand.AddFlags(flags) - return c -} - -// Action - Define an action from this command. -func (c *CliApp) Action(callback CliAction) *CliApp { - c.rootCommand.Action(callback) - return c -} - -// LongDescription - Sets the long description for the command. -func (c *CliApp) LongDescription(longdescription string) *CliApp { - c.rootCommand.LongDescription(longdescription) - return c -} - -// OtherArgs - Returns the non-flag arguments passed to the cli. -// NOTE: This should only be called within the context of an action. -func (c *CliApp) OtherArgs() []string { - return c.rootCommand.flags.Args() -} - -func (c *CliApp) NewSubCommandFunction(name string, description string, test interface{}) *CliApp { - c.rootCommand.NewSubCommandFunction(name, description, test) - return c -} diff --git a/pkg/core/cli_command.go b/pkg/core/command.go similarity index 96% rename from pkg/core/cli_command.go rename to pkg/core/command.go index eeee533..e9e671b 100644 --- a/pkg/core/cli_command.go +++ b/pkg/core/command.go @@ -1,10 +1,9 @@ package core import ( - "errors" "flag" - "io" "fmt" + "io" "os" "reflect" "strconv" @@ -21,7 +20,7 @@ type Command struct { subCommandsMap map[string]*Command longestSubcommand int actionCallback CliAction - app *CliApp + app *Cli flags *flag.FlagSet flagCount int helpFlag bool @@ -30,12 +29,16 @@ type Command struct { sliceSeparator map[string]string } -// NewCommand creates a new Command -// func NewCommand(name string, description string, app *Cli, parentCommandPath string) *Command { -func NewCommand(name string, description string) *Command { +// NewCommand creates a new Command. +// Description is optional — if omitted, i18n resolves it from the command path. +func NewCommand(name string, description ...string) *Command { + desc := "" + if len(description) > 0 { + desc = description[0] + } result := &Command{ name: name, - shortdescription: description, + shortdescription: desc, subCommandsMap: make(map[string]*Command), hidden: false, positionalArgsMap: make(map[string]reflect.Value), @@ -69,7 +72,7 @@ func (c *Command) inheritFlags(inheritFlags *flag.FlagSet) { }) } -func (c *Command) setApp(app *CliApp) { +func (c *Command) setApp(app *Cli) { c.app = app } @@ -77,7 +80,7 @@ func (c *Command) setApp(app *CliApp) { func (c *Command) parseFlags(args []string) error { // Parse flags // Suppress flag parse errors to stderr - + c.flags.SetOutput(io.Discard) // Credit: https://stackoverflow.com/a/74146375 @@ -129,7 +132,7 @@ func (c *Command) run(args []string) error { if c.app.errorHandler != nil { return c.app.errorHandler(c.commandPath, err) } - return fmt.Errorf("Error: %s\nSee '%s --help' for usage", err, c.commandPath) + return E("cli.Run", fmt.Sprintf("see '%s --help' for usage", c.commandPath), err) } // Help takes precedence @@ -225,9 +228,9 @@ func (c *Command) Hidden() { c.hidden = true } -// NewSubCommand - Creates a new subcommand -func (c *Command) NewSubCommand(name, description string) *Command { - result := NewCommand(name, description) +// NewChildCommand - Creates a new subcommand +func (c *Command) NewChildCommand(name string, description ...string) *Command { + result := NewCommand(name, description...) c.AddCommand(result) return result } @@ -244,14 +247,14 @@ func (c *Command) AddCommand(command *Command) { } } -// NewSubCommandInheritFlags - Creates a new subcommand, inherits flags from command -func (c *Command) NewSubCommandInheritFlags(name, description string) *Command { - result := c.NewSubCommand(name, description) +// NewChildCommandInheritFlags - Creates a new subcommand, inherits flags from command +func (c *Command) NewChildCommandInheritFlags(name string, description ...string) *Command { + result := c.NewChildCommand(name, description...) result.inheritFlags(c.flags) return result } -func (c *Command) AddFlags(optionStruct interface{}) *Command { +func (c *Command) AddFlags(optionStruct any) *Command { // use reflection to determine if this is a pointer to a struct // if not, panic @@ -436,7 +439,7 @@ func (c *Command) AddFlags(optionStruct interface{}) *Command { c.addSliceFlags(name, description, field) default: if pos != "" { - println("WARNING: Unsupported type for flag: ", fieldType.Type.Kind(), name) + fmt.Fprintf(os.Stderr, "WARNING: unsupported type for flag: %s %s\n", fieldType.Type.Kind(), name) } } } @@ -624,7 +627,7 @@ func (c *Command) addSliceField(field reflect.Value, defaultValue, separator str case reflect.Float32: defaultValues := make([]float32, 0, len(defaultSlice)) for _, value := range defaultSlice { - val, err := strconv.Atoi(value) + val, err := strconv.ParseFloat(value, 32) if err != nil { panic("Invalid default value for float32 flag") } @@ -634,7 +637,7 @@ func (c *Command) addSliceField(field reflect.Value, defaultValue, separator str case reflect.Float64: defaultValues := make([]float64, 0, len(defaultSlice)) for _, value := range defaultSlice { - val, err := strconv.Atoi(value) + val, err := strconv.ParseFloat(value, 64) if err != nil { panic("Invalid default value for float64 flag") } @@ -1250,8 +1253,8 @@ func (c *Command) OtherArgs() []string { return c.flags.Args() } -func (c *Command) NewSubCommandFunction(name string, description string, fn interface{}) *Command { - result := c.NewSubCommand(name, description) +func (c *Command) NewChildCommandFunction(name string, description string, fn any) *Command { + result := c.NewChildCommand(name, description) // use reflection to determine if this is a function // if not, panic t := reflect.TypeOf(fn) @@ -1326,7 +1329,7 @@ func (c *Command) parsePositionalArgs(args []string) error { case reflect.Slice: c.addSliceField(field, posArg, c.sliceSeparator[key]) default: - return errors.New("Unsupported type for positional argument: " + fieldType.Name()) + return E("cli.parsePositionalArgs", "unsupported type for positional argument: "+fieldType.Name(), nil) } } return nil diff --git a/pkg/core/etc.go b/pkg/core/config.go similarity index 62% rename from pkg/core/etc.go rename to pkg/core/config.go index 3a58016..fba030f 100644 --- a/pkg/core/etc.go +++ b/pkg/core/config.go @@ -11,49 +11,49 @@ import ( // Var is a variable that can be set, unset, and queried for its state. // Zero value is unset. -type Var[T any] struct { +type ConfigVar[T any] struct { val T set bool } // Get returns the value, or the zero value if unset. -func (v *Var[T]) Get() T { return v.val } +func (v *ConfigVar[T]) Get() T { return v.val } // Set sets the value and marks it as set. -func (v *Var[T]) Set(val T) { v.val = val; v.set = true } +func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } // IsSet returns true when a value has been set. -func (v *Var[T]) IsSet() bool { return v.set } +func (v *ConfigVar[T]) IsSet() bool { return v.set } // Unset resets to zero value and marks as unset. -func (v *Var[T]) Unset() { +func (v *ConfigVar[T]) Unset() { v.set = false var zero T v.val = zero } // NewVar creates a Var with the given value (marked as set). -func NewVar[T any](val T) Var[T] { - return Var[T]{val: val, set: true} +func NewConfigVar[T any](val T) ConfigVar[T] { + return ConfigVar[T]{val: val, set: true} } -// Etc holds configuration settings and feature flags. -type Etc struct { +// Cfg holds configuration settings and feature flags. +type Config struct { mu sync.RWMutex settings map[string]any features map[string]bool } // NewEtc creates a new configuration store. -func NewEtc() *Etc { - return &Etc{ +func NewConfig() *Config { + return &Config{ settings: make(map[string]any), features: make(map[string]bool), } } // Set stores a configuration value by key. -func (e *Etc) Set(key string, val any) { +func (e *Config) Set(key string, val any) { e.mu.Lock() e.settings[key] = val e.mu.Unlock() @@ -61,25 +61,20 @@ func (e *Etc) Set(key string, val any) { // Get retrieves a configuration value by key. // Returns (value, true) if found, (zero, false) if not. -func (e *Etc) Get(key string) (any, bool) { +func (e *Config) Get(key string) (any, bool) { e.mu.RLock() val, ok := e.settings[key] e.mu.RUnlock() return val, ok } -// GetString retrieves a string configuration value. -func (e *Etc) GetString(key string) string { return EtcGet[string](e, key) } +func (e *Config) String(key string) string { return ConfigGet[string](e, key) } +func (e *Config) Int(key string) int { return ConfigGet[int](e, key) } +func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) } -// GetInt retrieves an int configuration value. -func (e *Etc) GetInt(key string) int { return EtcGet[int](e, key) } - -// GetBool retrieves a bool configuration value. -func (e *Etc) GetBool(key string) bool { return EtcGet[bool](e, key) } - -// EtcGet retrieves a typed configuration value. +// ConfigGet retrieves a typed configuration value. // Returns zero value if key is missing or type doesn't match. -func EtcGet[T any](e *Etc, key string) T { +func ConfigGet[T any](e *Config, key string) T { val, ok := e.Get(key) if !ok { var zero T @@ -92,21 +87,21 @@ func EtcGet[T any](e *Etc, key string) T { // --- Feature Flags --- // Enable enables a feature flag. -func (e *Etc) Enable(feature string) { +func (e *Config) Enable(feature string) { e.mu.Lock() e.features[feature] = true e.mu.Unlock() } // Disable disables a feature flag. -func (e *Etc) Disable(feature string) { +func (e *Config) Disable(feature string) { e.mu.Lock() e.features[feature] = false e.mu.Unlock() } // Enabled returns true if the feature is enabled. -func (e *Etc) Enabled(feature string) bool { +func (e *Config) Enabled(feature string) bool { e.mu.RLock() v := e.features[feature] e.mu.RUnlock() @@ -114,7 +109,7 @@ func (e *Etc) Enabled(feature string) bool { } // Features returns all enabled feature names. -func (e *Etc) EnabledFeatures() []string { +func (e *Config) EnabledFeatures() []string { e.mu.RLock() defer e.mu.RUnlock() var result []string diff --git a/pkg/core/contract.go b/pkg/core/contract.go new file mode 100644 index 0000000..b19fe22 --- /dev/null +++ b/pkg/core/contract.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Contracts, options, and type definitions for the Core framework. + +package core + +import ( + "context" + "embed" + "fmt" + "io/fs" + "reflect" + "strings" +) + +// Contract specifies operational guarantees for Core and its services. +type Contract struct { + DontPanic bool + DisableLogging bool +} + +// Option is a function that configures the Core. +type Option func(*Core) error + +// Message is the type for IPC broadcasts (fire-and-forget). +type Message any + +// Query is the type for read-only IPC requests. +type Query any + +// Task is the type for IPC requests that perform side effects. +type Task any + +// TaskWithID is an optional interface for tasks that need to know their assigned ID. +type TaskWithID interface { + Task + SetTaskID(id string) + GetTaskID() string +} + +// QueryHandler handles Query requests. Returns (result, handled, error). +type QueryHandler func(*Core, Query) (any, bool, error) + +// TaskHandler handles Task requests. Returns (result, handled, error). +type TaskHandler func(*Core, Task) (any, bool, error) + +// Startable is implemented by services that need startup initialisation. +type Startable interface { + OnStartup(ctx context.Context) error +} + +// Stoppable is implemented by services that need shutdown cleanup. +type Stoppable interface { + OnShutdown(ctx context.Context) error +} + +// ConfigService provides access to application configuration. +type ConfigService interface { + Get(key string, out any) error + Set(key string, v any) error +} + +// --- Action Messages --- + +type ActionServiceStartup struct{} +type ActionServiceShutdown struct{} + +type ActionTaskStarted struct { + TaskID string + Task Task +} + +type ActionTaskProgress struct { + TaskID string + Task Task + Progress float64 + Message string +} + +type ActionTaskCompleted struct { + TaskID string + Task Task + Result any + Error error +} + +// --- Constructor --- + +// New creates a Core instance with the provided options. +func New(opts ...Option) (*Core, error) { + defaultFS, _ := NewIO("/") + app := NewApp("", "", "") + c := &Core{ + app: app, + fs: defaultFS, + cfg: NewConfig(), + err: &ErrPan{}, + log: &ErrLog{&ErrOpts{Log: defaultLog}}, + cli: NewCoreCli(app), + srv: NewService(), + lock: &Lock{}, + i18n: NewCoreI18n(), + } + c.ipc = NewBus(c) + + for _, o := range opts { + if err := o(c); err != nil { + return nil, err + } + } + + c.LockApply() + return c, nil +} + +// --- With* Options --- + +// WithService registers a service with auto-discovered name and IPC handler. +func WithService(factory func(*Core) (any, error)) Option { + return func(c *Core) error { + serviceInstance, err := factory(c) + if err != nil { + return E("core.WithService", "failed to create service", err) + } + if serviceInstance == nil { + return E("core.WithService", "service factory returned nil instance", nil) + } + + typeOfService := reflect.TypeOf(serviceInstance) + if typeOfService.Kind() == reflect.Ptr { + typeOfService = typeOfService.Elem() + } + pkgPath := typeOfService.PkgPath() + parts := strings.Split(pkgPath, "/") + name := strings.ToLower(parts[len(parts)-1]) + if name == "" { + return E("core.WithService", fmt.Sprintf("service name could not be discovered for type %T (PkgPath is empty)", serviceInstance), nil) + } + + instanceValue := reflect.ValueOf(serviceInstance) + handlerMethod := instanceValue.MethodByName("HandleIPCEvents") + if handlerMethod.IsValid() { + if handler, ok := handlerMethod.Interface().(func(*Core, Message) error); ok { + c.RegisterAction(handler) + } else { + return E("core.WithService", fmt.Sprintf("service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name), nil) + } + } + + result := c.Service(name, serviceInstance) + if err, ok := result.(error); ok { + return err + } + return nil + } +} + +// WithName registers a service with an explicit name. +func WithName(name string, factory func(*Core) (any, error)) Option { + return func(c *Core) error { + serviceInstance, err := factory(c) + if err != nil { + return E("core.WithName", fmt.Sprintf("failed to create service %q", name), err) + } + result := c.Service(name, serviceInstance) + if err, ok := result.(error); ok { + return err + } + return nil + } +} + +// WithApp injects the GUI runtime (e.g., Wails App). +func WithApp(runtime any) Option { + return func(c *Core) error { + c.app.Runtime = runtime + return nil + } +} + +// WithAssets mounts embedded assets. +func WithAssets(efs embed.FS) Option { + return func(c *Core) error { + sub, err := Mount(efs, ".") + if err != nil { + return E("core.WithAssets", "failed to mount assets", err) + } + c.emb = sub + return nil + } +} + +// WithIO sandboxes filesystem I/O to a root path. +func WithIO(root string) Option { + return func(c *Core) error { + io, err := NewIO(root) + if err != nil { + return E("core.WithIO", "failed to create IO at "+root, err) + } + c.fs = io + return nil + } +} + +// WithMount mounts an fs.FS at a specific subdirectory. +func WithMount(fsys fs.FS, basedir string) Option { + return func(c *Core) error { + sub, err := Mount(fsys, basedir) + if err != nil { + return E("core.WithMount", "failed to mount "+basedir, err) + } + c.emb = sub + return nil + } +} + +// WithServiceLock prevents service registration after initialisation. +func WithServiceLock() Option { + return func(c *Core) error { + c.LockEnable() + return nil + } +} diff --git a/pkg/core/core.go b/pkg/core/core.go index ae2abca..1e19b17 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -1,434 +1,76 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package core is a dependency injection and service lifecycle framework for Go. +// This file defines the Core struct, all exported methods, and all With* options. + package core import ( - "context" - "embed" - "errors" - "io/fs" - "fmt" - "reflect" - "slices" - "strings" "sync" + "sync/atomic" ) -var ( - instance *Core - instanceMu sync.RWMutex -) +// --- Core Struct --- -// New initialises a Core instance using the provided options and performs the necessary setup. -// It is the primary entry point for creating a new Core application. -// -// Example: -// -// core, err := core.New( -// core.WithService(&MyService{}), -// core.WithAssets(assets), -// ) -func New(opts ...Option) (*Core, error) { - // Default IO rooted at "/" (full access, like os package) - defaultIO, _ := NewIO("/") - c := &Core{ - io: defaultIO, - etc: NewEtc(), - crash: NewCrashHandler(), - cli: NewCliApp("", "", ""), - svc: newServiceManager(), - } - c.bus = newMessageBus(c) +// Core is the central application object that manages services, assets, and communication. +type Core struct { + app *App // c.App() — Application identity + optional GUI runtime + emb *Embed // c.Embed() — Mounted embedded assets (read-only) + fs *Fs // c.Fs() — Local filesystem I/O (sandboxable) + cfg *Config // c.Config() — Configuration, settings, feature flags + err *ErrPan // c.Error() — Panic recovery and crash reporting + log *ErrLog // c.Log() — Structured logging + error wrapping + cli *Cli // c.Cli() — CLI command framework + srv *Service // c.Service("name") — Service registry and lifecycle + lock *Lock // c.Lock("name") — Named mutexes + ipc *Ipc // c.IPC() — Message bus for IPC + i18n *I18n // c.I18n() — Internationalisation and locale collection - for _, o := range opts { - if err := o(c); err != nil { - return nil, err - } - } - - c.svc.applyLock() - return c, nil + taskIDCounter atomic.Uint64 + wg sync.WaitGroup + shutdown atomic.Bool } -// WithService creates an Option that registers a service. It automatically discovers -// the service name from its package path and registers its IPC handler if it -// implements a method named `HandleIPCEvents`. -// -// Example: -// -// // In myapp/services/calculator.go -// package services -// -// type Calculator struct{} -// -// func (s *Calculator) Add(a, b int) int { return a + b } -// -// // In main.go -// import "myapp/services" -// -// core.New(core.WithService(services.NewCalculator)) -func WithService(factory func(*Core) (any, error)) Option { - return func(c *Core) error { - serviceInstance, err := factory(c) +// --- Accessors --- - if err != nil { - return E("core.WithService", "failed to create service", err) - } - if serviceInstance == nil { - return E("core.WithService", "service factory returned nil instance", nil) - } +func (c *Core) App() *App { return c.app } +func (c *Core) Embed() *Embed { return c.emb } +func (c *Core) Fs() *Fs { return c.fs } +func (c *Core) Config() *Config { return c.cfg } +func (c *Core) Error() *ErrPan { return c.err } +func (c *Core) Log() *ErrLog { return c.log } +func (c *Core) Cli() *Cli { return c.cli } +func (c *Core) IPC() *Ipc { return c.ipc } +func (c *Core) I18n() *I18n { return c.i18n } +func (c *Core) Core() *Core { return c } - // --- Service Name Discovery --- - typeOfService := reflect.TypeOf(serviceInstance) - if typeOfService.Kind() == reflect.Ptr { - typeOfService = typeOfService.Elem() - } - pkgPath := typeOfService.PkgPath() - parts := strings.Split(pkgPath, "/") - name := strings.ToLower(parts[len(parts)-1]) - if name == "" { - return E("core.WithService", fmt.Sprintf("service name could not be discovered for type %T (PkgPath is empty)", serviceInstance), nil) - } +// --- IPC --- - // --- IPC Handler Discovery --- - instanceValue := reflect.ValueOf(serviceInstance) - handlerMethod := instanceValue.MethodByName("HandleIPCEvents") - if handlerMethod.IsValid() { - if handler, ok := handlerMethod.Interface().(func(*Core, Message) error); ok { - c.RegisterAction(handler) - } else { - return E("core.WithService", fmt.Sprintf("service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name), nil) - } - } +func (c *Core) ACTION(msg Message) error { return c.ipc.Action(msg) } +func (c *Core) RegisterAction(handler func(*Core, Message) error) { c.ipc.RegisterAction(handler) } +func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { c.ipc.RegisterActions(handlers...) } +func (c *Core) QUERY(q Query) (any, bool, error) { return c.ipc.Query(q) } +func (c *Core) QUERYALL(q Query) ([]any, error) { return c.ipc.QueryAll(q) } +func (c *Core) PERFORM(t Task) (any, bool, error) { return c.ipc.Perform(t) } +func (c *Core) RegisterQuery(handler QueryHandler) { c.ipc.RegisterQuery(handler) } +func (c *Core) RegisterTask(handler TaskHandler) { c.ipc.RegisterTask(handler) } - return c.RegisterService(name, serviceInstance) - } +// --- Error+Log --- + +// LogError logs an error and returns a wrapped error. +func (c *Core) LogError(err error, op, msg string) error { + return c.log.Error(err, op, msg) } -// WithName creates an option that registers a service with a specific name. -// This is useful when the service name cannot be inferred from the package path, -// such as when using anonymous functions as factories. -// Note: Unlike WithService, this does not automatically discover or register -// IPC handlers. If your service needs IPC handling, implement HandleIPCEvents -// and register it manually. -func WithName(name string, factory func(*Core) (any, error)) Option { - return func(c *Core) error { - serviceInstance, err := factory(c) - if err != nil { - return E("core.WithName", fmt.Sprintf("failed to create service %q", name), err) - } - return c.RegisterService(name, serviceInstance) - } +// LogWarn logs a warning and returns a wrapped error. +func (c *Core) LogWarn(err error, op, msg string) error { + return c.log.Warn(err, op, msg) } -// WithApp creates an Option that injects the GUI runtime (e.g., Wails App) into the Core. -// This is essential for services that need to interact with the GUI runtime. -func WithApp(app any) Option { - return func(c *Core) error { - c.App = app - return nil - } +// Must logs and panics if err is not nil. +func (c *Core) Must(err error, op, msg string) { + c.log.Must(err, op, msg) } -// WithAssets creates an Option that mounts embedded assets. -// The assets are accessible via c.Mnt(). -func WithAssets(efs embed.FS) Option { - return func(c *Core) error { - sub, err := Mount(efs, ".") - if err != nil { - return E("core.WithAssets", "failed to mount assets", err) - } - c.mnt = sub - return nil - } -} - -// WithIO creates an Option that sandboxes filesystem I/O to a root path. -// Default is "/" (full access). Use this to restrict c.Io() operations. -func WithIO(root string) Option { - return func(c *Core) error { - io, err := NewIO(root) - if err != nil { - return E("core.WithIO", "failed to create IO at "+root, err) - } - c.io = io - return nil - } -} - -// WithMount creates an Option that mounts an fs.FS at a specific subdirectory. -// The mounted assets are accessible via c.Mnt(). -func WithMount(fsys fs.FS, basedir string) Option { - return func(c *Core) error { - sub, err := Mount(fsys, basedir) - if err != nil { - return E("core.WithMount", "failed to mount "+basedir, err) - } - c.mnt = sub - return nil - } -} - -// WithServiceLock creates an Option that prevents any further services from being -// registered after the Core has been initialized. This is a security measure to -// prevent late-binding of services that could have unintended consequences. -func WithServiceLock() Option { - return func(c *Core) error { - c.svc.enableLock() - return nil - } -} - -// --- Core Methods --- - -// ServiceStartup is the entry point for the Core service's startup lifecycle. -// It is called by the GUI runtime when the application starts. -func (c *Core) ServiceStartup(ctx context.Context, options any) error { - startables := c.svc.getStartables() - - var agg error - for _, s := range startables { - if err := ctx.Err(); err != nil { - return errors.Join(agg, err) - } - if err := s.OnStartup(ctx); err != nil { - agg = errors.Join(agg, err) - } - } - - if err := c.ACTION(ActionServiceStartup{}); err != nil { - agg = errors.Join(agg, err) - } - - return agg -} - -// ServiceShutdown is the entry point for the Core service's shutdown lifecycle. -// It is called by the GUI runtime when the application shuts down. -func (c *Core) ServiceShutdown(ctx context.Context) error { - c.shutdown.Store(true) - - var agg error - if err := c.ACTION(ActionServiceShutdown{}); err != nil { - agg = errors.Join(agg, err) - } - - stoppables := c.svc.getStoppables() - for _, s := range slices.Backward(stoppables) { - if err := ctx.Err(); err != nil { - agg = errors.Join(agg, err) - break // don't return — must still wait for background tasks below - } - if err := s.OnShutdown(ctx); err != nil { - agg = errors.Join(agg, err) - } - } - - // Wait for background tasks (PerformAsync), respecting context deadline. - done := make(chan struct{}) - go func() { - c.wg.Wait() - close(done) - }() - select { - case <-done: - case <-ctx.Done(): - agg = errors.Join(agg, ctx.Err()) - } - - return agg -} - -// ACTION dispatches a message to all registered IPC handlers. -// This is the primary mechanism for services to communicate with each other. -func (c *Core) ACTION(msg Message) error { - return c.bus.action(msg) -} - -// RegisterAction adds a new IPC handler to the Core. -func (c *Core) RegisterAction(handler func(*Core, Message) error) { - c.bus.registerAction(handler) -} - -// RegisterActions adds multiple IPC handlers to the Core. -func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { - c.bus.registerActions(handlers...) -} - -// QUERY dispatches a query to handlers until one responds. -// Returns (result, handled, error). If no handler responds, handled is false. -func (c *Core) QUERY(q Query) (any, bool, error) { - return c.bus.query(q) -} - -// QUERYALL dispatches a query to all handlers and collects all responses. -// Returns all results from handlers that responded. -func (c *Core) QUERYALL(q Query) ([]any, error) { - return c.bus.queryAll(q) -} - -// PERFORM dispatches a task to handlers until one executes it. -// Returns (result, handled, error). If no handler responds, handled is false. -func (c *Core) PERFORM(t Task) (any, bool, error) { - return c.bus.perform(t) -} - -// PerformAsync dispatches a task to be executed in a background goroutine. -// It returns a unique task ID that can be used to track the task's progress. -// The result of the task will be broadcasted via an ActionTaskCompleted message. -func (c *Core) PerformAsync(t Task) string { - if c.shutdown.Load() { - return "" - } - - taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1)) - - // If the task supports it, inject the ID - if tid, ok := t.(TaskWithID); ok { - tid.SetTaskID(taskID) - } - - // Broadcast task started - _ = c.ACTION(ActionTaskStarted{ - TaskID: taskID, - Task: t, - }) - - c.wg.Go(func() { - result, handled, err := c.PERFORM(t) - if !handled && err == nil { - err = E("core.PerformAsync", fmt.Sprintf("no handler found for task type %T", t), nil) - } - - // Broadcast task completed - _ = c.ACTION(ActionTaskCompleted{ - TaskID: taskID, - Task: t, - Result: result, - Error: err, - }) - }) - - return taskID -} - -// Progress broadcasts a progress update for a background task. -func (c *Core) Progress(taskID string, progress float64, message string, t Task) { - _ = c.ACTION(ActionTaskProgress{ - TaskID: taskID, - Task: t, - Progress: progress, - Message: message, - }) -} - -// RegisterQuery adds a query handler to the Core. -func (c *Core) RegisterQuery(handler QueryHandler) { - c.bus.registerQuery(handler) -} - -// RegisterTask adds a task handler to the Core. -func (c *Core) RegisterTask(handler TaskHandler) { - c.bus.registerTask(handler) -} - -// RegisterService adds a new service to the Core. -// If the service implements LocaleProvider, its locale FS is collected -// for the i18n service to load during startup. -func (c *Core) RegisterService(name string, api any) error { - // Collect locale filesystems from services that provide them - if lp, ok := api.(LocaleProvider); ok { - c.locales = append(c.locales, lp.Locales()) - } - return c.svc.registerService(name, api) -} - -// Service retrieves a registered service by name. -// It returns nil if the service is not found. -func (c *Core) Service(name string) any { - return c.svc.service(name) -} - -// ServiceFor retrieves a registered service by name and asserts its type to the given interface T. -func ServiceFor[T any](c *Core, name string) (T, error) { - var zero T - raw := c.Service(name) - if raw == nil { - return zero, E("core.ServiceFor", fmt.Sprintf("service %q not found", name), nil) - } - typed, ok := raw.(T) - if !ok { - return zero, E("core.ServiceFor", fmt.Sprintf("service %q is type %T, expected %T", name, raw, zero), nil) - } - return typed, nil -} - -// MustServiceFor retrieves a registered service by name and asserts its type to the given interface T. -// It panics if the service is not found or cannot be cast to T. -func MustServiceFor[T any](c *Core, name string) T { - svc, err := ServiceFor[T](c, name) - if err != nil { - panic(err) - } - return svc -} - -// App returns the global application instance. -// It panics if the Core has not been initialized via SetInstance. -// This is typically used by GUI runtimes that need global access. -func App() any { - instanceMu.RLock() - inst := instance - instanceMu.RUnlock() - if inst == nil { - panic("core.App() called before core.SetInstance()") - } - return inst.App -} - -// SetInstance sets the global Core instance for App() access. -// This is typically called by GUI runtimes during initialization. -func SetInstance(c *Core) { - instanceMu.Lock() - instance = c - instanceMu.Unlock() -} - -// GetInstance returns the global Core instance, or nil if not set. -// Use this for non-panicking access to the global instance. -func GetInstance() *Core { - instanceMu.RLock() - inst := instance - instanceMu.RUnlock() - return inst -} - -// ClearInstance resets the global Core instance to nil. -// This is primarily useful for testing to ensure a clean state between tests. -func ClearInstance() { - instanceMu.Lock() - instance = nil - instanceMu.Unlock() -} - -// Config returns the registered Config service. -func (c *Core) Config() Config { - return MustServiceFor[Config](c, "config") -} - -// Display returns the registered Display service. -func (c *Core) Display() Display { - return MustServiceFor[Display](c, "display") -} - -// Workspace returns the registered Workspace service. -func (c *Core) Workspace() Workspace { - return MustServiceFor[Workspace](c, "workspace") -} - -// Crypt returns the registered Crypt service. -func (c *Core) Crypt() Crypt { - return MustServiceFor[Crypt](c, "crypt") -} - -// Core returns self, implementing the CoreProvider interface. -func (c *Core) Core() *Core { return c } +// --- Global Instance --- diff --git a/pkg/core/crash.go b/pkg/core/crash.go deleted file mode 100644 index 6a9ed87..0000000 --- a/pkg/core/crash.go +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -// Crash recovery and reporting for the Core framework. -// Named after adfer (Welsh for "recover"). Captures panics, -// writes JSON crash reports, and provides safe goroutine wrappers. - -package core - -import ( - "encoding/json" - "fmt" - "maps" - "os" - "runtime" - "runtime/debug" - "time" -) - -// CrashReport represents a single crash event. -type CrashReport struct { - Timestamp time.Time `json:"timestamp"` - Error string `json:"error"` - Stack string `json:"stack"` - System CrashSystem `json:"system,omitempty"` - Meta map[string]string `json:"meta,omitempty"` -} - -// CrashSystem holds system information at crash time. -type CrashSystem struct { - OS string `json:"os"` - Arch string `json:"arch"` - Version string `json:"go_version"` -} - -// CrashHandler manages panic recovery and crash reporting. -type CrashHandler struct { - filePath string - meta map[string]string - onCrash func(CrashReport) -} - -// CrashOption configures a CrashHandler. -type CrashOption func(*CrashHandler) - -// WithCrashFile sets the path for crash report JSON output. -func WithCrashFile(path string) CrashOption { - return func(h *CrashHandler) { h.filePath = path } -} - -// WithCrashMeta adds metadata included in every crash report. -func WithCrashMeta(meta map[string]string) CrashOption { - cloned := maps.Clone(meta) - return func(h *CrashHandler) { h.meta = cloned } -} - -// WithCrashHandler sets a callback invoked on every crash. -func WithCrashHandler(fn func(CrashReport)) CrashOption { - return func(h *CrashHandler) { h.onCrash = fn } -} - -// NewCrashHandler creates a crash handler with the given options. -func NewCrashHandler(opts ...CrashOption) *CrashHandler { - h := &CrashHandler{} - for _, o := range opts { - o(h) - } - return h -} - -// Recover captures a panic and creates a crash report. -// Use as: defer c.Crash().Recover() -func (h *CrashHandler) Recover() { - if h == nil { - return - } - r := recover() - if r == nil { - return - } - - err, ok := r.(error) - if !ok { - err = fmt.Errorf("%v", r) - } - - report := CrashReport{ - Timestamp: time.Now(), - Error: err.Error(), - Stack: string(debug.Stack()), - System: CrashSystem{ - OS: runtime.GOOS, - Arch: runtime.GOARCH, - Version: runtime.Version(), - }, - Meta: h.meta, - } - - if h.onCrash != nil { - h.onCrash(report) - } - - if h.filePath != "" { - h.appendReport(report) - } -} - -// SafeGo runs a function in a goroutine with panic recovery. -func (h *CrashHandler) SafeGo(fn func()) { - go func() { - defer h.Recover() - fn() - }() -} - -// Reports returns the last n crash reports from the file. -func (h *CrashHandler) Reports(n int) ([]CrashReport, error) { - if h.filePath == "" { - return nil, nil - } - data, err := os.ReadFile(h.filePath) - if err != nil { - return nil, err - } - var reports []CrashReport - if err := json.Unmarshal(data, &reports); err != nil { - return nil, err - } - if len(reports) <= n { - return reports, nil - } - return reports[len(reports)-n:], nil -} - -func (h *CrashHandler) appendReport(report CrashReport) { - var reports []CrashReport - - if data, err := os.ReadFile(h.filePath); err == nil { - json.Unmarshal(data, &reports) - } - - reports = append(reports, report) - data, _ := json.MarshalIndent(reports, "", " ") - os.WriteFile(h.filePath, data, 0644) -} diff --git a/pkg/core/embed.go b/pkg/core/embed.go index dd09c43..4e0addc 100644 --- a/pkg/core/embed.go +++ b/pkg/core/embed.go @@ -1,28 +1,30 @@ // SPDX-License-Identifier: EUPL-1.2 -// Build-time asset packing for the Core framework. -// Based on leaanthony/mewn — scans Go source AST for asset references, -// reads files, compresses, and generates Go source with embedded data. +// Embedded assets for the Core framework. // -// This enables asset embedding WITHOUT go:embed — the packer runs at -// build time and generates a .go file with init() that registers assets. -// This pattern works cross-language (Go, TypeScript, etc). +// Emb provides scoped filesystem access for go:embed and any fs.FS. +// Also includes build-time asset packing (AST scanner + compressor) +// and template-based directory extraction. // -// Usage (build tool): +// Usage (mount): // -// refs, _ := core.ScanAssets([]string{"main.go", "app.go"}) +// sub, _ := core.Mount(myFS, "lib/persona") +// content, _ := sub.ReadString("secops/developer.md") +// +// Usage (extract): +// +// core.Extract(fsys, "/tmp/workspace", data) +// +// Usage (pack): +// +// refs, _ := core.ScanAssets([]string{"main.go"}) // source, _ := core.GeneratePack(refs) -// os.WriteFile("pack.go", []byte(source), 0644) -// -// Usage (runtime): -// -// core.AddAsset(".", "template.html", compressedData) -// content := core.GetAsset(".", "template.html") package core import ( "bytes" "compress/gzip" + "embed" "encoding/base64" "fmt" "go/ast" @@ -34,13 +36,13 @@ import ( "path/filepath" "strings" "sync" + "text/template" ) // --- Runtime: Asset Registry --- // AssetGroup holds a named collection of packed assets. type AssetGroup struct { - assets map[string]string // name → compressed data } @@ -68,11 +70,11 @@ func GetAsset(group, name string) (string, error) { g, ok := assetGroups[group] assetGroupsMu.RUnlock() if !ok { - return "", fmt.Errorf("asset group %q not found", group) + return "", E("core.GetAsset", fmt.Sprintf("asset group %q not found", group), nil) } data, ok := g.assets[name] if !ok { - return "", fmt.Errorf("asset %q not found in group %q", name, group) + return "", E("core.GetAsset", fmt.Sprintf("asset %q not found in group %q", name, group), nil) } return decompress(data) } @@ -156,12 +158,12 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) { } fullPath, err := filepath.Abs(filepath.Join(baseDir, group, path)) if err != nil { - scanErr = fmt.Errorf("could not determine absolute path for asset %q in group %q: %w", path, group, err) + scanErr = Wrap(err, "core.ScanAssets", fmt.Sprintf("could not determine absolute path for asset %q in group %q", path, group)) return false } pkg.Assets = append(pkg.Assets, AssetRef{ - Name: path, - + Name: path, + Group: group, FullPath: fullPath, }) @@ -174,7 +176,7 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) { path := strings.Trim(lit.Value, "\"") fullPath, err := filepath.Abs(filepath.Join(baseDir, path)) if err != nil { - scanErr = fmt.Errorf("could not determine absolute path for group %q: %w", path, err) + scanErr = Wrap(err, "core.ScanAssets", fmt.Sprintf("could not determine absolute path for group %q", path)) return false } pkg.Groups = append(pkg.Groups, fullPath) @@ -217,7 +219,7 @@ func GeneratePack(pkg ScannedPackage) (string, error) { for _, groupPath := range pkg.Groups { files, err := getAllFiles(groupPath) if err != nil { - return "", fmt.Errorf("failed to scan asset group %q: %w", groupPath, err) + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to scan asset group %q", groupPath)) } for _, file := range files { if packed[file] { @@ -225,12 +227,12 @@ func GeneratePack(pkg ScannedPackage) (string, error) { } data, err := compressFile(file) if err != nil { - return "", fmt.Errorf("failed to compress asset %q in group %q: %w", file, groupPath, err) + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q in group %q", file, groupPath)) } localPath := strings.TrimPrefix(file, groupPath+"/") relGroup, err := filepath.Rel(pkg.BaseDir, groupPath) if err != nil { - return "", fmt.Errorf("could not determine relative path for group %q (base %q): %w", groupPath, pkg.BaseDir, err) + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("could not determine relative path for group %q (base %q)", groupPath, pkg.BaseDir)) } b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data)) packed[file] = true @@ -244,7 +246,7 @@ func GeneratePack(pkg ScannedPackage) (string, error) { } data, err := compressFile(asset.FullPath) if err != nil { - return "", fmt.Errorf("failed to compress asset %q: %w", asset.FullPath, err) + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q", asset.FullPath)) } b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data)) packed[asset.FullPath] = true @@ -292,7 +294,7 @@ func decompress(input string) (string, error) { if err != nil { return "", err } - + data, err := io.ReadAll(gz) if err != nil { return "", err @@ -316,3 +318,275 @@ func getAllFiles(dir string) ([]string, error) { }) return result, err } + +// --- Emb: Scoped Filesystem Mount --- + +// Emb wraps an fs.FS with a basedir for scoped access. +// All paths are relative to basedir. +type Embed struct { + basedir string + fsys fs.FS + embedFS *embed.FS // original embed.FS for type-safe access via EmbedFS() +} + +// Mount creates a scoped view of an fs.FS anchored at basedir. +// Works with embed.FS, os.DirFS, or any fs.FS implementation. +func Mount(fsys fs.FS, basedir string) (*Embed, error) { + s := &Embed{fsys: fsys, basedir: basedir} + + // If it's an embed.FS, keep a reference for EmbedFS() + if efs, ok := fsys.(embed.FS); ok { + s.embedFS = &efs + } + + // Verify the basedir exists + if _, err := s.ReadDir("."); err != nil { + return nil, err + } + return s, nil +} + +// MountEmbed creates a scoped view of an embed.FS. +func MountEmbed(efs embed.FS, basedir string) (*Embed, error) { + return Mount(efs, basedir) +} + +func (s *Embed) path(name string) string { + return filepath.ToSlash(filepath.Join(s.basedir, name)) +} + +// Open opens the named file for reading. +func (s *Embed) Open(name string) (fs.File, error) { + return s.fsys.Open(s.path(name)) +} + +// ReadDir reads the named directory. +func (s *Embed) ReadDir(name string) ([]fs.DirEntry, error) { + return fs.ReadDir(s.fsys, s.path(name)) +} + +// ReadFile reads the named file. +func (s *Embed) ReadFile(name string) ([]byte, error) { + return fs.ReadFile(s.fsys, s.path(name)) +} + +// ReadString reads the named file as a string. +func (s *Embed) ReadString(name string) (string, error) { + data, err := s.ReadFile(name) + if err != nil { + return "", err + } + return string(data), nil +} + +// Sub returns a new Emb anchored at a subdirectory within this mount. +func (s *Embed) Sub(subDir string) (*Embed, error) { + sub, err := fs.Sub(s.fsys, s.path(subDir)) + if err != nil { + return nil, err + } + return &Embed{fsys: sub, basedir: "."}, nil +} + +// FS returns the underlying fs.FS. +func (s *Embed) FS() fs.FS { + return s.fsys +} + +// EmbedFS returns the underlying embed.FS if mounted from one. +// Returns zero embed.FS if mounted from a non-embed source. +func (s *Embed) EmbedFS() embed.FS { + if s.embedFS != nil { + return *s.embedFS + } + return embed.FS{} +} + +// BaseDir returns the basedir this Emb is anchored at. +func (s *Embed) BaseDir() string { + return s.basedir +} + +// --- Template Extraction --- + +// ExtractOptions configures template extraction. +type ExtractOptions struct { + // TemplateFilters identifies template files by substring match. + // Default: [".tmpl"] + TemplateFilters []string + + // IgnoreFiles is a set of filenames to skip during extraction. + IgnoreFiles map[string]struct{} + + // RenameFiles maps original filenames to new names. + RenameFiles map[string]string +} + +// Extract copies a template directory from an fs.FS to targetDir, +// processing Go text/template in filenames and file contents. +// +// Files containing a template filter substring (default: ".tmpl") have +// their contents processed through text/template with the given data. +// The filter is stripped from the output filename. +// +// Directory and file names can contain Go template expressions: +// {{.Name}}/main.go → myproject/main.go +// +// Data can be any struct or map[string]string for template substitution. +func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error { + opt := ExtractOptions{ + TemplateFilters: []string{".tmpl"}, + IgnoreFiles: make(map[string]struct{}), + RenameFiles: make(map[string]string), + } + if len(opts) > 0 { + if len(opts[0].TemplateFilters) > 0 { + opt.TemplateFilters = opts[0].TemplateFilters + } + if opts[0].IgnoreFiles != nil { + opt.IgnoreFiles = opts[0].IgnoreFiles + } + if opts[0].RenameFiles != nil { + opt.RenameFiles = opts[0].RenameFiles + } + } + + // Ensure target directory exists + targetDir, err := filepath.Abs(targetDir) + if err != nil { + return err + } + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + // Categorise files + var dirs []string + var templateFiles []string + var standardFiles []string + + err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." { + return nil + } + if d.IsDir() { + dirs = append(dirs, path) + return nil + } + filename := filepath.Base(path) + if _, ignored := opt.IgnoreFiles[filename]; ignored { + return nil + } + if isTemplate(filename, opt.TemplateFilters) { + templateFiles = append(templateFiles, path) + } else { + standardFiles = append(standardFiles, path) + } + return nil + }) + if err != nil { + return err + } + + // Create directories (names may contain templates) + for _, dir := range dirs { + target := renderPath(filepath.Join(targetDir, dir), data) + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + } + + // Process template files + for _, path := range templateFiles { + tmpl, err := template.ParseFS(fsys, path) + if err != nil { + return err + } + + targetFile := renderPath(filepath.Join(targetDir, path), data) + + // Strip template filters from filename + dir := filepath.Dir(targetFile) + name := filepath.Base(targetFile) + for _, filter := range opt.TemplateFilters { + name = strings.ReplaceAll(name, filter, "") + } + if renamed := opt.RenameFiles[name]; renamed != "" { + name = renamed + } + targetFile = filepath.Join(dir, name) + + f, err := os.Create(targetFile) + if err != nil { + return err + } + if err := tmpl.Execute(f, data); err != nil { + f.Close() + return err + } + f.Close() + } + + // Copy standard files + for _, path := range standardFiles { + targetPath := path + name := filepath.Base(path) + if renamed := opt.RenameFiles[name]; renamed != "" { + targetPath = filepath.Join(filepath.Dir(path), renamed) + } + target := renderPath(filepath.Join(targetDir, targetPath), data) + if err := copyFile(fsys, path, target); err != nil { + return err + } + } + + return nil +} + +func isTemplate(filename string, filters []string) bool { + for _, f := range filters { + if strings.Contains(filename, f) { + return true + } + } + return false +} + +func renderPath(path string, data any) string { + if data == nil { + return path + } + tmpl, err := template.New("path").Parse(path) + if err != nil { + return path + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return path + } + return buf.String() +} + +func copyFile(fsys fs.FS, source, target string) error { + s, err := fsys.Open(source) + if err != nil { + return err + } + defer s.Close() + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + d, err := os.Create(target) + if err != nil { + return err + } + defer d.Close() + + _, err = io.Copy(d, s) + return err +} diff --git a/pkg/core/error.go b/pkg/core/error.go index b7a4bdf..f560051 100644 --- a/pkg/core/error.go +++ b/pkg/core/error.go @@ -1,17 +1,33 @@ -// 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. +// SPDX-License-Identifier: EUPL-1.2 + +// Structured errors, crash recovery, and reporting for the Core framework. +// Provides E() for error creation, Wrap()/WrapCode() for chaining, +// and Err for panic recovery and crash reporting. package core import ( + "encoding/json" "errors" "fmt" "iter" + "maps" + "os" + "runtime" + "runtime/debug" "strings" + "time" ) +// ErrSink is the shared interface for error reporting. +// Implemented by ErrLog (structured logging) and ErrPan (panic recovery). +type ErrSink interface { + Error(msg string, keyvals ...any) + Warn(msg string, keyvals ...any) +} + +var _ ErrSink = (*Log)(nil) + // Err represents a structured error with operational context. // It implements the error interface and supports unwrapping. type Err struct { @@ -215,56 +231,173 @@ func FormatStackTrace(err error) string { return strings.Join(ops, " -> ") } -// --- Combined Log-and-Return Helpers --- +// --- ErrLog: Log-and-Return Error 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 { +// ErrOpts holds shared options for error subsystems. +type ErrOpts struct { + Log *Log +} + +// ErrLog combines error creation with logging. +// Primary action: return an error. Secondary: log it. +type ErrLog struct { + *ErrOpts +} + +// NewErrLog creates an ErrLog (consumer convenience). +func NewErrLog(opts *ErrOpts) *ErrLog { + return &ErrLog{opts} +} + +// Error logs at Error level and returns a wrapped error. +func (el *ErrLog) Error(err error, op, msg string) error { if err == nil { return nil } wrapped := Wrap(err, op, msg) - defaultLogger.Error(msg, "op", op, "err", err) + el.Log.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 { +// Warn logs at Warn level and returns a wrapped error. +func (el *ErrLog) Warn(err error, op, msg string) error { if err == nil { return nil } wrapped := Wrap(err, op, msg) - defaultLogger.Warn(msg, "op", op, "err", err) + el.Log.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) { +// Must logs and panics if err is not nil. +func (el *ErrLog) Must(err error, op, msg string) { if err != nil { - defaultLogger.Error(msg, "op", op, "err", err) + el.Log.Error(msg, "op", op, "err", err) panic(Wrap(err, op, msg)) } } + +// --- Crash Recovery & Reporting --- + +// CrashReport represents a single crash event. +type CrashReport struct { + Timestamp time.Time `json:"timestamp"` + Error string `json:"error"` + Stack string `json:"stack"` + System CrashSystem `json:"system,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +// CrashSystem holds system information at crash time. +type CrashSystem struct { + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"go_version"` +} + +// ErrPan manages panic recovery and crash reporting. +type ErrPan struct { + filePath string + meta map[string]string + onCrash func(CrashReport) +} + +// PanOpts configures an ErrPan. +type PanOpts struct { + // FilePath is the crash report JSON output path. Empty disables file output. + FilePath string + // Meta is metadata included in every crash report. + Meta map[string]string + // OnCrash is a callback invoked on every crash. + OnCrash func(CrashReport) +} + +// NewErrPan creates an ErrPan (consumer convenience). +func NewErrPan(opts ...PanOpts) *ErrPan { + h := &ErrPan{} + if len(opts) > 0 { + o := opts[0] + h.filePath = o.FilePath + if o.Meta != nil { + h.meta = maps.Clone(o.Meta) + } + h.onCrash = o.OnCrash + } + return h +} + +// Recover captures a panic and creates a crash report. +// Use as: defer c.Error().Recover() +func (h *ErrPan) Recover() { + if h == nil { + return + } + r := recover() + if r == nil { + return + } + + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + + report := CrashReport{ + Timestamp: time.Now(), + Error: err.Error(), + Stack: string(debug.Stack()), + System: CrashSystem{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Version: runtime.Version(), + }, + Meta: h.meta, + } + + if h.onCrash != nil { + h.onCrash(report) + } + + if h.filePath != "" { + h.appendReport(report) + } +} + +// SafeGo runs a function in a goroutine with panic recovery. +func (h *ErrPan) SafeGo(fn func()) { + go func() { + defer h.Recover() + fn() + }() +} + +// Reports returns the last n crash reports from the file. +func (h *ErrPan) Reports(n int) ([]CrashReport, error) { + if h.filePath == "" { + return nil, nil + } + data, err := os.ReadFile(h.filePath) + if err != nil { + return nil, err + } + var reports []CrashReport + if err := json.Unmarshal(data, &reports); err != nil { + return nil, err + } + if len(reports) <= n { + return reports, nil + } + return reports[len(reports)-n:], nil +} + +func (h *ErrPan) appendReport(report CrashReport) { + var reports []CrashReport + + if data, err := os.ReadFile(h.filePath); err == nil { + json.Unmarshal(data, &reports) + } + + reports = append(reports, report) + data, _ := json.MarshalIndent(reports, "", " ") + os.WriteFile(h.filePath, data, 0644) +} diff --git a/pkg/core/io.go b/pkg/core/fs.go similarity index 75% rename from pkg/core/io.go rename to pkg/core/fs.go index bc7b84b..336253f 100644 --- a/pkg/core/io.go +++ b/pkg/core/fs.go @@ -1,4 +1,4 @@ -// Package local provides a local filesystem implementation of the io.Medium interface. +// Sandboxed local filesystem I/O for the Core framework. package core import ( @@ -10,18 +10,16 @@ import ( "path/filepath" "strings" "time" - - ) -// Medium is a local filesystem storage backend. -type IO struct { +// Fs is a sandboxed local filesystem backend. +type Fs struct { root string } -// New creates a new local Medium rooted at the given directory. +// NewIO creates a Fs rooted at the given directory. // Pass "/" for full filesystem access, or a specific path to sandbox. -func NewIO(root string) (*IO, error) { +func NewIO(root string) (*Fs, error) { abs, err := filepath.Abs(root) if err != nil { return nil, err @@ -33,12 +31,12 @@ func NewIO(root string) (*IO, error) { if resolved, err := filepath.EvalSymlinks(abs); err == nil { abs = resolved } - return &IO{root: abs}, nil + return &Fs{root: abs}, nil } // path sanitises and returns the full path. // Absolute paths are sandboxed under root (unless root is "/"). -func (m *IO) path(p string) string { +func (m *Fs) path(p string) string { if p == "" { return m.root } @@ -65,7 +63,7 @@ func (m *IO) path(p string) string { } // validatePath ensures the path is within the sandbox, following symlinks if they exist. -func (m *IO) validatePath(p string) (string, error) { +func (m *Fs) validatePath(p string) (string, error) { if m.root == "/" { return m.path(p), nil } @@ -111,7 +109,7 @@ func (m *IO) validatePath(p string) (string, error) { } // Read returns file contents as string. -func (m *IO) Read(p string) (string, error) { +func (m *Fs) Read(p string) (string, error) { full, err := m.validatePath(p) if err != nil { return "", err @@ -126,13 +124,13 @@ func (m *IO) Read(p string) (string, error) { // Write saves content to file, creating parent directories as needed. // Files are created with mode 0644. For sensitive files (keys, secrets), // use WriteMode with 0600. -func (m *IO) Write(p, content string) error { +func (m *Fs) Write(p, content string) error { return m.WriteMode(p, content, 0644) } // WriteMode saves content to file with explicit permissions. // Use 0600 for sensitive files (encryption output, private keys, auth hashes). -func (m *IO) WriteMode(p, content string, mode os.FileMode) error { +func (m *Fs) WriteMode(p, content string, mode os.FileMode) error { full, err := m.validatePath(p) if err != nil { return err @@ -144,7 +142,7 @@ func (m *IO) WriteMode(p, content string, mode os.FileMode) error { } // EnsureDir creates directory if it doesn't exist. -func (m *IO) EnsureDir(p string) error { +func (m *Fs) EnsureDir(p string) error { full, err := m.validatePath(p) if err != nil { return err @@ -153,7 +151,7 @@ func (m *IO) EnsureDir(p string) error { } // IsDir returns true if path is a directory. -func (m *IO) IsDir(p string) bool { +func (m *Fs) IsDir(p string) bool { if p == "" { return false } @@ -166,7 +164,7 @@ func (m *IO) IsDir(p string) bool { } // IsFile returns true if path is a regular file. -func (m *IO) IsFile(p string) bool { +func (m *Fs) IsFile(p string) bool { if p == "" { return false } @@ -179,7 +177,7 @@ func (m *IO) IsFile(p string) bool { } // Exists returns true if path exists. -func (m *IO) Exists(p string) bool { +func (m *Fs) Exists(p string) bool { full, err := m.validatePath(p) if err != nil { return false @@ -189,7 +187,7 @@ func (m *IO) Exists(p string) bool { } // List returns directory entries. -func (m *IO) List(p string) ([]fs.DirEntry, error) { +func (m *Fs) List(p string) ([]fs.DirEntry, error) { full, err := m.validatePath(p) if err != nil { return nil, err @@ -198,7 +196,7 @@ func (m *IO) List(p string) ([]fs.DirEntry, error) { } // Stat returns file info. -func (m *IO) Stat(p string) (fs.FileInfo, error) { +func (m *Fs) Stat(p string) (fs.FileInfo, error) { full, err := m.validatePath(p) if err != nil { return nil, err @@ -207,7 +205,7 @@ func (m *IO) Stat(p string) (fs.FileInfo, error) { } // Open opens the named file for reading. -func (m *IO) Open(p string) (fs.File, error) { +func (m *Fs) Open(p string) (fs.File, error) { full, err := m.validatePath(p) if err != nil { return nil, err @@ -216,7 +214,7 @@ func (m *IO) Open(p string) (fs.File, error) { } // Create creates or truncates the named file. -func (m *IO) Create(p string) (io.WriteCloser, error) { +func (m *Fs) Create(p string) (io.WriteCloser, error) { full, err := m.validatePath(p) if err != nil { return nil, err @@ -228,7 +226,7 @@ func (m *IO) Create(p string) (io.WriteCloser, error) { } // Append opens the named file for appending, creating it if it doesn't exist. -func (m *IO) Append(p string) (io.WriteCloser, error) { +func (m *Fs) Append(p string) (io.WriteCloser, error) { full, err := m.validatePath(p) if err != nil { return nil, err @@ -240,27 +238,17 @@ func (m *IO) Append(p string) (io.WriteCloser, error) { } // ReadStream returns a reader for the file content. -// -// This is a convenience wrapper around Open that exposes a streaming-oriented -// API, as required by the io.Medium interface, while Open provides the more -// general filesystem-level operation. Both methods are kept for semantic -// clarity and backward compatibility. -func (m *IO) ReadStream(path string) (io.ReadCloser, error) { +func (m *Fs) ReadStream(path string) (io.ReadCloser, error) { return m.Open(path) } // WriteStream returns a writer for the file content. -// -// This is a convenience wrapper around Create that exposes a streaming-oriented -// API, as required by the io.Medium interface, while Create provides the more -// general filesystem-level operation. Both methods are kept for semantic -// clarity and backward compatibility. -func (m *IO) WriteStream(path string) (io.WriteCloser, error) { +func (m *Fs) WriteStream(path string) (io.WriteCloser, error) { return m.Create(path) } // Delete removes a file or empty directory. -func (m *IO) Delete(p string) error { +func (m *Fs) Delete(p string) error { full, err := m.validatePath(p) if err != nil { return err @@ -272,7 +260,7 @@ func (m *IO) Delete(p string) error { } // DeleteAll removes a file or directory recursively. -func (m *IO) DeleteAll(p string) error { +func (m *Fs) DeleteAll(p string) error { full, err := m.validatePath(p) if err != nil { return err @@ -284,7 +272,7 @@ func (m *IO) DeleteAll(p string) error { } // Rename moves a file or directory. -func (m *IO) Rename(oldPath, newPath string) error { +func (m *Fs) Rename(oldPath, newPath string) error { oldFull, err := m.validatePath(oldPath) if err != nil { return err @@ -295,13 +283,3 @@ func (m *IO) Rename(oldPath, newPath string) error { } return os.Rename(oldFull, newFull) } - -// FileGet is an alias for Read. -func (m *IO) FileGet(p string) (string, error) { - return m.Read(p) -} - -// FileSet is an alias for Write. -func (m *IO) FileSet(p, content string) error { - return m.Write(p, content) -} diff --git a/pkg/core/i18n.go b/pkg/core/i18n.go new file mode 100644 index 0000000..73acf55 --- /dev/null +++ b/pkg/core/i18n.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Internationalisation for the Core framework. +// I18n collects locale mounts from services and delegates +// translation to a registered Translator implementation (e.g., go-i18n). + +package core + +import ( + "sync" +) + +// Translator defines the interface for translation services. +// Implemented by go-i18n's Srv. +type Translator interface { + // T translates a message by its ID with optional arguments. + T(messageID string, args ...any) string + // SetLanguage sets the active language (BCP47 tag, e.g., "en-GB", "de"). + SetLanguage(lang string) error + // Language returns the current language code. + Language() string + // AvailableLanguages returns all loaded language codes. + AvailableLanguages() []string +} + +// LocaleProvider is implemented by services that ship their own translation files. +// Core discovers this interface during service registration and collects the +// locale mounts. The i18n service loads them during startup. +// +// Usage in a service package: +// +// //go:embed locales +// var localeFS embed.FS +// +// func (s *MyService) Locales() *Embed { +// m, _ := Mount(localeFS, "locales") +// return m +// } +type LocaleProvider interface { + Locales() *Embed +} + +// I18n manages locale collection and translation dispatch. +type I18n struct { + mu sync.RWMutex + locales []*Embed // collected from LocaleProvider services + translator Translator // registered implementation (nil until set) +} + +// NewCoreI18n creates a new i18n manager. +func NewCoreI18n() *I18n { + return &I18n{} +} + +// AddLocales adds locale mounts (called during service registration). +func (i *I18n) AddLocales(mounts ...*Embed) { + i.mu.Lock() + i.locales = append(i.locales, mounts...) + i.mu.Unlock() +} + +// Locales returns all collected locale mounts. +func (i *I18n) Locales() []*Embed { + i.mu.RLock() + out := make([]*Embed, len(i.locales)) + copy(out, i.locales) + i.mu.RUnlock() + return out +} + +// SetTranslator registers the translation implementation. +// Called by go-i18n's Srv during startup. +func (i *I18n) SetTranslator(t Translator) { + i.mu.Lock() + i.translator = t + i.mu.Unlock() +} + +// Translator returns the registered translation implementation, or nil. +func (i *I18n) Translator() Translator { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + return t +} + +// T translates a message. Returns the key as-is if no translator is registered. +func (i *I18n) T(messageID string, args ...any) string { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.T(messageID, args...) + } + return messageID +} + +// SetLanguage sets the active language. No-op if no translator is registered. +func (i *I18n) SetLanguage(lang string) error { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.SetLanguage(lang) + } + return nil +} + +// Language returns the current language code, or "en" if no translator. +func (i *I18n) Language() string { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.Language() + } + return "en" +} + +// AvailableLanguages returns all loaded language codes. +func (i *I18n) AvailableLanguages() []string { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.AvailableLanguages() + } + return []string{"en"} +} diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go deleted file mode 100644 index 033038a..0000000 --- a/pkg/core/interfaces.go +++ /dev/null @@ -1,214 +0,0 @@ -package core - -import ( - "context" - goio "io" - "io/fs" - "sync" - "sync/atomic" -) - -// This file defines the public API contracts (interfaces) for the services -// in the Core framework. Services depend on these interfaces, not on -// concrete implementations. - -// Contract specifies the operational guarantees that the Core and its services must adhere to. -// This is used for configuring panic handling and other resilience features. -type Contract struct { - // DontPanic, if true, instructs the Core to recover from panics and return an error instead. - DontPanic bool - // DisableLogging, if true, disables all logging from the Core and its services. - DisableLogging bool -} - -// Option is a function that configures the Core. -// This is used to apply settings and register services during initialization. -type Option func(*Core) error - -// Message is the interface for all messages that can be sent through the Core's IPC system. -// Any struct can be a message, allowing for structured data to be passed between services. -// Used with ACTION for fire-and-forget broadcasts. -type Message any - -// Query is the interface for read-only requests that return data. -// Used with QUERY (first responder) or QUERYALL (all responders). -type Query any - -// Task is the interface for requests that perform side effects. -// Used with PERFORM (first responder executes). -type Task any - -// TaskWithID is an optional interface for tasks that need to know their assigned ID. -// This is useful for tasks that want to report progress back to the frontend. -type TaskWithID interface { - Task - SetTaskID(id string) - GetTaskID() string -} - -// QueryHandler handles Query requests. Returns (result, handled, error). -// If handled is false, the query will be passed to the next handler. -type QueryHandler func(*Core, Query) (any, bool, error) - -// TaskHandler handles Task requests. Returns (result, handled, error). -// If handled is false, the task will be passed to the next handler. -type TaskHandler func(*Core, Task) (any, bool, error) - -// Startable is an interface for services that need to perform initialization. -type Startable interface { - OnStartup(ctx context.Context) error -} - -// Stoppable is an interface for services that need to perform cleanup. -type Stoppable interface { - OnShutdown(ctx context.Context) error -} - -// LocaleProvider is implemented by services that ship their own translation files. -// Core discovers this interface during service registration and collects the -// locale filesystems. The i18n service loads them during startup. -// -// Usage in a service package: -// -// //go:embed locales -// var localeFS embed.FS -// -// func (s *MyService) Locales() fs.FS { return localeFS } -type LocaleProvider interface { - Locales() fs.FS -} - -// Core is the central application object that manages services, assets, and communication. -type Core struct { - App any // GUI runtime (e.g., Wails App) - set by WithApp option - mnt *Sub // Mounted embedded assets (read-only) - io *IO // Local filesystem I/O (read/write, sandboxable) - etc *Etc // Configuration, settings, and feature flags - crash *CrashHandler // Panic recovery and crash reporting - cli *CliApp // CLI command registration and execution - svc *serviceManager - bus *messageBus - locales []fs.FS // collected from LocaleProvider services - - taskIDCounter atomic.Uint64 - wg sync.WaitGroup - shutdown atomic.Bool -} - -// Mnt returns the mounted embedded assets (read-only). -// -// c.Mnt().ReadString("persona/secops/developer.md") -func (c *Core) Mnt() *Sub { - return c.mnt -} - -// Io returns the local filesystem I/O layer. -// Default: rooted at "/". Sandboxable via WithIO("./data"). -// -// c.Io().Read("config.yaml") -// c.Io().Write("output.txt", content) -func (c *Core) Io() *IO { - return c.io -} - -// Etc returns the configuration and feature flags store. -// -// c.Etc().Set("api_url", "https://api.lthn.sh") -// c.Etc().Enable("coderabbit") -// c.Etc().Enabled("coderabbit") // true -func (c *Core) Etc() *Etc { - return c.etc -} - -// Crash returns the crash handler for panic recovery. -// -// defer c.Crash().Recover() -// c.Crash().SafeGo(func() { ... }) -func (c *Core) Crash() *CrashHandler { - return c.crash -} - -// Cli returns the CLI command framework. -// Register commands without importing any CLI package. -// -// c.Cli().NewSubCommand("health", "Check service health").Action(func() error { ... }) -func (c *Core) Cli() *CliApp { - return c.cli -} - -// Locales returns all locale filesystems collected from registered services. -// The i18n service uses this during startup to load translations. -func (c *Core) Locales() []fs.FS { - return c.locales -} - -// Config provides access to application configuration. -type Config interface { - // Get retrieves a configuration value by key and stores it in the 'out' variable. - Get(key string, out any) error - // Set stores a configuration value by key. - Set(key string, v any) error -} - -// WindowOption is an interface for applying configuration options to a window. -type WindowOption interface { - Apply(any) -} - -// Display provides access to windowing and visual elements. -type Display interface { - // OpenWindow creates a new window with the given options. - OpenWindow(opts ...WindowOption) error -} - -// Workspace provides management for encrypted user workspaces. -type Workspace interface { - // CreateWorkspace creates a new encrypted workspace. - CreateWorkspace(identifier, password string) (string, error) - // SwitchWorkspace changes the active workspace. - SwitchWorkspace(name string) error - // WorkspaceFileGet retrieves the content of a file from the active workspace. - WorkspaceFileGet(filename string) (string, error) - // WorkspaceFileSet saves content to a file in the active workspace. - WorkspaceFileSet(filename, content string) error -} - -// Crypt provides PGP-based encryption, signing, and key management. -type Crypt interface { - // CreateKeyPair generates a new PGP keypair. - CreateKeyPair(name, passphrase string) (string, error) - // EncryptPGP encrypts data for a recipient. - EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) - // DecryptPGP decrypts a PGP message. - DecryptPGP(recipientPath, message, passphrase string, opts ...any) (string, error) -} - -// ActionServiceStartup is a message sent when the application's services are starting up. -// This provides a hook for services to perform initialization tasks. -type ActionServiceStartup struct{} - -// ActionServiceShutdown is a message sent when the application is shutting down. -// This allows services to perform cleanup tasks, such as saving state or closing resources. -type ActionServiceShutdown struct{} - -// ActionTaskStarted is a message sent when a background task has started. -type ActionTaskStarted struct { - TaskID string - Task Task -} - -// ActionTaskProgress is a message sent when a task has progress updates. -type ActionTaskProgress struct { - TaskID string - Task Task - Progress float64 // 0.0 to 1.0 - Message string -} - -// ActionTaskCompleted is a message sent when a task has completed. -type ActionTaskCompleted struct { - TaskID string - Task Task - Result any - Error error -} diff --git a/pkg/core/message_bus.go b/pkg/core/ipc.go similarity index 56% rename from pkg/core/message_bus.go rename to pkg/core/ipc.go index 4f81e77..223d3b6 100644 --- a/pkg/core/message_bus.go +++ b/pkg/core/ipc.go @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Message bus for the Core framework. +// Dispatches actions (fire-and-forget), queries (first responder), +// and tasks (first executor) between registered handlers. + package core import ( @@ -6,9 +12,8 @@ import ( "sync" ) -// messageBus owns the IPC action, query, and task dispatch. -// It is an unexported component used internally by Core. -type messageBus struct { +// Ipc owns action, query, and task dispatch between services. +type Ipc struct { core *Core ipcMu sync.RWMutex @@ -21,13 +26,13 @@ type messageBus struct { taskHandlers []TaskHandler } -// newMessageBus creates an empty message bus bound to the given Core. -func newMessageBus(c *Core) *messageBus { - return &messageBus{core: c} +// NewBus creates an empty message bus bound to the given Core. +func NewBus(c *Core) *Ipc { + return &Ipc{core: c} } -// action dispatches a message to all registered IPC handlers. -func (b *messageBus) action(msg Message) error { +// Action dispatches a message to all registered IPC handlers. +func (b *Ipc) Action(msg Message) error { b.ipcMu.RLock() handlers := slices.Clone(b.ipcHandlers) b.ipcMu.RUnlock() @@ -41,22 +46,22 @@ func (b *messageBus) action(msg Message) error { return agg } -// registerAction adds a single IPC handler. -func (b *messageBus) registerAction(handler func(*Core, Message) error) { +// RegisterAction adds a single IPC handler. +func (b *Ipc) RegisterAction(handler func(*Core, Message) error) { b.ipcMu.Lock() b.ipcHandlers = append(b.ipcHandlers, handler) b.ipcMu.Unlock() } -// registerActions adds multiple IPC handlers. -func (b *messageBus) registerActions(handlers ...func(*Core, Message) error) { +// RegisterActions adds multiple IPC handlers. +func (b *Ipc) RegisterActions(handlers ...func(*Core, Message) error) { b.ipcMu.Lock() b.ipcHandlers = append(b.ipcHandlers, handlers...) b.ipcMu.Unlock() } -// query dispatches a query to handlers until one responds. -func (b *messageBus) query(q Query) (any, bool, error) { +// Query dispatches a query to handlers until one responds. +func (b *Ipc) Query(q Query) (any, bool, error) { b.queryMu.RLock() handlers := slices.Clone(b.queryHandlers) b.queryMu.RUnlock() @@ -70,8 +75,8 @@ func (b *messageBus) query(q Query) (any, bool, error) { return nil, false, nil } -// queryAll dispatches a query to all handlers and collects all responses. -func (b *messageBus) queryAll(q Query) ([]any, error) { +// QueryAll dispatches a query to all handlers and collects all responses. +func (b *Ipc) QueryAll(q Query) ([]any, error) { b.queryMu.RLock() handlers := slices.Clone(b.queryHandlers) b.queryMu.RUnlock() @@ -90,15 +95,15 @@ func (b *messageBus) queryAll(q Query) ([]any, error) { return results, agg } -// registerQuery adds a query handler. -func (b *messageBus) registerQuery(handler QueryHandler) { +// RegisterQuery adds a query handler. +func (b *Ipc) RegisterQuery(handler QueryHandler) { b.queryMu.Lock() b.queryHandlers = append(b.queryHandlers, handler) b.queryMu.Unlock() } -// perform dispatches a task to handlers until one executes it. -func (b *messageBus) perform(t Task) (any, bool, error) { +// Perform dispatches a task to handlers until one executes it. +func (b *Ipc) Perform(t Task) (any, bool, error) { b.taskMu.RLock() handlers := slices.Clone(b.taskHandlers) b.taskMu.RUnlock() @@ -112,8 +117,8 @@ func (b *messageBus) perform(t Task) (any, bool, error) { return nil, false, nil } -// registerTask adds a task handler. -func (b *messageBus) registerTask(handler TaskHandler) { +// RegisterTask adds a task handler. +func (b *Ipc) RegisterTask(handler TaskHandler) { b.taskMu.Lock() b.taskHandlers = append(b.taskHandlers, handler) b.taskMu.Unlock() diff --git a/pkg/core/lock.go b/pkg/core/lock.go new file mode 100644 index 0000000..4c085b3 --- /dev/null +++ b/pkg/core/lock.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Synchronisation, locking, and lifecycle snapshots for the Core framework. + +package core + +import ( + "slices" + "sync" +) + +// package-level mutex infrastructure +var ( + lockMu sync.Mutex + lockMap = make(map[string]*sync.RWMutex) +) + +// Lock is the DTO for a named mutex. +type Lock struct { + Name string + Mu *sync.RWMutex +} + +// Lock returns a named Lock, creating the mutex if needed. +func (c *Core) Lock(name string) *Lock { + lockMu.Lock() + m, ok := lockMap[name] + if !ok { + m = &sync.RWMutex{} + lockMap[name] = m + } + lockMu.Unlock() + return &Lock{Name: name, Mu: m} +} + +// LockEnable marks that the service lock should be applied after initialisation. +func (c *Core) LockEnable(name ...string) { + n := "srv" + if len(name) > 0 { + n = name[0] + } + c.Lock(n).Mu.Lock() + defer c.Lock(n).Mu.Unlock() + c.srv.lockEnabled = true +} + +// LockApply activates the service lock if it was enabled. +func (c *Core) LockApply(name ...string) { + n := "srv" + if len(name) > 0 { + n = name[0] + } + c.Lock(n).Mu.Lock() + defer c.Lock(n).Mu.Unlock() + if c.srv.lockEnabled { + c.srv.locked = true + } +} + +// Startables returns a snapshot of services implementing Startable. +func (c *Core) Startables() []Startable { + c.Lock("srv").Mu.RLock() + out := slices.Clone(c.srv.startables) + c.Lock("srv").Mu.RUnlock() + return out +} + +// Stoppables returns a snapshot of services implementing Stoppable. +func (c *Core) Stoppables() []Stoppable { + c.Lock("srv").Mu.RLock() + out := slices.Clone(c.srv.stoppables) + c.Lock("srv").Mu.RUnlock() + return out +} diff --git a/pkg/core/log.go b/pkg/core/log.go index 99f62ad..75bef44 100644 --- a/pkg/core/log.go +++ b/pkg/core/log.go @@ -1,8 +1,8 @@ -// Package log provides structured logging and error handling for Core applications. +// Structured logging for the Core framework. // -// log.SetLevel(log.LevelDebug) -// log.Info("server started", "port", 8080) -// log.Error("failed to connect", "err", err) +// core.SetLevel(core.LevelDebug) +// core.Info("server started", "port", 8080) +// core.Error("failed to connect", "err", err) package core import ( @@ -50,8 +50,8 @@ func (l Level) String() string { } } -// Logger provides structured logging. -type Logger struct { +// Log provides structured logging. +type Log struct { mu sync.RWMutex level Level output goio.Writer @@ -68,8 +68,8 @@ type Logger struct { StyleSecurity func(string) string } -// RotationOptions defines the log rotation and retention policy. -type RotationOptions struct { +// RotationLogOpts defines the log rotation and retention policy. +type RotationLogOpts struct { // Filename is the log file path. If empty, rotation is disabled. Filename string @@ -91,24 +91,24 @@ type RotationOptions struct { Compress bool } -// Options configures a Logger. -type Options struct { +// LogOpts configures a Log. +type LogOpts 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 + Rotation *RotationLogOpts // 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 +var RotationWriterFactory func(RotationLogOpts) goio.WriteCloser -// New creates a new Logger with the given options. -func NewLogger(opts Options) *Logger { +// New creates a new Log with the given options. +func NewLog(opts LogOpts) *Log { output := opts.Output if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil { output = RotationWriterFactory(*opts.Rotation) @@ -117,7 +117,7 @@ func NewLogger(opts Options) *Logger { output = os.Stderr } - return &Logger{ + return &Log{ level: opts.Level, output: output, redactKeys: slices.Clone(opts.RedactKeys), @@ -133,40 +133,40 @@ func NewLogger(opts Options) *Logger { func identity(s string) string { return s } // SetLevel changes the log level. -func (l *Logger) SetLevel(level Level) { +func (l *Log) SetLevel(level Level) { l.mu.Lock() l.level = level l.mu.Unlock() } // Level returns the current log level. -func (l *Logger) Level() Level { +func (l *Log) Level() Level { l.mu.RLock() defer l.mu.RUnlock() return l.level } // SetOutput changes the output writer. -func (l *Logger) SetOutput(w goio.Writer) { +func (l *Log) 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) { +func (l *Log) SetRedactKeys(keys ...string) { l.mu.Lock() l.redactKeys = slices.Clone(keys) l.mu.Unlock() } -func (l *Logger) shouldLog(level Level) bool { +func (l *Log) 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) { +func (l *Log) log(level Level, prefix, msg string, keyvals ...any) { l.mu.RLock() output := l.output styleTimestamp := l.StyleTimestamp @@ -243,28 +243,28 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { } // Debug logs a debug message with optional key-value pairs. -func (l *Logger) Debug(msg string, keyvals ...any) { +func (l *Log) 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) { +func (l *Log) 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) { +func (l *Log) 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) { +func (l *Log) Error(msg string, keyvals ...any) { if l.shouldLog(LevelError) { l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...) } @@ -273,7 +273,7 @@ func (l *Logger) Error(msg string, keyvals ...any) { // 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) { +func (l *Log) Security(msg string, keyvals ...any) { if l.shouldLog(LevelError) { l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...) } @@ -294,49 +294,101 @@ func Username() string { // --- Default logger --- -var defaultLogger = NewLogger(Options{Level: LevelInfo}) +var defaultLog = NewLog(LogOpts{Level: LevelInfo}) // Default returns the default logger. -func Default() *Logger { - return defaultLogger +func Default() *Log { + return defaultLog } // SetDefault sets the default logger. -func SetDefault(l *Logger) { - defaultLogger = l +func SetDefault(l *Log) { + defaultLog = l } // SetLevel sets the default logger's level. func SetLevel(level Level) { - defaultLogger.SetLevel(level) + defaultLog.SetLevel(level) } // SetRedactKeys sets the default logger's redaction keys. func SetRedactKeys(keys ...string) { - defaultLogger.SetRedactKeys(keys...) + defaultLog.SetRedactKeys(keys...) } // Debug logs to the default logger. func Debug(msg string, keyvals ...any) { - defaultLogger.Debug(msg, keyvals...) + defaultLog.Debug(msg, keyvals...) } // Info logs to the default logger. func Info(msg string, keyvals ...any) { - defaultLogger.Info(msg, keyvals...) + defaultLog.Info(msg, keyvals...) } // Warn logs to the default logger. func Warn(msg string, keyvals ...any) { - defaultLogger.Warn(msg, keyvals...) + defaultLog.Warn(msg, keyvals...) } // Error logs to the default logger. func Error(msg string, keyvals ...any) { - defaultLogger.Error(msg, keyvals...) + defaultLog.Error(msg, keyvals...) } // Security logs to the default logger. func Security(msg string, keyvals ...any) { - defaultLogger.Security(msg, keyvals...) + defaultLog.Security(msg, keyvals...) +} + +// --- LogErr: Error-Aware Logger --- + +// LogErr logs structured information extracted from errors. +// Primary action: log. Secondary: extract error context. +type LogErr struct { + log *Log +} + +// NewLogErr creates a LogErr bound to the given logger. +func NewLogErr(log *Log) *LogErr { + return &LogErr{log: log} +} + +// Log extracts context from an Err and logs it at Error level. +func (le *LogErr) Log(err error) { + if err == nil { + return + } + le.log.Error(ErrorMessage(err), "op", Op(err), "code", ErrCode(err), "stack", FormatStackTrace(err)) +} + +// --- LogPan: Panic-Aware Logger --- + +// LogPan logs panic context without crash file management. +// Primary action: log. Secondary: recover panics. +type LogPan struct { + log *Log +} + +// NewLogPan creates a LogPan bound to the given logger. +func NewLogPan(log *Log) *LogPan { + return &LogPan{log: log} +} + +// Recover captures a panic and logs it. Does not write crash files. +// Use as: defer core.NewLogPan(logger).Recover() +func (lp *LogPan) Recover() { + r := recover() + if r == nil { + return + } + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + lp.log.Error("panic recovered", + "err", err, + "op", Op(err), + "stack", FormatStackTrace(err), + ) } diff --git a/pkg/core/message_bus_test.go b/pkg/core/message_bus_test.go deleted file mode 100644 index 493c265..0000000 --- a/pkg/core/message_bus_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package core - -import ( - "errors" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMessageBus_Action_Good(t *testing.T) { - c, _ := New() - - var received []Message - c.bus.registerAction(func(_ *Core, msg Message) error { - received = append(received, msg) - return nil - }) - c.bus.registerAction(func(_ *Core, msg Message) error { - received = append(received, msg) - return nil - }) - - err := c.bus.action("hello") - assert.NoError(t, err) - assert.Len(t, received, 2) -} - -func TestMessageBus_Action_Bad(t *testing.T) { - c, _ := New() - - err1 := errors.New("handler1 failed") - err2 := errors.New("handler2 failed") - - c.bus.registerAction(func(_ *Core, msg Message) error { return err1 }) - c.bus.registerAction(func(_ *Core, msg Message) error { return nil }) - c.bus.registerAction(func(_ *Core, msg Message) error { return err2 }) - - err := c.bus.action("test") - assert.Error(t, err) - assert.ErrorIs(t, err, err1) - assert.ErrorIs(t, err, err2) -} - -func TestMessageBus_RegisterAction_Good(t *testing.T) { - c, _ := New() - - var coreRef *Core - c.bus.registerAction(func(core *Core, msg Message) error { - coreRef = core - return nil - }) - - _ = c.bus.action(nil) - assert.Same(t, c, coreRef, "handler should receive the Core reference") -} - -func TestMessageBus_Query_Good(t *testing.T) { - c, _ := New() - - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return "first", true, nil - }) - - result, handled, err := c.bus.query(TestQuery{Value: "test"}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "first", result) -} - -func TestMessageBus_QueryAll_Good(t *testing.T) { - c, _ := New() - - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return "a", true, nil - }) - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return nil, false, nil // skips - }) - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return "b", true, nil - }) - - results, err := c.bus.queryAll(TestQuery{}) - assert.NoError(t, err) - assert.Equal(t, []any{"a", "b"}, results) -} - -func TestMessageBus_Perform_Good(t *testing.T) { - c, _ := New() - - c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) { - return "done", true, nil - }) - - result, handled, err := c.bus.perform(TestTask{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "done", result) -} - -func TestMessageBus_ConcurrentAccess_Good(t *testing.T) { - c, _ := New() - - var wg sync.WaitGroup - const goroutines = 20 - - // Concurrent register + dispatch - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.bus.registerAction(func(_ *Core, msg Message) error { return nil }) - }() - go func() { - defer wg.Done() - _ = c.bus.action("ping") - }() - } - - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil }) - }() - go func() { - defer wg.Done() - _, _ = c.bus.queryAll(TestQuery{}) - }() - } - - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil }) - }() - go func() { - defer wg.Done() - _, _, _ = c.bus.perform(TestTask{}) - }() - } - - wg.Wait() -} - -func TestMessageBus_Action_NoHandlers(t *testing.T) { - c, _ := New() - // Should not error if no handlers are registered - err := c.bus.action("no one listening") - assert.NoError(t, err) -} - -func TestMessageBus_Query_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.bus.query(TestQuery{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestMessageBus_QueryAll_NoHandlers(t *testing.T) { - c, _ := New() - results, err := c.bus.queryAll(TestQuery{}) - assert.NoError(t, err) - assert.Empty(t, results) -} - -func TestMessageBus_Perform_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.bus.perform(TestTask{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} diff --git a/pkg/core/mnt.go b/pkg/core/mnt.go deleted file mode 100644 index 3f60749..0000000 --- a/pkg/core/mnt.go +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -// Mount operations for the Core framework. -// -// Sub provides scoped filesystem access that works with: -// - go:embed (embed.FS) -// - any fs.FS implementation -// - the Core asset registry (AddAsset/GetAsset from embed.go) -// -// Usage: -// -// sub, _ := core.Mount(myFS, "lib/persona") -// content, _ := sub.ReadString("secops/developer.md") -// sub.Extract("/tmp/workspace", data) -package core - -import ( - "embed" - "io/fs" - "path/filepath" -) - -// Sub wraps an fs.FS with a basedir for scoped access. -// All paths are relative to basedir. -type Sub struct { - basedir string - fsys fs.FS - embedFS *embed.FS // kept for Embed() backwards compat -} - -// Mount creates a scoped view of an fs.FS anchored at basedir. -// Works with embed.FS, os.DirFS, or any fs.FS implementation. -func Mount(fsys fs.FS, basedir string) (*Sub, error) { - s := &Sub{fsys: fsys, basedir: basedir} - - // If it's an embed.FS, keep a reference for Embed() - if efs, ok := fsys.(embed.FS); ok { - s.embedFS = &efs - } - - // Verify the basedir exists - if _, err := s.ReadDir("."); err != nil { - return nil, err - } - return s, nil -} - -// MountEmbed creates a scoped view of an embed.FS. -// Convenience wrapper that preserves the embed.FS type for Embed(). -func MountEmbed(efs embed.FS, basedir string) (*Sub, error) { - return Mount(efs, basedir) -} - -func (s *Sub) path(name string) string { - return filepath.ToSlash(filepath.Join(s.basedir, name)) -} - -// Open opens the named file for reading. -func (s *Sub) Open(name string) (fs.File, error) { - return s.fsys.Open(s.path(name)) -} - -// ReadDir reads the named directory. -func (s *Sub) ReadDir(name string) ([]fs.DirEntry, error) { - return fs.ReadDir(s.fsys, s.path(name)) -} - -// ReadFile reads the named file. -func (s *Sub) ReadFile(name string) ([]byte, error) { - return fs.ReadFile(s.fsys, s.path(name)) -} - -// ReadString reads the named file as a string. -func (s *Sub) ReadString(name string) (string, error) { - data, err := s.ReadFile(name) - if err != nil { - return "", err - } - return string(data), nil -} - -// Sub returns a new Sub anchored at a subdirectory within this Sub. -func (s *Sub) Sub(subDir string) (*Sub, error) { - sub, err := fs.Sub(s.fsys, s.path(subDir)) - if err != nil { - return nil, err - } - return &Sub{fsys: sub, basedir: "."}, nil -} - -// FS returns the underlying fs.FS. -func (s *Sub) FS() fs.FS { - return s.fsys -} - -// Embed returns the underlying embed.FS if mounted from one. -// Returns zero embed.FS if mounted from a non-embed source. -func (s *Sub) Embed() embed.FS { - if s.embedFS != nil { - return *s.embedFS - } - return embed.FS{} -} - -// BaseDir returns the basedir this Sub is anchored at. -func (s *Sub) BaseDir() string { - return s.basedir -} diff --git a/pkg/core/mnt_extract.go b/pkg/core/mnt_extract.go deleted file mode 100644 index e882bb4..0000000 --- a/pkg/core/mnt_extract.go +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -package core - -import ( - "bytes" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - "text/template" -) - -// ExtractOptions configures template extraction. -type ExtractOptions struct { - // TemplateFilters identifies template files by substring match. - // Default: [".tmpl"] - TemplateFilters []string - - // IgnoreFiles is a set of filenames to skip during extraction. - IgnoreFiles map[string]struct{} - - // RenameFiles maps original filenames to new names. - RenameFiles map[string]string -} - -// Extract copies a template directory from an fs.FS to targetDir, -// processing Go text/template in filenames and file contents. -// -// Files containing a template filter substring (default: ".tmpl") have -// their contents processed through text/template with the given data. -// The filter is stripped from the output filename. -// -// Directory and file names can contain Go template expressions: -// {{.Name}}/main.go → myproject/main.go -// -// Data can be any struct or map[string]string for template substitution. -func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error { - opt := ExtractOptions{ - TemplateFilters: []string{".tmpl"}, - IgnoreFiles: make(map[string]struct{}), - RenameFiles: make(map[string]string), - } - if len(opts) > 0 { - if len(opts[0].TemplateFilters) > 0 { - opt.TemplateFilters = opts[0].TemplateFilters - } - if opts[0].IgnoreFiles != nil { - opt.IgnoreFiles = opts[0].IgnoreFiles - } - if opts[0].RenameFiles != nil { - opt.RenameFiles = opts[0].RenameFiles - } - } - - // Ensure target directory exists - targetDir, err := filepath.Abs(targetDir) - if err != nil { - return err - } - if err := os.MkdirAll(targetDir, 0755); err != nil { - return err - } - - // Categorise files - var dirs []string - var templateFiles []string - var standardFiles []string - - err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == "." { - return nil - } - if d.IsDir() { - dirs = append(dirs, path) - return nil - } - filename := filepath.Base(path) - if _, ignored := opt.IgnoreFiles[filename]; ignored { - return nil - } - if isTemplate(filename, opt.TemplateFilters) { - templateFiles = append(templateFiles, path) - } else { - standardFiles = append(standardFiles, path) - } - return nil - }) - if err != nil { - return err - } - - // Create directories (names may contain templates) - for _, dir := range dirs { - target := renderPath(filepath.Join(targetDir, dir), data) - if err := os.MkdirAll(target, 0755); err != nil { - return err - } - } - - // Process template files - for _, path := range templateFiles { - tmpl, err := template.ParseFS(fsys, path) - if err != nil { - return err - } - - targetFile := renderPath(filepath.Join(targetDir, path), data) - - // Strip template filters from filename - dir := filepath.Dir(targetFile) - name := filepath.Base(targetFile) - for _, filter := range opt.TemplateFilters { - name = strings.ReplaceAll(name, filter, "") - } - if renamed := opt.RenameFiles[name]; renamed != "" { - name = renamed - } - targetFile = filepath.Join(dir, name) - - f, err := os.Create(targetFile) - if err != nil { - return err - } - if err := tmpl.Execute(f, data); err != nil { - f.Close() - return err - } - f.Close() - } - - // Copy standard files - for _, path := range standardFiles { - targetPath := path - name := filepath.Base(path) - if renamed := opt.RenameFiles[name]; renamed != "" { - targetPath = filepath.Join(filepath.Dir(path), renamed) - } - target := renderPath(filepath.Join(targetDir, targetPath), data) - if err := copyFile(fsys, path, target); err != nil { - return err - } - } - - return nil -} - -func isTemplate(filename string, filters []string) bool { - for _, f := range filters { - if strings.Contains(filename, f) { - return true - } - } - return false -} - -func renderPath(path string, data any) string { - if data == nil { - return path - } - tmpl, err := template.New("path").Parse(path) - if err != nil { - return path - } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return path - } - return buf.String() -} - -func copyFile(fsys fs.FS, source, target string) error { - s, err := fsys.Open(source) - if err != nil { - return err - } - defer s.Close() - - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - - d, err := os.Create(target) - if err != nil { - return err - } - defer d.Close() - - _, err = io.Copy(d, s) - return err -} diff --git a/pkg/core/runtime.go b/pkg/core/runtime.go new file mode 100644 index 0000000..edfa068 --- /dev/null +++ b/pkg/core/runtime.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Runtime helpers for the Core framework. +// ServiceRuntime is embedded by consumer services. +// Runtime is the GUI binding container (e.g., Wails). + +package core + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" +) + +// --- ServiceRuntime (embedded by consumer services) --- + +// ServiceRuntime is embedded in services to provide access to the Core and typed options. +type ServiceRuntime[T any] struct { + core *Core + opts T +} + +// NewServiceRuntime creates a ServiceRuntime for a service constructor. +func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] { + return &ServiceRuntime[T]{core: c, opts: opts} +} + +func (r *ServiceRuntime[T]) Core() *Core { return r.core } +func (r *ServiceRuntime[T]) Opts() T { return r.opts } +func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() } + +// --- Lifecycle --- + +// ServiceStartup runs the startup lifecycle for all registered services. +func (c *Core) ServiceStartup(ctx context.Context, options any) error { + startables := c.Startables() + var agg error + for _, s := range startables { + if err := ctx.Err(); err != nil { + return errors.Join(agg, err) + } + if err := s.OnStartup(ctx); err != nil { + agg = errors.Join(agg, err) + } + } + if err := c.ACTION(ActionServiceStartup{}); err != nil { + agg = errors.Join(agg, err) + } + return agg +} + +// ServiceShutdown runs the shutdown lifecycle for all registered services. +func (c *Core) ServiceShutdown(ctx context.Context) error { + c.shutdown.Store(true) + var agg error + if err := c.ACTION(ActionServiceShutdown{}); err != nil { + agg = errors.Join(agg, err) + } + stoppables := c.Stoppables() + for _, s := range slices.Backward(stoppables) { + if err := ctx.Err(); err != nil { + agg = errors.Join(agg, err) + break + } + if err := s.OnShutdown(ctx); err != nil { + agg = errors.Join(agg, err) + } + } + done := make(chan struct{}) + go func() { + c.wg.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + agg = errors.Join(agg, ctx.Err()) + } + return agg +} + +// --- Runtime DTO (GUI binding) --- + +// Runtime is the container for GUI runtimes (e.g., Wails). +type Runtime struct { + app any + Core *Core +} + +// ServiceFactory defines a function that creates a service instance. +type ServiceFactory func() (any, error) + +// NewWithFactories creates a Runtime with the provided service factories. +func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, error) { + coreOpts := []Option{WithApp(app)} + names := slices.Sorted(maps.Keys(factories)) + for _, name := range names { + factory := factories[name] + if factory == nil { + return nil, E("core.NewWithFactories", fmt.Sprintf("factory is nil for service %q", name), nil) + } + svc, err := factory() + if err != nil { + return nil, E("core.NewWithFactories", fmt.Sprintf("failed to create service %q", name), err) + } + svcCopy := svc + coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil })) + } + coreInstance, err := New(coreOpts...) + if err != nil { + return nil, err + } + return &Runtime{app: app, Core: coreInstance}, nil +} + +// NewRuntime creates a Runtime with no custom services. +func NewRuntime(app any) (*Runtime, error) { + return NewWithFactories(app, map[string]ServiceFactory{}) +} + +func (r *Runtime) ServiceName() string { return "Core" } +func (r *Runtime) ServiceStartup(ctx context.Context, options any) error { + return r.Core.ServiceStartup(ctx, options) +} +func (r *Runtime) ServiceShutdown(ctx context.Context) error { + if r.Core != nil { + return r.Core.ServiceShutdown(ctx) + } + return nil +} diff --git a/pkg/core/runtime_pkg.go b/pkg/core/runtime_pkg.go deleted file mode 100644 index 0c78556..0000000 --- a/pkg/core/runtime_pkg.go +++ /dev/null @@ -1,113 +0,0 @@ -package core - -import ( - "context" - "fmt" - "maps" - "slices" -) - -// ServiceRuntime is a helper struct embedded in services to provide access to the core application. -// It is generic and can be parameterized with a service-specific options struct. -type ServiceRuntime[T any] struct { - core *Core - opts T -} - -// NewServiceRuntime creates a new ServiceRuntime instance for a service. -// This is typically called by a service's constructor. -func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] { - return &ServiceRuntime[T]{ - core: c, - opts: opts, - } -} - -// Core returns the central core instance, providing access to all registered services. -func (r *ServiceRuntime[T]) Core() *Core { - return r.core -} - -// Opts returns the service-specific options. -func (r *ServiceRuntime[T]) Opts() T { - return r.opts -} - -// Config returns the registered Config service from the core application. -// This is a convenience method for accessing the application's configuration. -func (r *ServiceRuntime[T]) Config() Config { - return r.core.Config() -} - -// Runtime is the container that holds all instantiated services. -// Its fields are the concrete types, allowing GUI runtimes to bind them directly. -// This struct is the primary entry point for the application. -type Runtime struct { - app any // GUI runtime (e.g., Wails App) - Core *Core -} - -// ServiceFactory defines a function that creates a service instance. -// This is used to decouple the service creation from the runtime initialization. -type ServiceFactory func() (any, error) - -// NewWithFactories creates a new Runtime instance using the provided service factories. -// This is the most flexible way to create a new Runtime, as it allows for -// the registration of any number of services. -func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, error) { - coreOpts := []Option{ - WithApp(app), - } - - names := slices.Sorted(maps.Keys(factories)) - - for _, name := range names { - factory := factories[name] - if factory == nil { - return nil, E("core.NewWithFactories", fmt.Sprintf("factory is nil for service %q", name), nil) - } - svc, err := factory() - if err != nil { - return nil, E("core.NewWithFactories", fmt.Sprintf("failed to create service %q", name), err) - } - svcCopy := svc - coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil })) - } - - coreInstance, err := New(coreOpts...) - if err != nil { - return nil, err - } - - return &Runtime{ - app: app, - Core: coreInstance, - }, nil -} - -// NewRuntime creates and wires together all application services. -// This is the simplest way to create a new Runtime, but it does not allow for -// the registration of any custom services. -func NewRuntime(app any) (*Runtime, error) { - return NewWithFactories(app, map[string]ServiceFactory{}) -} - -// ServiceName returns the name of the service. This is used by GUI runtimes to identify the service. -func (r *Runtime) ServiceName() string { - return "Core" -} - -// ServiceStartup is called by the GUI runtime at application startup. -// This is where the Core's startup lifecycle is initiated. -func (r *Runtime) ServiceStartup(ctx context.Context, options any) error { - return r.Core.ServiceStartup(ctx, options) -} - -// ServiceShutdown is called by the GUI runtime at application shutdown. -// This is where the Core's shutdown lifecycle is initiated. -func (r *Runtime) ServiceShutdown(ctx context.Context) error { - if r.Core != nil { - return r.Core.ServiceShutdown(ctx) - } - return nil -} diff --git a/pkg/core/service.go b/pkg/core/service.go new file mode 100644 index 0000000..3479a7a --- /dev/null +++ b/pkg/core/service.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Service registry, lifecycle tracking, and runtime helpers for the Core framework. + +package core + +import "fmt" + +// --- Service Registry DTO --- + +// Service holds service registry data. +type Service struct { + Services map[string]any + startables []Startable + stoppables []Stoppable + lockEnabled bool + locked bool +} + +// NewSrv creates an empty service registry. +func NewService() *Service { + return &Service{ + Services: make(map[string]any), + } +} + +// --- Core service methods --- + +// Service gets or registers a service. +// +// c.Service() // returns *Service +// c.Service("auth") // returns the "auth" service +// c.Service("auth", myService) // registers "auth" +func (c *Core) Service(args ...any) any { + switch len(args) { + case 0: + return c.srv + case 1: + name, _ := args[0].(string) + c.Lock("srv").Mu.RLock() + v, ok := c.srv.Services[name] + c.Lock("srv").Mu.RUnlock() + if !ok { + return nil + } + return v + default: + name, _ := args[0].(string) + if name == "" { + return E("core.Service", "service name cannot be empty", nil) + } + c.Lock("srv").Mu.Lock() + defer c.Lock("srv").Mu.Unlock() + if c.srv.locked { + return E("core.Service", fmt.Sprintf("service %q is not permitted by the serviceLock setting", name), nil) + } + if _, exists := c.srv.Services[name]; exists { + return E("core.Service", fmt.Sprintf("service %q already registered", name), nil) + } + svc := args[1] + c.srv.Services[name] = svc + if st, ok := svc.(Startable); ok { + c.srv.startables = append(c.srv.startables, st) + } + if st, ok := svc.(Stoppable); ok { + c.srv.stoppables = append(c.srv.stoppables, st) + } + if lp, ok := svc.(LocaleProvider); ok { + c.i18n.AddLocales(lp.Locales()) + } + return nil + } +} + diff --git a/pkg/core/service_manager.go b/pkg/core/service_manager.go deleted file mode 100644 index 95fe85f..0000000 --- a/pkg/core/service_manager.go +++ /dev/null @@ -1,96 +0,0 @@ -package core - -import ( - "errors" - "fmt" - "slices" - "sync" -) - -// serviceManager owns the service registry and lifecycle tracking. -// It is an unexported component used internally by Core. -type serviceManager struct { - mu sync.RWMutex - services map[string]any - startables []Startable - stoppables []Stoppable - lockEnabled bool // WithServiceLock was called - locked bool // lock applied after New() completes -} - -// newServiceManager creates an empty service manager. -func newServiceManager() *serviceManager { - return &serviceManager{ - services: make(map[string]any), - } -} - -// registerService adds a named service to the registry. -// It also appends to startables/stoppables if the service implements those interfaces. -func (m *serviceManager) registerService(name string, svc any) error { - if name == "" { - return errors.New("core: service name cannot be empty") - } - m.mu.Lock() - defer m.mu.Unlock() - if m.locked { - return E("core.RegisterService", fmt.Sprintf("service %q is not permitted by the serviceLock setting", name), nil) - } - if _, exists := m.services[name]; exists { - return E("core.RegisterService", fmt.Sprintf("service %q already registered", name), nil) - } - m.services[name] = svc - - if s, ok := svc.(Startable); ok { - m.startables = append(m.startables, s) - } - if s, ok := svc.(Stoppable); ok { - m.stoppables = append(m.stoppables, s) - } - - return nil -} - -// service retrieves a registered service by name, or nil if not found. -func (m *serviceManager) service(name string) any { - m.mu.RLock() - svc, ok := m.services[name] - m.mu.RUnlock() - if !ok { - return nil - } - return svc -} - -// enableLock marks that the lock should be applied after initialisation. -func (m *serviceManager) enableLock() { - m.mu.Lock() - defer m.mu.Unlock() - m.lockEnabled = true -} - -// applyLock activates the service lock if it was enabled. -// Called once during New() after all options have been processed. -func (m *serviceManager) applyLock() { - m.mu.Lock() - defer m.mu.Unlock() - if m.lockEnabled { - m.locked = true - } -} - -// getStartables returns a snapshot copy of the startables slice. -func (m *serviceManager) getStartables() []Startable { - m.mu.RLock() - out := slices.Clone(m.startables) - m.mu.RUnlock() - return out -} - -// getStoppables returns a snapshot copy of the stoppables slice. -func (m *serviceManager) getStoppables() []Stoppable { - m.mu.RLock() - out := slices.Clone(m.stoppables) - m.mu.RUnlock() - return out -} diff --git a/pkg/core/service_manager_test.go b/pkg/core/service_manager_test.go deleted file mode 100644 index fe408c4..0000000 --- a/pkg/core/service_manager_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package core - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestServiceManager_RegisterService_Good(t *testing.T) { - m := newServiceManager() - - err := m.registerService("svc1", &MockService{Name: "one"}) - assert.NoError(t, err) - - got := m.service("svc1") - assert.NotNil(t, got) - assert.Equal(t, "one", got.(*MockService).GetName()) -} - -func TestServiceManager_RegisterService_Bad(t *testing.T) { - m := newServiceManager() - - // Empty name - err := m.registerService("", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot be empty") - - // Duplicate - err = m.registerService("dup", &MockService{}) - assert.NoError(t, err) - err = m.registerService("dup", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already registered") - - // Locked - m2 := newServiceManager() - m2.enableLock() - m2.applyLock() - err = m2.registerService("late", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "serviceLock") -} - -func TestServiceManager_ServiceNotFound_Good(t *testing.T) { - m := newServiceManager() - assert.Nil(t, m.service("nonexistent")) -} - -func TestServiceManager_Startables_Good(t *testing.T) { - m := newServiceManager() - - s1 := &MockStartable{} - s2 := &MockStartable{} - - _ = m.registerService("s1", s1) - _ = m.registerService("s2", s2) - - startables := m.getStartables() - assert.Len(t, startables, 2) - - // Verify order matches registration order - assert.Same(t, s1, startables[0]) - assert.Same(t, s2, startables[1]) - - // Verify it's a copy — mutating the slice doesn't affect internal state - startables[0] = nil - assert.Len(t, m.getStartables(), 2) - assert.NotNil(t, m.getStartables()[0]) -} - -func TestServiceManager_Stoppables_Good(t *testing.T) { - m := newServiceManager() - - s1 := &MockStoppable{} - s2 := &MockStoppable{} - - _ = m.registerService("s1", s1) - _ = m.registerService("s2", s2) - - stoppables := m.getStoppables() - assert.Len(t, stoppables, 2) - - // Stoppables are returned in registration order; Core.ServiceShutdown reverses them - assert.Same(t, s1, stoppables[0]) - assert.Same(t, s2, stoppables[1]) -} - -func TestServiceManager_Lock_Good(t *testing.T) { - m := newServiceManager() - - // Register before lock — should succeed - err := m.registerService("early", &MockService{}) - assert.NoError(t, err) - - // Enable and apply lock - m.enableLock() - m.applyLock() - - // Register after lock — should fail - err = m.registerService("late", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "serviceLock") - - // Early service is still accessible - assert.NotNil(t, m.service("early")) -} - -func TestServiceManager_LockNotAppliedWithoutEnable_Good(t *testing.T) { - m := newServiceManager() - m.applyLock() // applyLock without enableLock should be a no-op - - err := m.registerService("svc", &MockService{}) - assert.NoError(t, err) -} - -type mockFullLifecycle struct{} - -func (m *mockFullLifecycle) OnStartup(_ context.Context) error { return nil } -func (m *mockFullLifecycle) OnShutdown(_ context.Context) error { return nil } - -func TestServiceManager_LifecycleBoth_Good(t *testing.T) { - m := newServiceManager() - - svc := &mockFullLifecycle{} - err := m.registerService("both", svc) - assert.NoError(t, err) - - // Should appear in both startables and stoppables - assert.Len(t, m.getStartables(), 1) - assert.Len(t, m.getStoppables(), 1) -} diff --git a/pkg/core/task.go b/pkg/core/task.go new file mode 100644 index 0000000..542ec7e --- /dev/null +++ b/pkg/core/task.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Background task dispatch for the Core framework. + +package core + +import "fmt" + +// TaskState holds background task state. +type TaskState struct { + ID string + Task Task + Result any + Error error +} + +// PerformAsync dispatches a task in a background goroutine. +func (c *Core) PerformAsync(t Task) string { + if c.shutdown.Load() { + return "" + } + taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1)) + if tid, ok := t.(TaskWithID); ok { + tid.SetTaskID(taskID) + } + _ = c.ACTION(ActionTaskStarted{TaskID: taskID, Task: t}) + c.wg.Go(func() { + result, handled, err := c.PERFORM(t) + if !handled && err == nil { + err = E("core.PerformAsync", fmt.Sprintf("no handler found for task type %T", t), nil) + } + _ = c.ACTION(ActionTaskCompleted{TaskID: taskID, Task: t, Result: result, Error: err}) + }) + return taskID +} + +// Progress broadcasts a progress update for a background task. +func (c *Core) Progress(taskID string, progress float64, message string, t Task) { + _ = c.ACTION(ActionTaskProgress{TaskID: taskID, Task: t, Progress: progress, Message: message}) +} diff --git a/pkg/core/async_test.go b/tests/async_test.go similarity index 90% rename from pkg/core/async_test.go rename to tests/async_test.go index f29ff9e..d9b589f 100644 --- a/pkg/core/async_test.go +++ b/tests/async_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "errors" "sync/atomic" @@ -13,10 +14,10 @@ import ( func TestCore_PerformAsync_Good(t *testing.T) { c, _ := New() - + var completed atomic.Bool var resultReceived any - + c.RegisterAction(func(c *Core, msg Message) error { if tc, ok := msg.(ActionTaskCompleted); ok { resultReceived = tc.Result @@ -24,36 +25,36 @@ func TestCore_PerformAsync_Good(t *testing.T) { } return nil }) - + c.RegisterTask(func(c *Core, task Task) (any, bool, error) { return "async-result", true, nil }) - + taskID := c.PerformAsync(TestTask{}) assert.NotEmpty(t, taskID) - + // Wait for completion assert.Eventually(t, func() bool { return completed.Load() }, 1*time.Second, 10*time.Millisecond) - + assert.Equal(t, "async-result", resultReceived) } func TestCore_PerformAsync_Shutdown(t *testing.T) { c, _ := New() _ = c.ServiceShutdown(context.Background()) - + taskID := c.PerformAsync(TestTask{}) assert.Empty(t, taskID, "PerformAsync should return empty string if already shut down") } func TestCore_Progress_Good(t *testing.T) { c, _ := New() - + var progressReceived float64 var messageReceived string - + c.RegisterAction(func(c *Core, msg Message) error { if tp, ok := msg.(ActionTaskProgress); ok { progressReceived = tp.Progress @@ -61,9 +62,9 @@ func TestCore_Progress_Good(t *testing.T) { } return nil }) - + c.Progress("task-1", 0.5, "halfway", TestTask{}) - + assert.Equal(t, 0.5, progressReceived) assert.Equal(t, "halfway", messageReceived) } @@ -74,7 +75,7 @@ func TestCore_WithService_UnnamedType(t *testing.T) { s := "primitive" return &s, nil } - + _, err := New(WithService(factory)) require.Error(t, err) assert.Contains(t, err.Error(), "service name could not be discovered") @@ -82,11 +83,11 @@ func TestCore_WithService_UnnamedType(t *testing.T) { func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) { rt, _ := NewRuntime(nil) - + // Register a service that fails startup errSvc := &MockStartable{err: errors.New("startup failed")} _ = rt.Core.RegisterService("error-svc", errSvc) - + err := rt.ServiceStartup(context.Background(), nil) assert.Error(t, err) assert.Contains(t, err.Error(), "startup failed") @@ -94,46 +95,47 @@ func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) { func TestCore_ServiceStartup_ContextCancellation(t *testing.T) { c, _ := New() - + ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately - + s1 := &MockStartable{} _ = c.RegisterService("s1", s1) - + err := c.ServiceStartup(ctx, nil) assert.Error(t, err) assert.ErrorIs(t, err, context.Canceled) - assert.False(t, s1.started, "Service should not have started if context was cancelled before loop") + assert.False(t, s1.started, "Srv should not have started if context was cancelled before loop") } func TestCore_ServiceShutdown_ContextCancellation(t *testing.T) { c, _ := New() - + ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately - + s1 := &MockStoppable{} _ = c.RegisterService("s1", s1) - + err := c.ServiceShutdown(ctx) assert.Error(t, err) assert.ErrorIs(t, err, context.Canceled) - assert.False(t, s1.stopped, "Service should not have stopped if context was cancelled before loop") + assert.False(t, s1.stopped, "Srv should not have stopped if context was cancelled before loop") } type TaskWithIDImpl struct { id string } + func (t *TaskWithIDImpl) SetTaskID(id string) { t.id = id } -func (t *TaskWithIDImpl) GetTaskID() string { return t.id } +func (t *TaskWithIDImpl) GetTaskID() string { return t.id } func TestCore_PerformAsync_InjectsID(t *testing.T) { c, _ := New() c.RegisterTask(func(c *Core, t Task) (any, bool, error) { return nil, true, nil }) - + task := &TaskWithIDImpl{} taskID := c.PerformAsync(task) - + assert.Equal(t, taskID, task.GetTaskID()) } diff --git a/pkg/core/bench_test.go b/tests/bench_test.go similarity index 92% rename from pkg/core/bench_test.go rename to tests/bench_test.go index 2337c6e..a59aa82 100644 --- a/pkg/core/bench_test.go +++ b/tests/bench_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "testing" ) diff --git a/pkg/core/core_extra_test.go b/tests/core_extra_test.go similarity index 94% rename from pkg/core/core_extra_test.go rename to tests/core_extra_test.go index 38da57f..408476e 100644 --- a/pkg/core/core_extra_test.go +++ b/tests/core_extra_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "testing" "github.com/stretchr/testify/assert" diff --git a/pkg/core/core_lifecycle_test.go b/tests/core_lifecycle_test.go similarity index 98% rename from pkg/core/core_lifecycle_test.go rename to tests/core_lifecycle_test.go index 6b1a302..6f2fadf 100644 --- a/pkg/core/core_lifecycle_test.go +++ b/tests/core_lifecycle_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "errors" "testing" diff --git a/pkg/core/core_test.go b/tests/core_test.go similarity index 82% rename from pkg/core/core_test.go rename to tests/core_test.go index 3532101..2966089 100644 --- a/pkg/core/core_test.go +++ b/tests/core_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "embed" "io" @@ -33,7 +34,7 @@ func TestCore_WithService_Good(t *testing.T) { } c, err := New(WithService(factory)) assert.NoError(t, err) - svc := c.Service("core") + svc := c.Service().Get("core") assert.NotNil(t, svc) mockSvc, ok := svc.(*MockService) assert.True(t, ok) @@ -54,10 +55,6 @@ type MockConfigService struct{} func (m *MockConfigService) Get(key string, out any) error { return nil } func (m *MockConfigService) Set(key string, v any) error { return nil } -type MockDisplayService struct{} - -func (m *MockDisplayService) OpenWindow(opts ...WindowOption) error { return nil } - func TestCore_Services_Good(t *testing.T) { c, err := New() assert.NoError(t, err) @@ -65,29 +62,12 @@ func TestCore_Services_Good(t *testing.T) { err = c.RegisterService("config", &MockConfigService{}) assert.NoError(t, err) - err = c.RegisterService("display", &MockDisplayService{}) - assert.NoError(t, err) + svc := c.Service("config") + assert.NotNil(t, svc) + // Cfg() returns Cfg (always available, not a service) cfg := c.Config() assert.NotNil(t, cfg) - - d := c.Display() - assert.NotNil(t, d) -} - -func TestCore_Services_Ugly(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // Config panics when service not registered - assert.Panics(t, func() { - c.Config() - }) - - // Display panics when service not registered - assert.Panics(t, func() { - c.Display() - }) } func TestCore_App_Good(t *testing.T) { @@ -95,21 +75,21 @@ func TestCore_App_Good(t *testing.T) { c, err := New(WithApp(app)) assert.NoError(t, err) - // To test the global App() function, we need to set the global instance. + // To test the global CoreGUI() function, we need to set the global instance. originalInstance := GetInstance() SetInstance(c) defer SetInstance(originalInstance) - assert.Equal(t, app, App()) + assert.Equal(t, app, CoreGUI()) } func TestCore_App_Ugly(t *testing.T) { - // This test ensures that calling App() before the core is initialized panics. + // This test ensures that calling CoreGUI() before the core is initialized panics. originalInstance := GetInstance() ClearInstance() defer SetInstance(originalInstance) assert.Panics(t, func() { - App() + CoreGUI() }) } @@ -123,33 +103,33 @@ func TestEtc_Features_Good(t *testing.T) { c, err := New() assert.NoError(t, err) - c.Etc().Enable("feature1") - c.Etc().Enable("feature2") + c.Config().Enable("feature1") + c.Config().Enable("feature2") - assert.True(t, c.Etc().Enabled("feature1")) - assert.True(t, c.Etc().Enabled("feature2")) - assert.False(t, c.Etc().Enabled("feature3")) - assert.False(t, c.Etc().Enabled("")) + assert.True(t, c.Config().Enabled("feature1")) + assert.True(t, c.Config().Enabled("feature2")) + assert.False(t, c.Config().Enabled("feature3")) + assert.False(t, c.Config().Enabled("")) } func TestEtc_Settings_Good(t *testing.T) { c, _ := New() - c.Etc().Set("api_url", "https://api.lthn.sh") - c.Etc().Set("max_agents", 5) + c.Config().Set("api_url", "https://api.lthn.sh") + c.Config().Set("max_agents", 5) - assert.Equal(t, "https://api.lthn.sh", c.Etc().GetString("api_url")) - assert.Equal(t, 5, c.Etc().GetInt("max_agents")) - assert.Equal(t, "", c.Etc().GetString("missing")) + assert.Equal(t, "https://api.lthn.sh", c.Config().GetString("api_url")) + assert.Equal(t, 5, c.Config().GetInt("max_agents")) + assert.Equal(t, "", c.Config().GetString("missing")) } func TestEtc_Features_Edge(t *testing.T) { c, _ := New() - c.Etc().Enable("foo") - assert.True(t, c.Etc().Enabled("foo")) - assert.False(t, c.Etc().Enabled("FOO")) // Case sensitive + c.Config().Enable("foo") + assert.True(t, c.Config().Enabled("foo")) + assert.False(t, c.Config().Enabled("FOO")) // Case sensitive - c.Etc().Disable("foo") - assert.False(t, c.Etc().Enabled("foo")) + c.Config().Disable("foo") + assert.False(t, c.Config().Enabled("foo")) } func TestCore_ServiceLifecycle_Good(t *testing.T) { @@ -178,7 +158,7 @@ func TestCore_WithApp_Good(t *testing.T) { app := &mockApp{} c, err := New(WithApp(app)) assert.NoError(t, err) - assert.Equal(t, app, c.App) + assert.Equal(t, app, c.App().Runtime) } //go:embed testdata @@ -187,7 +167,7 @@ var testFS embed.FS func TestCore_WithAssets_Good(t *testing.T) { c, err := New(WithAssets(testFS)) assert.NoError(t, err) - file, err := c.Mnt().Open("testdata/test.txt") + file, err := c.Embed().Open("testdata/test.txt") assert.NoError(t, err) defer func() { _ = file.Close() }() content, err := io.ReadAll(file) diff --git a/pkg/core/e_test.go b/tests/e_test.go similarity index 92% rename from pkg/core/e_test.go rename to tests/e_test.go index eaf1683..a468842 100644 --- a/pkg/core/e_test.go +++ b/tests/e_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" diff --git a/pkg/core/fuzz_test.go b/tests/fuzz_test.go similarity index 83% rename from pkg/core/fuzz_test.go rename to tests/fuzz_test.go index 4835c13..1a5501b 100644 --- a/pkg/core/fuzz_test.go +++ b/tests/fuzz_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" ) @@ -41,7 +42,7 @@ func FuzzE(f *testing.F) { }) } -// FuzzServiceRegistration exercises service name registration with arbitrary names. +// FuzzServiceRegistration exercises service registration with arbitrary names. func FuzzServiceRegistration(f *testing.F) { f.Add("myservice") f.Add("") @@ -50,9 +51,9 @@ func FuzzServiceRegistration(f *testing.F) { f.Add("service\x00null") f.Fuzz(func(t *testing.T, name string) { - sm := newServiceManager() + c, _ := New() - err := sm.registerService(name, struct{}{}) + err := c.RegisterService(name, struct{}{}) if name == "" { if err == nil { t.Fatal("expected error for empty name") @@ -64,13 +65,13 @@ func FuzzServiceRegistration(f *testing.F) { } // Retrieve should return the same service - got := sm.service(name) + got := c.Service(name) if got == nil { t.Fatalf("service %q not found after registration", name) } // Duplicate registration should fail - err = sm.registerService(name, struct{}{}) + err = c.RegisterService(name, struct{}{}) if err == nil { t.Fatalf("expected duplicate error for name %q", name) } @@ -84,18 +85,15 @@ func FuzzMessageDispatch(f *testing.F) { f.Add("test\nmultiline") f.Fuzz(func(t *testing.T, payload string) { - c := &Core{ - svc: newServiceManager(), - } - c.bus = newMessageBus(c) + c, _ := New() var received string - c.bus.registerAction(func(_ *Core, msg Message) error { + c.IPC().RegisterAction(func(_ *Core, msg Message) error { received = msg.(string) return nil }) - err := c.bus.action(payload) + err := c.IPC().Action(payload) if err != nil { t.Fatalf("action dispatch failed: %v", err) } diff --git a/pkg/core/ipc_test.go b/tests/ipc_test.go similarity index 97% rename from pkg/core/ipc_test.go rename to tests/ipc_test.go index e019297..cb0559c 100644 --- a/pkg/core/ipc_test.go +++ b/tests/ipc_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" "time" diff --git a/tests/message_bus_test.go b/tests/message_bus_test.go new file mode 100644 index 0000000..0a46031 --- /dev/null +++ b/tests/message_bus_test.go @@ -0,0 +1,176 @@ +package core_test + +import ( + . "forge.lthn.ai/core/go/pkg/core" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBus_Action_Good(t *testing.T) { + c, _ := New() + + var received []Message + c.IPC().RegisterAction(func(_ *Core, msg Message) error { + received = append(received, msg) + return nil + }) + c.IPC().RegisterAction(func(_ *Core, msg Message) error { + received = append(received, msg) + return nil + }) + + err := c.IPC().Action("hello") + assert.NoError(t, err) + assert.Len(t, received, 2) +} + +func TestBus_Action_Bad(t *testing.T) { + c, _ := New() + + err1 := errors.New("handler1 failed") + err2 := errors.New("handler2 failed") + + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err1 }) + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil }) + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err2 }) + + err := c.IPC().Action("test") + assert.Error(t, err) + assert.ErrorIs(t, err, err1) + assert.ErrorIs(t, err, err2) +} + +func TestBus_RegisterAction_Good(t *testing.T) { + c, _ := New() + + var coreRef *Core + c.IPC().RegisterAction(func(core *Core, msg Message) error { + coreRef = core + return nil + }) + + _ = c.IPC().Action(nil) + assert.Same(t, c, coreRef, "handler should receive the Core reference") +} + +func TestBus_Query_Good(t *testing.T) { + c, _ := New() + + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return "first", true, nil + }) + + result, handled, err := c.IPC().Query(TestQuery{Value: "test"}) + assert.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "first", result) +} + +func TestBus_QueryAll_Good(t *testing.T) { + c, _ := New() + + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return "a", true, nil + }) + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return nil, false, nil // skips + }) + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return "b", true, nil + }) + + results, err := c.IPC().QueryAll(TestQuery{}) + assert.NoError(t, err) + assert.Equal(t, []any{"a", "b"}, results) +} + +func TestBus_Perform_Good(t *testing.T) { + c, _ := New() + + c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) { + return "done", true, nil + }) + + result, handled, err := c.IPC().Perform(TestTask{}) + assert.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "done", result) +} + +func TestBus_ConcurrentAccess_Good(t *testing.T) { + c, _ := New() + + var wg sync.WaitGroup + const goroutines = 20 + + // Concurrent register + dispatch + for i := 0; i < goroutines; i++ { + wg.Add(2) + go func() { + defer wg.Done() + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil }) + }() + go func() { + defer wg.Done() + _ = c.IPC().Action("ping") + }() + } + + for i := 0; i < goroutines; i++ { + wg.Add(2) + go func() { + defer wg.Done() + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil }) + }() + go func() { + defer wg.Done() + _, _ = c.IPC().QueryAll(TestQuery{}) + }() + } + + for i := 0; i < goroutines; i++ { + wg.Add(2) + go func() { + defer wg.Done() + c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil }) + }() + go func() { + defer wg.Done() + _, _, _ = c.IPC().Perform(TestTask{}) + }() + } + + wg.Wait() +} + +func TestBus_Action_NoHandlers(t *testing.T) { + c, _ := New() + err := c.IPC().Action("no one listening") + assert.NoError(t, err) +} + +func TestBus_Query_NoHandlers(t *testing.T) { + c, _ := New() + result, handled, err := c.IPC().Query(TestQuery{}) + assert.NoError(t, err) + assert.False(t, handled) + assert.Nil(t, result) +} + +func TestBus_QueryAll_NoHandlers(t *testing.T) { + c, _ := New() + results, err := c.IPC().QueryAll(TestQuery{}) + assert.NoError(t, err) + assert.Empty(t, results) +} + +func TestBus_Perform_NoHandlers(t *testing.T) { + c, _ := New() + result, handled, err := c.IPC().Perform(TestTask{}) + assert.NoError(t, err) + assert.False(t, handled) + assert.Nil(t, result) +} diff --git a/pkg/core/query_test.go b/tests/query_test.go similarity index 98% rename from pkg/core/query_test.go rename to tests/query_test.go index 43b00fb..e4118c2 100644 --- a/pkg/core/query_test.go +++ b/tests/query_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" diff --git a/pkg/core/runtime_pkg_extra_test.go b/tests/runtime_pkg_extra_test.go similarity index 86% rename from pkg/core/runtime_pkg_extra_test.go rename to tests/runtime_pkg_extra_test.go index c63a4a1..ffa60bb 100644 --- a/pkg/core/runtime_pkg_extra_test.go +++ b/tests/runtime_pkg_extra_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "testing" "github.com/stretchr/testify/assert" diff --git a/pkg/core/runtime_pkg_test.go b/tests/runtime_pkg_test.go similarity index 95% rename from pkg/core/runtime_pkg_test.go rename to tests/runtime_pkg_test.go index bc9b388..4970810 100644 --- a/pkg/core/runtime_pkg_test.go +++ b/tests/runtime_pkg_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "testing" @@ -120,7 +121,7 @@ func TestNewServiceRuntime_Good(t *testing.T) { assert.NotNil(t, sr) assert.Equal(t, c, sr.Core()) - // We can't directly test sr.Config() without a registered config service, + // We can't directly test sr.Cfg() without a registered config service, // as it will panic. assert.Panics(t, func() { sr.Config() diff --git a/tests/service_manager_test.go b/tests/service_manager_test.go new file mode 100644 index 0000000..bfd1e99 --- /dev/null +++ b/tests/service_manager_test.go @@ -0,0 +1,116 @@ +package core_test + +import ( + . "forge.lthn.ai/core/go/pkg/core" + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestServiceManager_RegisterService_Good(t *testing.T) { + c, _ := New() + + err := c.RegisterService("svc1", &MockService{Name: "one"}) + assert.NoError(t, err) + + got := c.Service("svc1") + assert.NotNil(t, got) + assert.Equal(t, "one", got.(*MockService).GetName()) +} + +func TestServiceManager_RegisterService_Bad(t *testing.T) { + c, _ := New() + + // Empty name + err := c.RegisterService("", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be empty") + + // Duplicate + err = c.RegisterService("dup", &MockService{}) + assert.NoError(t, err) + err = c.RegisterService("dup", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + + // Locked + c2, _ := New(WithServiceLock()) + err = c2.RegisterService("late", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "serviceLock") +} + +func TestServiceManager_ServiceNotFound_Good(t *testing.T) { + c, _ := New() + assert.Nil(t, c.Service("nonexistent")) +} + +func TestServiceManager_Startables_Good(t *testing.T) { + s1 := &MockStartable{} + s2 := &MockStartable{} + + c, _ := New( + WithName("s1", func(_ *Core) (any, error) { return s1, nil }), + WithName("s2", func(_ *Core) (any, error) { return s2, nil }), + ) + + // Startup should call both + err := c.ServiceStartup(context.Background(), nil) + assert.NoError(t, err) +} + +func TestServiceManager_Stoppables_Good(t *testing.T) { + s1 := &MockStoppable{} + s2 := &MockStoppable{} + + c, _ := New( + WithName("s1", func(_ *Core) (any, error) { return s1, nil }), + WithName("s2", func(_ *Core) (any, error) { return s2, nil }), + ) + + // Shutdown should call both + err := c.ServiceShutdown(context.Background()) + assert.NoError(t, err) +} + +func TestServiceManager_Lock_Good(t *testing.T) { + c, _ := New( + WithName("early", func(_ *Core) (any, error) { return &MockService{}, nil }), + WithServiceLock(), + ) + + // Register after lock — should fail + err := c.RegisterService("late", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "serviceLock") + + // Early service is still accessible + assert.NotNil(t, c.Service("early")) +} + +func TestServiceManager_LockNotAppliedWithoutEnable_Good(t *testing.T) { + // No WithServiceLock — should allow registration after New() + c, _ := New() + err := c.RegisterService("svc", &MockService{}) + assert.NoError(t, err) +} + +type mockFullLifecycle struct{} + +func (m *mockFullLifecycle) OnStartup(_ context.Context) error { return nil } +func (m *mockFullLifecycle) OnShutdown(_ context.Context) error { return nil } + +func TestServiceManager_LifecycleBoth_Good(t *testing.T) { + svc := &mockFullLifecycle{} + + c, _ := New( + WithName("both", func(_ *Core) (any, error) { return svc, nil }), + ) + + // Should participate in both startup and shutdown + err := c.ServiceStartup(context.Background(), nil) + assert.NoError(t, err) + err = c.ServiceShutdown(context.Background()) + assert.NoError(t, err) +} diff --git a/pkg/core/testdata/test.txt b/tests/testdata/test.txt similarity index 100% rename from pkg/core/testdata/test.txt rename to tests/testdata/test.txt From 2525d10515e5efd8ca5faeb5c6d132cb9873ba08 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 09:20:10 +0000 Subject: [PATCH 25/31] =?UTF-8?q?fix:=20resolve=20Gemini=20review=20findin?= =?UTF-8?q?gs=20=E2=80=94=20race=20conditions=20and=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - error.go: appendReport now mutex-protected, handles JSON errors, uses 0600 perms - log.go: keyvals slice copied before mutation to prevent caller data races - log.go: defaultLog uses atomic.Pointer for thread-safe replacement Co-Authored-By: Virgil --- pkg/core/error.go | 18 +++++++++++++----- pkg/core/log.go | 19 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/pkg/core/error.go b/pkg/core/error.go index f560051..e9ec161 100644 --- a/pkg/core/error.go +++ b/pkg/core/error.go @@ -16,6 +16,7 @@ import ( "runtime" "runtime/debug" "strings" + "sync" "time" ) @@ -390,14 +391,21 @@ func (h *ErrPan) Reports(n int) ([]CrashReport, error) { return reports[len(reports)-n:], nil } -func (h *ErrPan) appendReport(report CrashReport) { - var reports []CrashReport +var crashMu sync.Mutex +func (h *ErrPan) appendReport(report CrashReport) { + crashMu.Lock() + defer crashMu.Unlock() + + var reports []CrashReport if data, err := os.ReadFile(h.filePath); err == nil { - json.Unmarshal(data, &reports) + if err := json.Unmarshal(data, &reports); err != nil { + reports = nil + } } reports = append(reports, report) - data, _ := json.MarshalIndent(reports, "", " ") - os.WriteFile(h.filePath, data, 0644) + if data, err := json.MarshalIndent(reports, "", " "); err == nil { + _ = os.WriteFile(h.filePath, data, 0600) + } } diff --git a/pkg/core/log.go b/pkg/core/log.go index 75bef44..3b66599 100644 --- a/pkg/core/log.go +++ b/pkg/core/log.go @@ -12,6 +12,7 @@ import ( "os/user" "slices" "sync" + "sync/atomic" "time" ) @@ -175,6 +176,9 @@ func (l *Log) log(level Level, prefix, msg string, keyvals ...any) { timestamp := styleTimestamp(time.Now().Format("15:04:05")) + // Copy keyvals to avoid mutating the caller's slice + keyvals = append([]any(nil), keyvals...) + // Automatically extract context from error if present in keyvals origLen := len(keyvals) for i := 0; i < origLen; i += 2 { @@ -294,16 +298,23 @@ func Username() string { // --- Default logger --- -var defaultLog = NewLog(LogOpts{Level: LevelInfo}) +var defaultLogPtr atomic.Pointer[Log] + +func init() { + l := NewLog(LogOpts{Level: LevelInfo}) + defaultLogPtr.Store(l) +} + +var defaultLog = defaultLogPtr.Load() // Default returns the default logger. func Default() *Log { - return defaultLog + return defaultLogPtr.Load() } -// SetDefault sets the default logger. +// SetDefault sets the default logger (thread-safe). func SetDefault(l *Log) { - defaultLog = l + defaultLogPtr.Store(l) } // SetLevel sets the default logger's level. From 173067719edec6b2abe5fc7d296d74da041d8b7f Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 09:37:34 +0000 Subject: [PATCH 26/31] =?UTF-8?q?fix:=20resolve=20Codex=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20stale=20comments,=20constructor=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.go: comments updated from Cfg/NewEtc to Config/NewConfig - service.go: comment updated from NewSrv to NewService - embed.go: comments updated from Emb to Embed - command.go: panic strings updated from NewSubFunction to NewChildCommandFunction - fs.go: error ops updated from local.Delete to core.Delete - core.go: header updated to reflect actual file contents - contract.go: thin constructors inlined as struct literals (NewConfig, NewService, NewCoreI18n, NewBus) Co-Authored-By: Virgil --- pkg/core/command.go | 12 ++++++------ pkg/core/config.go | 4 ++-- pkg/core/contract.go | 8 ++++---- pkg/core/core.go | 2 +- pkg/core/embed.go | 10 +++++----- pkg/core/fs.go | 4 ++-- pkg/core/service.go | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/pkg/core/command.go b/pkg/core/command.go index e9e671b..5c1d77c 100644 --- a/pkg/core/command.go +++ b/pkg/core/command.go @@ -1259,27 +1259,27 @@ func (c *Command) NewChildCommandFunction(name string, description string, fn an // if not, panic t := reflect.TypeOf(fn) if t.Kind() != reflect.Func { - panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") } // Check the function has 1 input ant it's a struct pointer fnValue := reflect.ValueOf(fn) if t.NumIn() != 1 { - panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") } // Check the input is a struct pointer if t.In(0).Kind() != reflect.Ptr { - panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") } if t.In(0).Elem().Kind() != reflect.Struct { - panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") } // Check only 1 output and it's an error if t.NumOut() != 1 { - panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") } if t.Out(0) != reflect.TypeOf((*error)(nil)).Elem() { - panic("NewSubFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") } flags := reflect.New(t.In(0).Elem()) result.Action(func() error { diff --git a/pkg/core/config.go b/pkg/core/config.go index fba030f..b3ddca0 100644 --- a/pkg/core/config.go +++ b/pkg/core/config.go @@ -37,14 +37,14 @@ func NewConfigVar[T any](val T) ConfigVar[T] { return ConfigVar[T]{val: val, set: true} } -// Cfg holds configuration settings and feature flags. +// Config holds configuration settings and feature flags. type Config struct { mu sync.RWMutex settings map[string]any features map[string]bool } -// NewEtc creates a new configuration store. +// NewConfig creates a new configuration store. func NewConfig() *Config { return &Config{ settings: make(map[string]any), diff --git a/pkg/core/contract.go b/pkg/core/contract.go index b19fe22..522f5be 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -93,15 +93,15 @@ func New(opts ...Option) (*Core, error) { c := &Core{ app: app, fs: defaultFS, - cfg: NewConfig(), + cfg: &Config{settings: make(map[string]any), features: make(map[string]bool)}, err: &ErrPan{}, log: &ErrLog{&ErrOpts{Log: defaultLog}}, cli: NewCoreCli(app), - srv: NewService(), + srv: &Service{Services: make(map[string]any)}, lock: &Lock{}, - i18n: NewCoreI18n(), + i18n: &I18n{}, } - c.ipc = NewBus(c) + c.ipc = &Ipc{core: c} for _, o := range opts { if err := o(c); err != nil { diff --git a/pkg/core/core.go b/pkg/core/core.go index 1e19b17..a7861fc 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 // Package core is a dependency injection and service lifecycle framework for Go. -// This file defines the Core struct, all exported methods, and all With* options. +// This file defines the Core struct, accessors, and IPC/error wrappers. package core diff --git a/pkg/core/embed.go b/pkg/core/embed.go index 4e0addc..259a7dc 100644 --- a/pkg/core/embed.go +++ b/pkg/core/embed.go @@ -2,7 +2,7 @@ // Embedded assets for the Core framework. // -// Emb provides scoped filesystem access for go:embed and any fs.FS. +// Embed provides scoped filesystem access for go:embed and any fs.FS. // Also includes build-time asset packing (AST scanner + compressor) // and template-based directory extraction. // @@ -319,9 +319,9 @@ func getAllFiles(dir string) ([]string, error) { return result, err } -// --- Emb: Scoped Filesystem Mount --- +// --- Embed: Scoped Filesystem Mount --- -// Emb wraps an fs.FS with a basedir for scoped access. +// Embed wraps an fs.FS with a basedir for scoped access. // All paths are relative to basedir. type Embed struct { basedir string @@ -379,7 +379,7 @@ func (s *Embed) ReadString(name string) (string, error) { return string(data), nil } -// Sub returns a new Emb anchored at a subdirectory within this mount. +// Sub returns a new Embed anchored at a subdirectory within this mount. func (s *Embed) Sub(subDir string) (*Embed, error) { sub, err := fs.Sub(s.fsys, s.path(subDir)) if err != nil { @@ -402,7 +402,7 @@ func (s *Embed) EmbedFS() embed.FS { return embed.FS{} } -// BaseDir returns the basedir this Emb is anchored at. +// BaseDir returns the basedir this Embed is anchored at. func (s *Embed) BaseDir() string { return s.basedir } diff --git a/pkg/core/fs.go b/pkg/core/fs.go index 336253f..fcf4e94 100644 --- a/pkg/core/fs.go +++ b/pkg/core/fs.go @@ -254,7 +254,7 @@ func (m *Fs) Delete(p string) error { return err } if full == "/" || full == os.Getenv("HOME") { - return E("local.Delete", "refusing to delete protected path: "+full, nil) + return E("core.Delete", "refusing to delete protected path: "+full, nil) } return os.Remove(full) } @@ -266,7 +266,7 @@ func (m *Fs) DeleteAll(p string) error { return err } if full == "/" || full == os.Getenv("HOME") { - return E("local.DeleteAll", "refusing to delete protected path: "+full, nil) + return E("core.DeleteAll", "refusing to delete protected path: "+full, nil) } return os.RemoveAll(full) } diff --git a/pkg/core/service.go b/pkg/core/service.go index 3479a7a..3b489d4 100644 --- a/pkg/core/service.go +++ b/pkg/core/service.go @@ -17,7 +17,7 @@ type Service struct { locked bool } -// NewSrv creates an empty service registry. +// NewService creates an empty service registry. func NewService() *Service { return &Service{ Services: make(map[string]any), From c2227fbb333c7f17b8a3d596c5354bec83619eac Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 10:53:13 +0000 Subject: [PATCH 27/31] =?UTF-8?q?feat:=20complete=20DTO=20pattern=20?= =?UTF-8?q?=E2=80=94=20struct=20literals,=20no=20constructors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All New* constructors removed (NewApp, NewIO, NewCoreCli, NewBus, NewService, NewCoreI18n, NewConfig) - New() uses pure struct literals: &App{}, &Fs{}, &Config{ConfigOpts:}, &Cli{opts:}, &Service{}, &Ipc{}, &I18n{} - Ipc methods moved to func (c *Core) — Ipc is now a DTO - LockApply only called from WithServiceLock, not on every New() - Service map lazy-inits on first write - CliOpts DTO with Version/Name/Description Co-Authored-By: Virgil --- pkg/core/app.go | 20 ---------- pkg/core/cli.go | 49 ++++++++++--------------- pkg/core/config.go | 68 ++++++++++++++++------------------ pkg/core/contract.go | 24 ++++++------ pkg/core/core.go | 25 ++++++------- pkg/core/fs.go | 16 -------- pkg/core/i18n.go | 4 -- pkg/core/ipc.go | 87 ++++++++++---------------------------------- pkg/core/service.go | 9 ++--- pkg/core/task.go | 25 ++++++++++++- 10 files changed, 122 insertions(+), 205 deletions(-) diff --git a/pkg/core/app.go b/pkg/core/app.go index 05f3236..18e976d 100644 --- a/pkg/core/app.go +++ b/pkg/core/app.go @@ -6,7 +6,6 @@ package core import ( - "os" "os/exec" "path/filepath" ) @@ -33,25 +32,6 @@ type App struct { Runtime any } -// NewApp creates a App with the given identity. -// Filename and Path are auto-detected from the running binary. -func NewApp(name, description, version string) *App { - app := &App{ - Name: name, - Version: version, - Description: description, - } - - // Auto-detect executable identity - if exe, err := os.Executable(); err == nil { - if abs, err := filepath.Abs(exe); err == nil { - app.Path = abs - app.Filename = filepath.Base(abs) - } - } - - return app -} // Find locates a program on PATH and returns a App for it. // Returns nil if not found. diff --git a/pkg/core/cli.go b/pkg/core/cli.go index b117254..03fb869 100644 --- a/pkg/core/cli.go +++ b/pkg/core/cli.go @@ -13,9 +13,16 @@ import ( // CliAction represents a function called when a command is invoked. type CliAction func() error +// CliOpts configures a Cli. +type CliOpts struct { + Version string + Name string + Description string +} + // Cli is the CLI command framework. type Cli struct { - app *App + opts *CliOpts rootCommand *Command defaultCommand *Command preRunCommand func(*Cli) error @@ -27,14 +34,14 @@ type Cli struct { // defaultBannerFunction prints a banner for the application. func defaultBannerFunction(c *Cli) string { version := "" - if c.app != nil && c.app.Version != "" { - version = " " + c.app.Version + if c.opts != nil && c.opts.Version != "" { + version = " " + c.opts.Version } name := "" description := "" - if c.app != nil { - name = c.app.Name - description = c.app.Description + if c.opts != nil { + name = c.opts.Name + description = c.opts.Description } if description != "" { return fmt.Sprintf("%s%s - %s", name, version, description) @@ -42,24 +49,6 @@ func defaultBannerFunction(c *Cli) string { return fmt.Sprintf("%s%s", name, version) } -// NewCoreCli creates a new CLI bound to the given App identity. -func NewCoreCli(app *App) *Cli { - name := "" - description := "" - if app != nil { - name = app.Name - description = app.Description - } - - result := &Cli{ - app: app, - bannerFunction: defaultBannerFunction, - } - result.rootCommand = NewCommand(name, description) - result.rootCommand.setApp(result) - result.rootCommand.setParentCommandPath("") - return result -} // Command returns the root command. func (c *Cli) Command() *Command { @@ -68,24 +57,24 @@ func (c *Cli) Command() *Command { // Version returns the application version string. func (c *Cli) Version() string { - if c.app != nil { - return c.app.Version + if c.opts != nil { + return c.opts.Version } return "" } // Name returns the application name. func (c *Cli) Name() string { - if c.app != nil { - return c.app.Name + if c.opts != nil { + return c.opts.Name } return c.rootCommand.name } // ShortDescription returns the application short description. func (c *Cli) ShortDescription() string { - if c.app != nil { - return c.app.Description + if c.opts != nil { + return c.opts.Description } return c.rootCommand.shortdescription } diff --git a/pkg/core/config.go b/pkg/core/config.go index b3ddca0..f2b64a0 100644 --- a/pkg/core/config.go +++ b/pkg/core/config.go @@ -1,7 +1,6 @@ // SPDX-License-Identifier: EUPL-1.2 // Settings, feature flags, and typed configuration for the Core framework. -// Named after /etc — the configuration directory. package core @@ -9,62 +8,62 @@ import ( "sync" ) -// Var is a variable that can be set, unset, and queried for its state. -// Zero value is unset. +// ConfigVar is a variable that can be set, unset, and queried for its state. type ConfigVar[T any] struct { val T set bool } -// Get returns the value, or the zero value if unset. -func (v *ConfigVar[T]) Get() T { return v.val } - -// Set sets the value and marks it as set. -func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } - -// IsSet returns true when a value has been set. +func (v *ConfigVar[T]) Get() T { return v.val } +func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } func (v *ConfigVar[T]) IsSet() bool { return v.set } - -// Unset resets to zero value and marks as unset. func (v *ConfigVar[T]) Unset() { v.set = false var zero T v.val = zero } -// NewVar creates a Var with the given value (marked as set). func NewConfigVar[T any](val T) ConfigVar[T] { return ConfigVar[T]{val: val, set: true} } -// Config holds configuration settings and feature flags. -type Config struct { - mu sync.RWMutex - settings map[string]any - features map[string]bool +// ConfigOpts holds configuration data. +type ConfigOpts struct { + Settings map[string]any + Features map[string]bool } -// NewConfig creates a new configuration store. -func NewConfig() *Config { - return &Config{ - settings: make(map[string]any), - features: make(map[string]bool), +func (o *ConfigOpts) init() { + if o.Settings == nil { + o.Settings = make(map[string]any) } + if o.Features == nil { + o.Features = make(map[string]bool) + } +} + +// Config holds configuration settings and feature flags. +type Config struct { + *ConfigOpts + mu sync.RWMutex } // Set stores a configuration value by key. func (e *Config) Set(key string, val any) { e.mu.Lock() - e.settings[key] = val + e.ConfigOpts.init() + e.Settings[key] = val e.mu.Unlock() } // Get retrieves a configuration value by key. -// Returns (value, true) if found, (zero, false) if not. func (e *Config) Get(key string) (any, bool) { e.mu.RLock() - val, ok := e.settings[key] - e.mu.RUnlock() + defer e.mu.RUnlock() + if e.ConfigOpts == nil || e.Settings == nil { + return nil, false + } + val, ok := e.Settings[key] return val, ok } @@ -73,7 +72,6 @@ func (e *Config) Int(key string) int { return ConfigGet[int](e, key) } func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) } // ConfigGet retrieves a typed configuration value. -// Returns zero value if key is missing or type doesn't match. func ConfigGet[T any](e *Config, key string) T { val, ok := e.Get(key) if !ok { @@ -86,34 +84,32 @@ func ConfigGet[T any](e *Config, key string) T { // --- Feature Flags --- -// Enable enables a feature flag. func (e *Config) Enable(feature string) { e.mu.Lock() - e.features[feature] = true + e.ConfigOpts.init() + e.Features[feature] = true e.mu.Unlock() } -// Disable disables a feature flag. func (e *Config) Disable(feature string) { e.mu.Lock() - e.features[feature] = false + e.ConfigOpts.init() + e.Features[feature] = false e.mu.Unlock() } -// Enabled returns true if the feature is enabled. func (e *Config) Enabled(feature string) bool { e.mu.RLock() - v := e.features[feature] + v := e.Features[feature] e.mu.RUnlock() return v } -// Features returns all enabled feature names. func (e *Config) EnabledFeatures() []string { e.mu.RLock() defer e.mu.RUnlock() var result []string - for k, v := range e.features { + for k, v := range e.Features { if v { result = append(result, k) } diff --git a/pkg/core/contract.go b/pkg/core/contract.go index 522f5be..8e74b0c 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -9,6 +9,7 @@ import ( "embed" "fmt" "io/fs" + "path/filepath" "reflect" "strings" ) @@ -88,20 +89,18 @@ type ActionTaskCompleted struct { // New creates a Core instance with the provided options. func New(opts ...Option) (*Core, error) { - defaultFS, _ := NewIO("/") - app := NewApp("", "", "") c := &Core{ - app: app, - fs: defaultFS, - cfg: &Config{settings: make(map[string]any), features: make(map[string]bool)}, + app: &App{}, + fs: &Fs{root: "/"}, + cfg: &Config{ConfigOpts: &ConfigOpts{}}, err: &ErrPan{}, log: &ErrLog{&ErrOpts{Log: defaultLog}}, - cli: NewCoreCli(app), - srv: &Service{Services: make(map[string]any)}, + cli: &Cli{opts: &CliOpts{}}, + srv: &Service{}, lock: &Lock{}, + ipc: &Ipc{}, i18n: &I18n{}, } - c.ipc = &Ipc{core: c} for _, o := range opts { if err := o(c); err != nil { @@ -109,7 +108,6 @@ func New(opts ...Option) (*Core, error) { } } - c.LockApply() return c, nil } @@ -193,11 +191,14 @@ func WithAssets(efs embed.FS) Option { // WithIO sandboxes filesystem I/O to a root path. func WithIO(root string) Option { return func(c *Core) error { - io, err := NewIO(root) + abs, err := filepath.Abs(root) if err != nil { return E("core.WithIO", "failed to create IO at "+root, err) } - c.fs = io + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + c.fs = &Fs{root: abs} return nil } } @@ -218,6 +219,7 @@ func WithMount(fsys fs.FS, basedir string) Option { func WithServiceLock() Option { return func(c *Core) error { c.LockEnable() + c.LockApply() return nil } } diff --git a/pkg/core/core.go b/pkg/core/core.go index a7861fc..a54b6f6 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -15,9 +15,9 @@ import ( // Core is the central application object that manages services, assets, and communication. type Core struct { app *App // c.App() — Application identity + optional GUI runtime - emb *Embed // c.Embed() — Mounted embedded assets (read-only) + emb *Embed // c.Embed() — Mounted embedded assets (read-only) fs *Fs // c.Fs() — Local filesystem I/O (sandboxable) - cfg *Config // c.Config() — Configuration, settings, feature flags + cfg *Config // c.Config() — Configuration, settings, feature flags err *ErrPan // c.Error() — Panic recovery and crash reporting log *ErrLog // c.Log() — Structured logging + error wrapping cli *Cli // c.Cli() — CLI command framework @@ -34,9 +34,9 @@ type Core struct { // --- Accessors --- func (c *Core) App() *App { return c.app } -func (c *Core) Embed() *Embed { return c.emb } +func (c *Core) Embed() *Embed { return c.emb } func (c *Core) Fs() *Fs { return c.fs } -func (c *Core) Config() *Config { return c.cfg } +func (c *Core) Config() *Config { return c.cfg } func (c *Core) Error() *ErrPan { return c.err } func (c *Core) Log() *ErrLog { return c.log } func (c *Core) Cli() *Cli { return c.cli } @@ -44,16 +44,14 @@ func (c *Core) IPC() *Ipc { return c.ipc } func (c *Core) I18n() *I18n { return c.i18n } func (c *Core) Core() *Core { return c } -// --- IPC --- +// --- IPC (uppercase aliases) --- -func (c *Core) ACTION(msg Message) error { return c.ipc.Action(msg) } -func (c *Core) RegisterAction(handler func(*Core, Message) error) { c.ipc.RegisterAction(handler) } -func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { c.ipc.RegisterActions(handlers...) } -func (c *Core) QUERY(q Query) (any, bool, error) { return c.ipc.Query(q) } -func (c *Core) QUERYALL(q Query) ([]any, error) { return c.ipc.QueryAll(q) } -func (c *Core) PERFORM(t Task) (any, bool, error) { return c.ipc.Perform(t) } -func (c *Core) RegisterQuery(handler QueryHandler) { c.ipc.RegisterQuery(handler) } -func (c *Core) RegisterTask(handler TaskHandler) { c.ipc.RegisterTask(handler) } +func (c *Core) ACTION(msg Message) error { return c.Action(msg) } +func (c *Core) RegisterAction(handler func(*Core, Message) error) { c.RegisterAction(handler) } +func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { c.RegisterActions(handlers...) } +func (c *Core) QUERY(q Query) (any, bool, error) { return c.Query(q) } +func (c *Core) QUERYALL(q Query) ([]any, error) { return c.QueryAll(q) } +func (c *Core) PERFORM(t Task) (any, bool, error) { return c.Perform(t) } // --- Error+Log --- @@ -73,4 +71,3 @@ func (c *Core) Must(err error, op, msg string) { } // --- Global Instance --- - diff --git a/pkg/core/fs.go b/pkg/core/fs.go index fcf4e94..d977046 100644 --- a/pkg/core/fs.go +++ b/pkg/core/fs.go @@ -17,22 +17,6 @@ type Fs struct { root string } -// NewIO creates a Fs rooted at the given directory. -// Pass "/" for full filesystem access, or a specific path to sandbox. -func NewIO(root string) (*Fs, error) { - abs, err := filepath.Abs(root) - if err != nil { - return nil, err - } - // Resolve symlinks so sandbox checks compare like-for-like. - // On macOS, /var is a symlink to /private/var — without this, - // EvalSymlinks on child paths resolves to /private/var/... while - // root stays /var/..., causing false sandbox escape detections. - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - abs = resolved - } - return &Fs{root: abs}, nil -} // path sanitises and returns the full path. // Absolute paths are sandboxed under root (unless root is "/"). diff --git a/pkg/core/i18n.go b/pkg/core/i18n.go index 73acf55..e8ff836 100644 --- a/pkg/core/i18n.go +++ b/pkg/core/i18n.go @@ -47,10 +47,6 @@ type I18n struct { translator Translator // registered implementation (nil until set) } -// NewCoreI18n creates a new i18n manager. -func NewCoreI18n() *I18n { - return &I18n{} -} // AddLocales adds locale mounts (called during service registration). func (i *I18n) AddLocales(mounts ...*Embed) { diff --git a/pkg/core/ipc.go b/pkg/core/ipc.go index 223d3b6..aa66d0e 100644 --- a/pkg/core/ipc.go +++ b/pkg/core/ipc.go @@ -12,10 +12,8 @@ import ( "sync" ) -// Ipc owns action, query, and task dispatch between services. +// Ipc holds IPC dispatch data. type Ipc struct { - core *Core - ipcMu sync.RWMutex ipcHandlers []func(*Core, Message) error @@ -26,48 +24,27 @@ type Ipc struct { taskHandlers []TaskHandler } -// NewBus creates an empty message bus bound to the given Core. -func NewBus(c *Core) *Ipc { - return &Ipc{core: c} -} - -// Action dispatches a message to all registered IPC handlers. -func (b *Ipc) Action(msg Message) error { - b.ipcMu.RLock() - handlers := slices.Clone(b.ipcHandlers) - b.ipcMu.RUnlock() +func (c *Core) Action(msg Message) error { + c.ipc.ipcMu.RLock() + handlers := slices.Clone(c.ipc.ipcHandlers) + c.ipc.ipcMu.RUnlock() var agg error for _, h := range handlers { - if err := h(b.core, msg); err != nil { + if err := h(c, msg); err != nil { agg = errors.Join(agg, err) } } return agg } -// RegisterAction adds a single IPC handler. -func (b *Ipc) RegisterAction(handler func(*Core, Message) error) { - b.ipcMu.Lock() - b.ipcHandlers = append(b.ipcHandlers, handler) - b.ipcMu.Unlock() -} - -// RegisterActions adds multiple IPC handlers. -func (b *Ipc) RegisterActions(handlers ...func(*Core, Message) error) { - b.ipcMu.Lock() - b.ipcHandlers = append(b.ipcHandlers, handlers...) - b.ipcMu.Unlock() -} - -// Query dispatches a query to handlers until one responds. -func (b *Ipc) Query(q Query) (any, bool, error) { - b.queryMu.RLock() - handlers := slices.Clone(b.queryHandlers) - b.queryMu.RUnlock() +func (c *Core) Query(q Query) (any, bool, error) { + c.ipc.queryMu.RLock() + handlers := slices.Clone(c.ipc.queryHandlers) + c.ipc.queryMu.RUnlock() for _, h := range handlers { - result, handled, err := h(b.core, q) + result, handled, err := h(c, q) if handled { return result, true, err } @@ -75,16 +52,15 @@ func (b *Ipc) Query(q Query) (any, bool, error) { return nil, false, nil } -// QueryAll dispatches a query to all handlers and collects all responses. -func (b *Ipc) QueryAll(q Query) ([]any, error) { - b.queryMu.RLock() - handlers := slices.Clone(b.queryHandlers) - b.queryMu.RUnlock() +func (c *Core) QueryAll(q Query) ([]any, error) { + c.ipc.queryMu.RLock() + handlers := slices.Clone(c.ipc.queryHandlers) + c.ipc.queryMu.RUnlock() var results []any var agg error for _, h := range handlers { - result, handled, err := h(b.core, q) + result, handled, err := h(c, q) if err != nil { agg = errors.Join(agg, err) } @@ -95,31 +71,8 @@ func (b *Ipc) QueryAll(q Query) ([]any, error) { return results, agg } -// RegisterQuery adds a query handler. -func (b *Ipc) RegisterQuery(handler QueryHandler) { - b.queryMu.Lock() - b.queryHandlers = append(b.queryHandlers, handler) - b.queryMu.Unlock() -} - -// Perform dispatches a task to handlers until one executes it. -func (b *Ipc) Perform(t Task) (any, bool, error) { - b.taskMu.RLock() - handlers := slices.Clone(b.taskHandlers) - b.taskMu.RUnlock() - - for _, h := range handlers { - result, handled, err := h(b.core, t) - if handled { - return result, true, err - } - } - return nil, false, nil -} - -// RegisterTask adds a task handler. -func (b *Ipc) RegisterTask(handler TaskHandler) { - b.taskMu.Lock() - b.taskHandlers = append(b.taskHandlers, handler) - b.taskMu.Unlock() +func (c *Core) RegisterQuery(handler QueryHandler) { + c.ipc.queryMu.Lock() + c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler) + c.ipc.queryMu.Unlock() } diff --git a/pkg/core/service.go b/pkg/core/service.go index 3b489d4..526b755 100644 --- a/pkg/core/service.go +++ b/pkg/core/service.go @@ -17,12 +17,6 @@ type Service struct { locked bool } -// NewService creates an empty service registry. -func NewService() *Service { - return &Service{ - Services: make(map[string]any), - } -} // --- Core service methods --- @@ -58,6 +52,9 @@ func (c *Core) Service(args ...any) any { return E("core.Service", fmt.Sprintf("service %q already registered", name), nil) } svc := args[1] + if c.srv.Services == nil { + c.srv.Services = make(map[string]any) + } c.srv.Services[name] = svc if st, ok := svc.(Startable); ok { c.srv.startables = append(c.srv.startables, st) diff --git a/pkg/core/task.go b/pkg/core/task.go index 542ec7e..cc6e308 100644 --- a/pkg/core/task.go +++ b/pkg/core/task.go @@ -4,7 +4,10 @@ package core -import "fmt" +import ( + "fmt" + "slices" +) // TaskState holds background task state. type TaskState struct { @@ -38,3 +41,23 @@ func (c *Core) PerformAsync(t Task) string { func (c *Core) Progress(taskID string, progress float64, message string, t Task) { _ = c.ACTION(ActionTaskProgress{TaskID: taskID, Task: t, Progress: progress, Message: message}) } + +func (c *Core) Perform(t Task) (any, bool, error) { + c.ipc.taskMu.RLock() + handlers := slices.Clone(c.ipc.taskHandlers) + c.ipc.taskMu.RUnlock() + + for _, h := range handlers { + result, handled, err := h(c, t) + if handled { + return result, true, err + } + } + return nil, false, nil +} + +func (c *Core) RegisterTask(handler TaskHandler) { + c.ipc.taskMu.Lock() + c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler) + c.ipc.taskMu.Unlock() +} From 2406e81c20b57de9f7fdcf3a478f79fe228d2d15 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 11:05:29 +0000 Subject: [PATCH 28/31] fix(critical): RegisterAction infinite recursion + restore missing methods - core.go: removed self-calling RegisterAction/RegisterActions aliases (stack overflow) - task.go: restored RegisterAction/RegisterActions implementations - contract.go: removed WithIO/WithMount (intentional simplification) Co-Authored-By: Virgil --- pkg/core/contract.go | 28 ---------------------------- pkg/core/core.go | 10 ++++------ pkg/core/task.go | 12 ++++++++++++ 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/pkg/core/contract.go b/pkg/core/contract.go index 8e74b0c..cab7fa0 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -8,8 +8,6 @@ import ( "context" "embed" "fmt" - "io/fs" - "path/filepath" "reflect" "strings" ) @@ -188,32 +186,6 @@ func WithAssets(efs embed.FS) Option { } } -// WithIO sandboxes filesystem I/O to a root path. -func WithIO(root string) Option { - return func(c *Core) error { - abs, err := filepath.Abs(root) - if err != nil { - return E("core.WithIO", "failed to create IO at "+root, err) - } - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - abs = resolved - } - c.fs = &Fs{root: abs} - return nil - } -} - -// WithMount mounts an fs.FS at a specific subdirectory. -func WithMount(fsys fs.FS, basedir string) Option { - return func(c *Core) error { - sub, err := Mount(fsys, basedir) - if err != nil { - return E("core.WithMount", "failed to mount "+basedir, err) - } - c.emb = sub - return nil - } -} // WithServiceLock prevents service registration after initialisation. func WithServiceLock() Option { diff --git a/pkg/core/core.go b/pkg/core/core.go index a54b6f6..ad5aa4d 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -46,12 +46,10 @@ func (c *Core) Core() *Core { return c } // --- IPC (uppercase aliases) --- -func (c *Core) ACTION(msg Message) error { return c.Action(msg) } -func (c *Core) RegisterAction(handler func(*Core, Message) error) { c.RegisterAction(handler) } -func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { c.RegisterActions(handlers...) } -func (c *Core) QUERY(q Query) (any, bool, error) { return c.Query(q) } -func (c *Core) QUERYALL(q Query) ([]any, error) { return c.QueryAll(q) } -func (c *Core) PERFORM(t Task) (any, bool, error) { return c.Perform(t) } +func (c *Core) ACTION(msg Message) error { return c.Action(msg) } +func (c *Core) QUERY(q Query) (any, bool, error) { return c.Query(q) } +func (c *Core) QUERYALL(q Query) ([]any, error) { return c.QueryAll(q) } +func (c *Core) PERFORM(t Task) (any, bool, error) { return c.Perform(t) } // --- Error+Log --- diff --git a/pkg/core/task.go b/pkg/core/task.go index cc6e308..5420d8b 100644 --- a/pkg/core/task.go +++ b/pkg/core/task.go @@ -56,6 +56,18 @@ func (c *Core) Perform(t Task) (any, bool, error) { return nil, false, nil } +func (c *Core) RegisterAction(handler func(*Core, Message) error) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler) + c.ipc.ipcMu.Unlock() +} + +func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...) + c.ipc.ipcMu.Unlock() +} + func (c *Core) RegisterTask(handler TaskHandler) { c.ipc.taskMu.Lock() c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler) From ead9ea00e5350803949eba95583ed13160797bbd Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 13:20:30 +0000 Subject: [PATCH 29/31] =?UTF-8?q?fix:=20resolve=20CodeRabbit=20findings=20?= =?UTF-8?q?=E2=80=94=20init=20ordering,=20crash=20safety,=20lock=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - log.go: remove atomic.Pointer — defaultLog init was nil (var runs before init()) - error.go: Reports(n) validates n<=0, appendReport creates parent dir - contract.go: WithServiceLock is order-independent (LockApply after all opts) Co-Authored-By: Virgil --- pkg/core/contract.go | 3 ++- pkg/core/error.go | 4 +++- pkg/core/log.go | 16 ++++------------ 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/pkg/core/contract.go b/pkg/core/contract.go index cab7fa0..7c2f604 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -106,6 +106,7 @@ func New(opts ...Option) (*Core, error) { } } + c.LockApply() return c, nil } @@ -188,10 +189,10 @@ func WithAssets(efs embed.FS) Option { // WithServiceLock prevents service registration after initialisation. +// Order-independent — lock is applied after all options are processed. func WithServiceLock() Option { return func(c *Core) error { c.LockEnable() - c.LockApply() return nil } } diff --git a/pkg/core/error.go b/pkg/core/error.go index e9ec161..b0e6077 100644 --- a/pkg/core/error.go +++ b/pkg/core/error.go @@ -13,6 +13,7 @@ import ( "iter" "maps" "os" + "path/filepath" "runtime" "runtime/debug" "strings" @@ -385,7 +386,7 @@ func (h *ErrPan) Reports(n int) ([]CrashReport, error) { if err := json.Unmarshal(data, &reports); err != nil { return nil, err } - if len(reports) <= n { + if n <= 0 || len(reports) <= n { return reports, nil } return reports[len(reports)-n:], nil @@ -406,6 +407,7 @@ func (h *ErrPan) appendReport(report CrashReport) { reports = append(reports, report) if data, err := json.MarshalIndent(reports, "", " "); err == nil { + _ = os.MkdirAll(filepath.Dir(h.filePath), 0755) _ = os.WriteFile(h.filePath, data, 0600) } } diff --git a/pkg/core/log.go b/pkg/core/log.go index 3b66599..276917b 100644 --- a/pkg/core/log.go +++ b/pkg/core/log.go @@ -12,7 +12,6 @@ import ( "os/user" "slices" "sync" - "sync/atomic" "time" ) @@ -298,23 +297,16 @@ func Username() string { // --- Default logger --- -var defaultLogPtr atomic.Pointer[Log] - -func init() { - l := NewLog(LogOpts{Level: LevelInfo}) - defaultLogPtr.Store(l) -} - -var defaultLog = defaultLogPtr.Load() +var defaultLog = NewLog(LogOpts{Level: LevelInfo}) // Default returns the default logger. func Default() *Log { - return defaultLogPtr.Load() + return defaultLog } -// SetDefault sets the default logger (thread-safe). +// SetDefault sets the default logger. func SetDefault(l *Log) { - defaultLogPtr.Store(l) + defaultLog = l } // SetLevel sets the default logger's level. From 4fa90a8294b3c59d27c47f167c1f195de76e40df Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 13:28:01 +0000 Subject: [PATCH 30/31] =?UTF-8?q?fix:=20guard=20ErrLog=20against=20nil=20L?= =?UTF-8?q?og=20=E2=80=94=20falls=20back=20to=20defaultLog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Virgil --- pkg/core/error.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/core/error.go b/pkg/core/error.go index b0e6077..40ce94c 100644 --- a/pkg/core/error.go +++ b/pkg/core/error.go @@ -251,13 +251,20 @@ func NewErrLog(opts *ErrOpts) *ErrLog { return &ErrLog{opts} } +func (el *ErrLog) log() *Log { + if el.ErrOpts != nil && el.Log != nil { + return el.Log + } + return defaultLog +} + // Error logs at Error level and returns a wrapped error. func (el *ErrLog) Error(err error, op, msg string) error { if err == nil { return nil } wrapped := Wrap(err, op, msg) - el.Log.Error(msg, "op", op, "err", err) + el.log().Error(msg, "op", op, "err", err) return wrapped } @@ -267,14 +274,14 @@ func (el *ErrLog) Warn(err error, op, msg string) error { return nil } wrapped := Wrap(err, op, msg) - el.Log.Warn(msg, "op", op, "err", err) + el.log().Warn(msg, "op", op, "err", err) return wrapped } // Must logs and panics if err is not nil. func (el *ErrLog) Must(err error, op, msg string) { if err != nil { - el.Log.Error(msg, "op", op, "err", err) + el.log().Error(msg, "op", op, "err", err) panic(Wrap(err, op, msg)) } } From 7c7a257c19e6be0c90f24ad8d737f46a1c8bde16 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 18 Mar 2026 13:33:55 +0000 Subject: [PATCH 31/31] fix: clone Meta per crash report + sync Reports reads with crashMu Co-Authored-By: Virgil --- pkg/core/error.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/core/error.go b/pkg/core/error.go index 40ce94c..efdd594 100644 --- a/pkg/core/error.go +++ b/pkg/core/error.go @@ -360,7 +360,7 @@ func (h *ErrPan) Recover() { Arch: runtime.GOARCH, Version: runtime.Version(), }, - Meta: h.meta, + Meta: maps.Clone(h.meta), } if h.onCrash != nil { @@ -385,6 +385,8 @@ func (h *ErrPan) Reports(n int) ([]CrashReport, error) { if h.filePath == "" { return nil, nil } + crashMu.Lock() + defer crashMu.Unlock() data, err := os.ReadFile(h.filePath) if err != nil { return nil, err