From a6be0df3ea56d9c2ca7a8b0afd335e85ef8902cd Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 24 Mar 2026 23:26:43 +0000 Subject: [PATCH] dev (#38) Co-authored-by: Snider Co-authored-by: Virgil Reviewed-on: https://forge.lthn.ai/core/go/pulls/38 --- app.go | 52 ++++++++++------ app_test.go | 41 +++++++++++-- cli.go | 69 ++++++++++++--------- cli_test.go | 4 +- command.go | 2 +- command_test.go | 8 +-- config.go | 9 +++ contract.go | 156 +++++++++++++++++++++++++++++++++++++++++------ contract_test.go | 133 ++++++++++++++++++++++++++++++++++++++++ core.go | 54 +++++++++++++--- core_test.go | 142 +++++++++++++++++++++++++++++++++++++++--- data.go | 20 +++--- data_test.go | 51 ++++++++++------ drive.go | 36 ++++++----- drive_test.go | 40 ++++++------ embed.go | 2 +- embed_test.go | 34 +++++++---- fs.go | 22 +++++-- i18n_test.go | 10 +-- ipc.go | 2 + lock.go | 20 +++--- options.go | 135 +++++++++++++++++++++++++++------------- options_test.go | 129 ++++++++++++++++++++++++++++++++------- runtime.go | 2 +- service.go | 101 ++++++++++++++++++++++++++++-- service_test.go | 113 +++++++++++++++++++++++++++++++++- 26 files changed, 1114 insertions(+), 273 deletions(-) create mode 100644 contract_test.go diff --git a/app.go b/app.go index 3a5aa02..17c3214 100644 --- a/app.go +++ b/app.go @@ -1,7 +1,6 @@ // SPDX-License-Identifier: EUPL-1.2 // Application identity for the Core framework. -// Based on leaanthony/sail — Name, Filename, Path. package core @@ -11,32 +10,47 @@ import ( ) // App holds the application identity and optional GUI runtime. +// +// app := core.App{}.New(core.NewOptions( +// core.Option{Key: "name", Value: "Core CLI"}, +// core.Option{Key: "version", Value: "1.0.0"}, +// )) 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. + Name string + Version string Description string + Filename string + Path string + Runtime any // GUI runtime (e.g., Wails App). Nil for CLI-only. +} - // 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 +// New creates an App from Options. +// +// app := core.App{}.New(core.NewOptions( +// core.Option{Key: "name", Value: "myapp"}, +// core.Option{Key: "version", Value: "1.0.0"}, +// )) +func (a App) New(opts Options) App { + if name := opts.String("name"); name != "" { + a.Name = name + } + if version := opts.String("version"); version != "" { + a.Version = version + } + if desc := opts.String("description"); desc != "" { + a.Description = desc + } + if filename := opts.String("filename"); filename != "" { + a.Filename = filename + } + return a } // Find locates a program on PATH and returns a Result containing the App. // -// r := core.Find("node", "Node.js") +// r := core.App{}.Find("node", "Node.js") // if r.OK { app := r.Value.(*App) } -func Find(filename, name string) Result { +func (a App) Find(filename, name string) Result { path, err := exec.LookPath(filename) if err != nil { return Result{err, false} diff --git a/app_test.go b/app_test.go index dc85bff..0598b16 100644 --- a/app_test.go +++ b/app_test.go @@ -7,14 +7,41 @@ import ( "github.com/stretchr/testify/assert" ) -// --- App --- +// --- App.New --- -func TestApp_Good(t *testing.T) { - c := New(Options{{Key: "name", Value: "myapp"}}) +func TestApp_New_Good(t *testing.T) { + app := App{}.New(NewOptions( + Option{Key: "name", Value: "myapp"}, + Option{Key: "version", Value: "1.0.0"}, + Option{Key: "description", Value: "test app"}, + )) + assert.Equal(t, "myapp", app.Name) + assert.Equal(t, "1.0.0", app.Version) + assert.Equal(t, "test app", app.Description) +} + +func TestApp_New_Empty_Good(t *testing.T) { + app := App{}.New(NewOptions()) + assert.Equal(t, "", app.Name) + assert.Equal(t, "", app.Version) +} + +func TestApp_New_Partial_Good(t *testing.T) { + app := App{}.New(NewOptions( + Option{Key: "name", Value: "myapp"}, + )) + assert.Equal(t, "myapp", app.Name) + assert.Equal(t, "", app.Version) +} + +// --- App via Core --- + +func TestApp_Core_Good(t *testing.T) { + c := New(WithOption("name", "myapp")) assert.Equal(t, "myapp", c.App().Name) } -func TestApp_Empty_Good(t *testing.T) { +func TestApp_Core_Empty_Good(t *testing.T) { c := New() assert.NotNil(t, c.App()) assert.Equal(t, "", c.App().Name) @@ -26,14 +53,16 @@ func TestApp_Runtime_Good(t *testing.T) { assert.NotNil(t, c.App().Runtime) } +// --- App.Find --- + func TestApp_Find_Good(t *testing.T) { - r := Find("go", "go") + r := App{}.Find("go", "go") assert.True(t, r.OK) app := r.Value.(*App) assert.NotEmpty(t, app.Path) } func TestApp_Find_Bad(t *testing.T) { - r := Find("nonexistent-binary-xyz", "test") + r := App{}.Find("nonexistent-binary-xyz", "test") assert.False(t, r.OK) } diff --git a/cli.go b/cli.go index ff7d298..1744f80 100644 --- a/cli.go +++ b/cli.go @@ -1,16 +1,10 @@ // SPDX-License-Identifier: EUPL-1.2 // Cli is the CLI surface layer for the Core command tree. -// It reads commands from Core's registry and wires them to terminal I/O. // -// Run the CLI: -// -// c := core.New(core.Options{{Key: "name", Value: "myapp"}}) -// c.Command("deploy", handler) +// c := core.New(core.WithOption("name", "myapp")).Value.(*Core) +// c.Command("deploy", core.Command{Action: handler}) // c.Cli().Run() -// -// The Cli resolves os.Args to a command path, parses flags, -// and calls the command's action with parsed options. package core import ( @@ -18,13 +12,25 @@ import ( "os" ) +// CliOptions holds configuration for the Cli service. +type CliOptions struct{} + // Cli is the CLI surface for the Core command tree. type Cli struct { - core *Core + *ServiceRuntime[CliOptions] output io.Writer banner func(*Cli) string } +// Register creates a Cli service factory for core.WithService. +// +// core.New(core.WithService(core.CliRegister)) +func CliRegister(c *Core) Result { + cl := &Cli{output: os.Stdout} + cl.ServiceRuntime = NewServiceRuntime[CliOptions](c, CliOptions{}) + return c.RegisterService("cli", cl) +} + // Print writes to the CLI output (defaults to os.Stdout). // // c.Cli().Print("hello %s", "world") @@ -49,17 +55,18 @@ func (cl *Cli) Run(args ...string) Result { } clean := FilterArgs(args) + c := cl.Core() - if cl.core == nil || cl.core.commands == nil { + if c == nil || c.commands == nil { if cl.banner != nil { cl.Print(cl.banner(cl)) } return Result{} } - cl.core.commands.mu.RLock() - cmdCount := len(cl.core.commands.commands) - cl.core.commands.mu.RUnlock() + c.commands.mu.RLock() + cmdCount := len(c.commands.commands) + c.commands.mu.RUnlock() if cmdCount == 0 { if cl.banner != nil { @@ -72,16 +79,16 @@ func (cl *Cli) Run(args ...string) Result { var cmd *Command var remaining []string - cl.core.commands.mu.RLock() + c.commands.mu.RLock() for i := len(clean); i > 0; i-- { path := JoinPath(clean[:i]...) - if c, ok := cl.core.commands.commands[path]; ok { - cmd = c + if found, ok := c.commands.commands[path]; ok { + cmd = found remaining = clean[i:] break } } - cl.core.commands.mu.RUnlock() + c.commands.mu.RUnlock() if cmd == nil { if cl.banner != nil { @@ -92,17 +99,17 @@ func (cl *Cli) Run(args ...string) Result { } // Build options from remaining args - opts := Options{} + opts := NewOptions() for _, arg := range remaining { key, val, valid := ParseFlag(arg) if valid { if Contains(arg, "=") { - opts = append(opts, Option{Key: key, Value: val}) + opts.Set(key, val) } else { - opts = append(opts, Option{Key: key, Value: true}) + opts.Set(key, true) } } else if !IsFlag(arg) { - opts = append(opts, Option{Key: "_arg", Value: arg}) + opts.Set("_arg", arg) } } @@ -119,13 +126,14 @@ func (cl *Cli) Run(args ...string) Result { // // c.Cli().PrintHelp() func (cl *Cli) PrintHelp() { - if cl.core == nil || cl.core.commands == nil { + c := cl.Core() + if c == nil || c.commands == nil { return } name := "" - if cl.core.app != nil { - name = cl.core.app.Name + if c.app != nil { + name = c.app.Name } if name != "" { cl.Print("%s commands:", name) @@ -133,14 +141,14 @@ func (cl *Cli) PrintHelp() { cl.Print("Commands:") } - cl.core.commands.mu.RLock() - defer cl.core.commands.mu.RUnlock() + c.commands.mu.RLock() + defer c.commands.mu.RUnlock() - for path, cmd := range cl.core.commands.commands { + for path, cmd := range c.commands.commands { if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) { continue } - tr := cl.core.I18n().Translate(cmd.I18nKey()) + tr := c.I18n().Translate(cmd.I18nKey()) desc, _ := tr.Value.(string) if desc == "" || desc == cmd.I18nKey() { cl.Print(" %s", path) @@ -162,8 +170,9 @@ func (cl *Cli) Banner() string { if cl.banner != nil { return cl.banner(cl) } - if cl.core != nil && cl.core.app != nil && cl.core.app.Name != "" { - return cl.core.app.Name + c := cl.Core() + if c != nil && c.app != nil && c.app.Name != "" { + return c.app.Name } return "" } diff --git a/cli_test.go b/cli_test.go index 46f01de..5dee0ac 100644 --- a/cli_test.go +++ b/cli_test.go @@ -16,7 +16,7 @@ func TestCli_Good(t *testing.T) { } func TestCli_Banner_Good(t *testing.T) { - c := New(Options{{Key: "name", Value: "myapp"}}) + c := New(WithOption("name", "myapp")) assert.Equal(t, "myapp", c.Cli().Banner()) } @@ -70,7 +70,7 @@ func TestCli_Run_NoCommand_Good(t *testing.T) { } func TestCli_PrintHelp_Good(t *testing.T) { - c := New(Options{{Key: "name", Value: "myapp"}}) + c := New(WithOption("name", "myapp")) c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }}) c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }}) c.Cli().PrintHelp() diff --git a/command.go b/command.go index 7b74e9f..c774ed6 100644 --- a/command.go +++ b/command.go @@ -69,7 +69,7 @@ func (cmd *Command) I18nKey() string { // Run executes the command's action with the given options. // -// result := cmd.Run(core.Options{{Key: "target", Value: "homelab"}}) +// result := cmd.Run(core.NewOptions(core.Option{Key: "target", Value: "homelab"})) func (cmd *Command) Run(opts Options) Result { if cmd.Action == nil { return Result{E("core.Command.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false} diff --git a/command_test.go b/command_test.go index 57966a5..7a81cf1 100644 --- a/command_test.go +++ b/command_test.go @@ -37,7 +37,7 @@ func TestCommand_Run_Good(t *testing.T) { return Result{Value: Concat("hello ", opts.String("name")), OK: true} }}) cmd := c.Command("greet").Value.(*Command) - r := cmd.Run(Options{{Key: "name", Value: "world"}}) + r := cmd.Run(NewOptions(Option{Key: "name", Value: "world"})) assert.True(t, r.OK) assert.Equal(t, "hello world", r.Value) } @@ -46,7 +46,7 @@ func TestCommand_Run_NoAction_Good(t *testing.T) { c := New() c.Command("empty", Command{Description: "no action"}) cmd := c.Command("empty").Value.(*Command) - r := cmd.Run(Options{}) + r := cmd.Run(NewOptions()) assert.False(t, r.OK) } @@ -111,7 +111,7 @@ func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) { }}) cmd := c.Command("serve").Value.(*Command) - r := cmd.Start(Options{}) + r := cmd.Start(NewOptions()) assert.True(t, r.OK) assert.Equal(t, "running", r.Value) @@ -158,7 +158,7 @@ func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) { c.Command("daemon", Command{Lifecycle: lc}) cmd := c.Command("daemon").Value.(*Command) - r := cmd.Start(Options{}) + r := cmd.Start(NewOptions()) assert.True(t, r.OK) assert.True(t, lc.started) diff --git a/config.go b/config.go index 395a0f6..cf71d1b 100644 --- a/config.go +++ b/config.go @@ -48,6 +48,15 @@ type Config struct { mu sync.RWMutex } +// New initialises a Config with empty settings and features. +// +// cfg := (&core.Config{}).New() +func (e *Config) New() *Config { + e.ConfigOptions = &ConfigOptions{} + e.ConfigOptions.init() + return e +} + // Set stores a configuration value by key. func (e *Config) Set(key string, val any) { e.mu.Lock() diff --git a/contract.go b/contract.go index b5fd099..7d65926 100644 --- a/contract.go +++ b/contract.go @@ -6,6 +6,7 @@ package core import ( "context" + "reflect" ) // Message is the type for IPC broadcasts (fire-and-forget). @@ -66,20 +67,36 @@ type ActionTaskCompleted struct { // --- Constructor --- -// New creates a Core instance. +// CoreOption is a functional option applied during Core construction. +// Returns Result — if !OK, New() stops and returns the error. // -// c := core.New(core.Options{ -// {Key: "name", Value: "myapp"}, -// }) -func New(opts ...Options) *Core { +// core.New( +// core.WithService(agentic.Register), +// core.WithService(monitor.Register), +// core.WithServiceLock(), +// ) +type CoreOption func(*Core) Result + +// New initialises a Core instance by applying options in order. +// Services registered here form the application conclave — they share +// IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown). +// +// r := core.New( +// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})), +// core.WithService(auth.Register), +// core.WithServiceLock(), +// ) +// if !r.OK { log.Fatal(r.Value) } +// c := r.Value.(*Core) +func New(opts ...CoreOption) *Core { c := &Core{ app: &App{}, data: &Data{}, drive: &Drive{}, - fs: &Fs{root: "/"}, - config: &Config{ConfigOptions: &ConfigOptions{}}, + fs: (&Fs{}).New("/"), + config: (&Config{}).New(), error: &ErrorPanic{}, - log: &ErrorLog{log: Default()}, + log: &ErrorLog{}, lock: &Lock{}, ipc: &Ipc{}, info: systemInfo, @@ -89,18 +106,123 @@ func New(opts ...Options) *Core { } c.context, c.cancel = context.WithCancel(context.Background()) - if len(opts) > 0 { - cp := make(Options, len(opts[0])) - copy(cp, opts[0]) - c.options = &cp - name := cp.String("name") - if name != "" { - c.app.Name = name + // Core services + CliRegister(c) + + for _, opt := range opts { + if r := opt(c); !r.OK { + Error("core.New failed", "err", r.Value) + break } } - // Init Cli surface with Core reference - c.cli = &Cli{core: c} + // Apply service lock after all opts — v0.3.3 parity + c.LockApply() return c } + +// WithOptions applies key-value configuration to Core. +// +// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})) +func WithOptions(opts Options) CoreOption { + return func(c *Core) Result { + c.options = &opts + if name := opts.String("name"); name != "" { + c.app.Name = name + } + return Result{OK: true} + } +} + +// WithService registers a service via its factory function. +// If the factory returns a non-nil Value, WithService auto-discovers the +// service name from the factory's package path (last path segment, lowercase, +// with any "_test" suffix stripped) and calls RegisterService on the instance. +// IPC handler auto-registration is handled by RegisterService. +// +// If the factory returns nil Value (it registered itself), WithService +// returns success without a second registration. +// +// core.WithService(agentic.Register) +// core.WithService(display.Register(nil)) +func WithService(factory func(*Core) Result) CoreOption { + return func(c *Core) Result { + r := factory(c) + if !r.OK { + return r + } + if r.Value == nil { + // Factory self-registered — nothing more to do. + return Result{OK: true} + } + // Auto-discover the service name from the instance's package path. + instance := r.Value + typeOf := reflect.TypeOf(instance) + if typeOf.Kind() == reflect.Ptr { + typeOf = typeOf.Elem() + } + pkgPath := typeOf.PkgPath() + parts := Split(pkgPath, "/") + name := Lower(parts[len(parts)-1]) + if name == "" { + return Result{E("core.WithService", Sprintf("service name could not be discovered for type %T", instance), nil), false} + } + + // RegisterService handles Startable/Stoppable/HandleIPCEvents discovery + return c.RegisterService(name, instance) + } +} + +// WithName registers a service with an explicit name (no reflect discovery). +// +// core.WithName("ws", func(c *Core) Result { +// return Result{Value: hub, OK: true} +// }) +func WithName(name string, factory func(*Core) Result) CoreOption { + return func(c *Core) Result { + r := factory(c) + if !r.OK { + return r + } + if r.Value == nil { + return Result{E("core.WithName", Sprintf("failed to create service %q", name), nil), false} + } + return c.RegisterService(name, r.Value) + } +} + +// WithOption is a convenience for setting a single key-value option. +// +// core.New( +// core.WithOption("name", "myapp"), +// core.WithOption("port", 8080), +// ) +func WithOption(key string, value any) CoreOption { + return func(c *Core) Result { + if c.options == nil { + opts := NewOptions() + c.options = &opts + } + c.options.Set(key, value) + if key == "name" { + if s, ok := value.(string); ok { + c.app.Name = s + } + } + return Result{OK: true} + } +} + +// WithServiceLock prevents further service registration after construction. +// +// core.New( +// core.WithService(auth.Register), +// core.WithServiceLock(), +// ) +func WithServiceLock() CoreOption { + return func(c *Core) Result { + c.LockEnable() + return Result{OK: true} + } +} diff --git a/contract_test.go b/contract_test.go new file mode 100644 index 0000000..9984a55 --- /dev/null +++ b/contract_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- WithService --- + +// stub service used only for name-discovery tests. +type stubNamedService struct{} + +// stubFactory is a package-level factory so the runtime function name carries +// the package path "core_test.stubFactory" — last segment after '/' is +// "core_test", and after stripping a "_test" suffix we get "core". +// For a real service package such as "dappco.re/go/agentic" the discovered +// name would be "agentic". +func stubFactory(c *Core) Result { + return Result{Value: &stubNamedService{}, OK: true} +} + +// TestWithService_NameDiscovery_Good verifies that WithService discovers the +// service name from the factory's package path and registers the instance via +// RegisterService, making it retrievable through c.Services(). +// +// stubFactory lives in package "dappco.re/go/core_test", so the last path +// segment is "core_test" — WithService strips the "_test" suffix and registers +// the service under the name "core". +func TestWithService_NameDiscovery_Good(t *testing.T) { + c := New(WithService(stubFactory)) + + names := c.Services() + // Service should be auto-registered under a discovered name (not just "cli" which is built-in) + assert.Greater(t, len(names), 1, "expected auto-discovered service to be registered alongside built-in 'cli'") +} + +// TestWithService_FactorySelfRegisters_Good verifies that when a factory +// returns Result{OK:true} with no Value (it registered itself), WithService +// does not attempt a second registration and returns success. +func TestWithService_FactorySelfRegisters_Good(t *testing.T) { + selfReg := func(c *Core) Result { + // Factory registers directly, returns no instance. + c.Service("self", Service{}) + return Result{OK: true} + } + + c := New(WithService(selfReg)) + + // "self" must be present and registered exactly once. + svc := c.Service("self") + assert.True(t, svc.OK, "expected self-registered service to be present") +} + +// --- WithName --- + +func TestWithName_Good(t *testing.T) { + c := New( + WithName("custom", func(c *Core) Result { + return Result{Value: &stubNamedService{}, OK: true} + }), + ) + assert.Contains(t, c.Services(), "custom") +} + +// --- Lifecycle --- + +type lifecycleService struct { + started bool +} + +func (s *lifecycleService) OnStartup(_ context.Context) error { + s.started = true + return nil +} + +func TestWithService_Lifecycle_Good(t *testing.T) { + svc := &lifecycleService{} + c := New( + WithService(func(c *Core) Result { + return Result{Value: svc, OK: true} + }), + ) + + c.ServiceStartup(context.Background(), nil) + assert.True(t, svc.started) +} + +// --- IPC Handler --- + +type ipcService struct { + received Message +} + +func (s *ipcService) HandleIPCEvents(c *Core, msg Message) Result { + s.received = msg + return Result{OK: true} +} + +func TestWithService_IPCHandler_Good(t *testing.T) { + svc := &ipcService{} + c := New( + WithService(func(c *Core) Result { + return Result{Value: svc, OK: true} + }), + ) + + c.ACTION("ping") + assert.Equal(t, "ping", svc.received) +} + +// --- Error --- + +// TestWithService_FactoryError_Bad verifies that a failing factory +// stops further option processing (second service not registered). +func TestWithService_FactoryError_Bad(t *testing.T) { + secondCalled := false + c := New( + WithService(func(c *Core) Result { + return Result{Value: E("test", "factory failed", nil), OK: false} + }), + WithService(func(c *Core) Result { + secondCalled = true + return Result{OK: true} + }), + ) + assert.NotNil(t, c) + assert.False(t, secondCalled, "second option should not run after first fails") +} diff --git a/core.go b/core.go index fb9c5d9..9074b5c 100644 --- a/core.go +++ b/core.go @@ -7,6 +7,7 @@ package core import ( "context" + "os" "sync" "sync/atomic" ) @@ -15,15 +16,15 @@ import ( // Core is the central application object that manages services, assets, and communication. type Core struct { - options *Options // c.Options() — Input configuration used to create this Core - app *App // c.App() — Application identity + optional GUI runtime - data *Data // c.Data() — Embedded/stored content from packages - drive *Drive // c.Drive() — Resource handle registry (transports) - fs *Fs // c.Fs() — Local filesystem I/O (sandboxable) - config *Config // c.Config() — Configuration, settings, feature flags - error *ErrorPanic // c.Error() — Panic recovery and crash reporting - log *ErrorLog // c.Log() — Structured logging + error wrapping - cli *Cli // c.Cli() — CLI surface layer + options *Options // c.Options() — Input configuration used to create this Core + app *App // c.App() — Application identity + optional GUI runtime + data *Data // c.Data() — Embedded/stored content from packages + drive *Drive // c.Drive() — Resource handle registry (transports) + fs *Fs // c.Fs() — Local filesystem I/O (sandboxable) + config *Config // c.Config() — Configuration, settings, feature flags + error *ErrorPanic // c.Error() — Panic recovery and crash reporting + log *ErrorLog // c.Log() — Structured logging + error wrapping + // cli accessed via ServiceFor[*Cli](c, "cli") commands *commandRegistry // c.Command("path") — Command tree services *serviceRegistry // c.Service("name") — Service registry lock *Lock // c.Lock("name") — Named mutexes @@ -49,13 +50,46 @@ func (c *Core) Fs() *Fs { return c.fs } func (c *Core) Config() *Config { return c.config } func (c *Core) Error() *ErrorPanic { return c.error } func (c *Core) Log() *ErrorLog { return c.log } -func (c *Core) Cli() *Cli { return c.cli } +func (c *Core) Cli() *Cli { + cl, _ := ServiceFor[*Cli](c, "cli") + return cl +} func (c *Core) IPC() *Ipc { return c.ipc } func (c *Core) I18n() *I18n { return c.i18n } func (c *Core) Env(key string) string { return Env(key) } func (c *Core) Context() context.Context { return c.context } func (c *Core) Core() *Core { return c } +// --- Lifecycle --- + +// Run starts all services, runs the CLI, then shuts down. +// This is the standard application lifecycle for CLI apps. +// +// c := core.New(core.WithService(myService.Register)).Value.(*Core) +// c.Run() +func (c *Core) Run() { + r := c.ServiceStartup(c.context, nil) + if !r.OK { + if err, ok := r.Value.(error); ok { + Error(err.Error()) + } + os.Exit(1) + } + + if cli := c.Cli(); cli != nil { + r = cli.Run() + } + + c.ServiceShutdown(context.Background()) + + if !r.OK { + if err, ok := r.Value.(error); ok { + Error(err.Error()) + } + os.Exit(1) + } +} + // --- IPC (uppercase aliases) --- func (c *Core) ACTION(msg Message) Result { return c.Action(msg) } diff --git a/core_test.go b/core_test.go index 3b5a68c..17ee587 100644 --- a/core_test.go +++ b/core_test.go @@ -1,6 +1,10 @@ package core_test import ( + "context" + "os" + "os/exec" + "path/filepath" "testing" . "dappco.re/go/core" @@ -15,17 +19,64 @@ func TestNew_Good(t *testing.T) { } func TestNew_WithOptions_Good(t *testing.T) { - c := New(Options{{Key: "name", Value: "myapp"}}) + c := New(WithOptions(NewOptions(Option{Key: "name", Value: "myapp"}))) assert.NotNil(t, c) assert.Equal(t, "myapp", c.App().Name) } func TestNew_WithOptions_Bad(t *testing.T) { // Empty options — should still create a valid Core - c := New(Options{}) + c := New(WithOptions(NewOptions())) assert.NotNil(t, c) } +func TestNew_WithService_Good(t *testing.T) { + started := false + c := New( + WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})), + WithService(func(c *Core) Result { + c.Service("test", Service{ + OnStart: func() Result { started = true; return Result{OK: true} }, + }) + return Result{OK: true} + }), + ) + + svc := c.Service("test") + assert.True(t, svc.OK) + + c.ServiceStartup(context.Background(), nil) + assert.True(t, started) +} + +func TestNew_WithServiceLock_Good(t *testing.T) { + c := New( + WithService(func(c *Core) Result { + c.Service("allowed", Service{}) + return Result{OK: true} + }), + WithServiceLock(), + ) + + // Registration after lock should fail + reg := c.Service("blocked", Service{}) + assert.False(t, reg.OK) +} + +func TestNew_WithService_Bad_FailingOption(t *testing.T) { + secondCalled := false + _ = New( + WithService(func(c *Core) Result { + return Result{Value: E("test", "intentional failure", nil), OK: false} + }), + WithService(func(c *Core) Result { + secondCalled = true + return Result{OK: true} + }), + ) + assert.False(t, secondCalled, "second option should not run after first fails") +} + // --- Accessors --- func TestAccessors_Good(t *testing.T) { @@ -44,11 +95,11 @@ func TestAccessors_Good(t *testing.T) { } func TestOptions_Accessor_Good(t *testing.T) { - c := New(Options{ - {Key: "name", Value: "testapp"}, - {Key: "port", Value: 8080}, - {Key: "debug", Value: true}, - }) + c := New(WithOptions(NewOptions( + Option{Key: "name", Value: "testapp"}, + Option{Key: "port", Value: 8080}, + Option{Key: "debug", Value: true}, + ))) opts := c.Options() assert.NotNil(t, opts) assert.Equal(t, "testapp", opts.String("name")) @@ -68,7 +119,7 @@ func TestCore_LogError_Good(t *testing.T) { c := New() cause := assert.AnError r := c.LogError(cause, "test.Operation", "something broke") - assert.False(t, r.OK) + err, ok := r.Value.(error) assert.True(t, ok) assert.ErrorIs(t, err, cause) @@ -77,7 +128,7 @@ func TestCore_LogError_Good(t *testing.T) { func TestCore_LogWarn_Good(t *testing.T) { c := New() r := c.LogWarn(assert.AnError, "test.Operation", "heads up") - assert.False(t, r.OK) + _, ok := r.Value.(error) assert.True(t, ok) } @@ -95,3 +146,76 @@ func TestCore_Must_Nil_Good(t *testing.T) { c.Must(nil, "test.Operation", "no error") }) } + +func TestCore_Run_HelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + switch os.Getenv("CORE_RUN_MODE") { + case "startup-fail": + c := New( + WithService(func(c *Core) Result { + return c.Service("broken", Service{ + OnStart: func() Result { + return Result{Value: NewError("startup failed"), OK: false} + }, + }) + }), + ) + c.Run() + case "cli-fail": + shutdownFile := os.Getenv("CORE_RUN_SHUTDOWN_FILE") + c := New( + WithService(func(c *Core) Result { + return c.Service("cleanup", Service{ + OnStop: func() Result { + if err := os.WriteFile(shutdownFile, []byte("stopped"), 0o600); err != nil { + return Result{Value: err, OK: false} + } + return Result{OK: true} + }, + }) + }), + ) + c.Command("explode", Command{ + Action: func(_ Options) Result { + return Result{Value: NewError("cli failed"), OK: false} + }, + }) + os.Args = []string{"core-test", "explode"} + c.Run() + default: + os.Exit(2) + } +} + +func TestCore_Run_Bad(t *testing.T) { + err := runCoreRunHelper(t, "startup-fail") + var exitErr *exec.ExitError + if assert.ErrorAs(t, err, &exitErr) { + assert.Equal(t, 1, exitErr.ExitCode()) + } +} + +func TestCore_Run_Ugly(t *testing.T) { + shutdownFile := filepath.Join(t.TempDir(), "shutdown.txt") + err := runCoreRunHelper(t, "cli-fail", "CORE_RUN_SHUTDOWN_FILE="+shutdownFile) + var exitErr *exec.ExitError + if assert.ErrorAs(t, err, &exitErr) { + assert.Equal(t, 1, exitErr.ExitCode()) + } + + data, readErr := os.ReadFile(shutdownFile) + assert.NoError(t, readErr) + assert.Equal(t, "stopped", string(data)) +} + +func runCoreRunHelper(t *testing.T, mode string, extraEnv ...string) error { + t.Helper() + + cmd := exec.Command(os.Args[0], "-test.run=^TestCore_Run_HelperProcess$") + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "CORE_RUN_MODE="+mode) + cmd.Env = append(cmd.Env, extraEnv...) + return cmd.Run() +} diff --git a/data.go b/data.go index 3fa5d7b..47f9414 100644 --- a/data.go +++ b/data.go @@ -6,11 +6,11 @@ // // Mount a package's assets: // -// c.Data().New(core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "source", Value: brainFS}, -// {Key: "path", Value: "prompts"}, -// }) +// c.Data().New(core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "source", Value: brainFS}, +// core.Option{Key: "path", Value: "prompts"}, +// )) // // Read from any mounted path: // @@ -36,11 +36,11 @@ type Data struct { // New registers an embedded filesystem under a named prefix. // -// c.Data().New(core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "source", Value: brainFS}, -// {Key: "path", Value: "prompts"}, -// }) +// c.Data().New(core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "source", Value: brainFS}, +// core.Option{Key: "path", Value: "prompts"}, +// )) func (d *Data) New(opts Options) Result { name := opts.String("name") if name == "" { diff --git a/data_test.go b/data_test.go index ffbccea..89763d6 100644 --- a/data_test.go +++ b/data_test.go @@ -14,13 +14,24 @@ var testFS embed.FS // --- Data (Embedded Content Mounts) --- +func mountTestData(t *testing.T, c *Core, name string) { + t.Helper() + + r := c.Data().New(NewOptions( + Option{Key: "name", Value: name}, + Option{Key: "source", Value: testFS}, + Option{Key: "path", Value: "testdata"}, + )) + assert.True(t, r.OK) +} + func TestData_New_Good(t *testing.T) { c := New() - r := c.Data().New(Options{ - {Key: "name", Value: "test"}, - {Key: "source", Value: testFS}, - {Key: "path", Value: "testdata"}, - }) + r := c.Data().New(NewOptions( + Option{Key: "name", Value: "test"}, + Option{Key: "source", Value: testFS}, + Option{Key: "path", Value: "testdata"}, + )) assert.True(t, r.OK) assert.NotNil(t, r.Value) } @@ -28,19 +39,19 @@ func TestData_New_Good(t *testing.T) { func TestData_New_Bad(t *testing.T) { c := New() - r := c.Data().New(Options{{Key: "source", Value: testFS}}) + r := c.Data().New(NewOptions(Option{Key: "source", Value: testFS})) assert.False(t, r.OK) - r = c.Data().New(Options{{Key: "name", Value: "test"}}) + r = c.Data().New(NewOptions(Option{Key: "name", Value: "test"})) assert.False(t, r.OK) - r = c.Data().New(Options{{Key: "name", Value: "test"}, {Key: "source", Value: "not-an-fs"}}) + r = c.Data().New(NewOptions(Option{Key: "name", Value: "test"}, Option{Key: "source", Value: "not-an-fs"})) assert.False(t, r.OK) } func TestData_ReadString_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}}) + mountTestData(t, c, "app") r := c.Data().ReadString("app/test.txt") assert.True(t, r.OK) assert.Equal(t, "hello from testdata\n", r.Value.(string)) @@ -54,7 +65,7 @@ func TestData_ReadString_Bad(t *testing.T) { func TestData_ReadFile_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}}) + mountTestData(t, c, "app") r := c.Data().ReadFile("app/test.txt") assert.True(t, r.OK) assert.Equal(t, "hello from testdata\n", string(r.Value.([]byte))) @@ -62,7 +73,7 @@ func TestData_ReadFile_Good(t *testing.T) { func TestData_Get_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "brain"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}}) + mountTestData(t, c, "brain") gr := c.Data().Get("brain") assert.True(t, gr.OK) emb := gr.Value.(*Embed) @@ -83,22 +94,22 @@ func TestData_Get_Bad(t *testing.T) { func TestData_Mounts_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "a"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}}) - c.Data().New(Options{{Key: "name", Value: "b"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}}) + mountTestData(t, c, "a") + mountTestData(t, c, "b") mounts := c.Data().Mounts() assert.Len(t, mounts, 2) } func TestEmbed_Legacy_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}}) + mountTestData(t, c, "app") assert.NotNil(t, c.Embed()) } func TestData_List_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}}) - r := c.Data().List("app/testdata") + mountTestData(t, c, "app") + r := c.Data().List("app/.") assert.True(t, r.OK) } @@ -110,16 +121,16 @@ func TestData_List_Bad(t *testing.T) { func TestData_ListNames_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}}) - r := c.Data().ListNames("app/testdata") + mountTestData(t, c, "app") + r := c.Data().ListNames("app/.") assert.True(t, r.OK) assert.Contains(t, r.Value.([]string), "test") } func TestData_Extract_Good(t *testing.T) { c := New() - c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}}) - r := c.Data().Extract("app/testdata", t.TempDir(), nil) + mountTestData(t, c, "app") + r := c.Data().Extract("app/.", t.TempDir(), nil) assert.True(t, r.OK) } diff --git a/drive.go b/drive.go index e6988c4..2d9f7e6 100644 --- a/drive.go +++ b/drive.go @@ -6,18 +6,18 @@ // // Register a transport: // -// c.Drive().New(core.Options{ -// {Key: "name", Value: "api"}, -// {Key: "transport", Value: "https://api.lthn.ai"}, -// }) -// c.Drive().New(core.Options{ -// {Key: "name", Value: "ssh"}, -// {Key: "transport", Value: "ssh://claude@10.69.69.165"}, -// }) -// c.Drive().New(core.Options{ -// {Key: "name", Value: "mcp"}, -// {Key: "transport", Value: "mcp://mcp.lthn.sh"}, -// }) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "api"}, +// core.Option{Key: "transport", Value: "https://api.lthn.ai"}, +// )) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "ssh"}, +// core.Option{Key: "transport", Value: "ssh://claude@10.69.69.165"}, +// )) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "mcp"}, +// core.Option{Key: "transport", Value: "mcp://mcp.lthn.sh"}, +// )) // // Retrieve a handle: // @@ -43,10 +43,10 @@ type Drive struct { // New registers a transport handle. // -// c.Drive().New(core.Options{ -// {Key: "name", Value: "api"}, -// {Key: "transport", Value: "https://api.lthn.ai"}, -// }) +// c.Drive().New(core.NewOptions( +// core.Option{Key: "name", Value: "api"}, +// core.Option{Key: "transport", Value: "https://api.lthn.ai"}, +// )) func (d *Drive) New(opts Options) Result { name := opts.String("name") if name == "" { @@ -62,12 +62,10 @@ func (d *Drive) New(opts Options) Result { d.handles = make(map[string]*DriveHandle) } - cp := make(Options, len(opts)) - copy(cp, opts) handle := &DriveHandle{ Name: name, Transport: transport, - Options: cp, + Options: opts, } d.handles[name] = handle diff --git a/drive_test.go b/drive_test.go index 8152876..c3bd706 100644 --- a/drive_test.go +++ b/drive_test.go @@ -11,10 +11,10 @@ import ( func TestDrive_New_Good(t *testing.T) { c := New() - r := c.Drive().New(Options{ - {Key: "name", Value: "api"}, - {Key: "transport", Value: "https://api.lthn.ai"}, - }) + r := c.Drive().New(NewOptions( + Option{Key: "name", Value: "api"}, + Option{Key: "transport", Value: "https://api.lthn.ai"}, + )) assert.True(t, r.OK) assert.Equal(t, "api", r.Value.(*DriveHandle).Name) assert.Equal(t, "https://api.lthn.ai", r.Value.(*DriveHandle).Transport) @@ -23,18 +23,18 @@ func TestDrive_New_Good(t *testing.T) { func TestDrive_New_Bad(t *testing.T) { c := New() // Missing name - r := c.Drive().New(Options{ - {Key: "transport", Value: "https://api.lthn.ai"}, - }) + r := c.Drive().New(NewOptions( + Option{Key: "transport", Value: "https://api.lthn.ai"}, + )) assert.False(t, r.OK) } func TestDrive_Get_Good(t *testing.T) { c := New() - c.Drive().New(Options{ - {Key: "name", Value: "ssh"}, - {Key: "transport", Value: "ssh://claude@10.69.69.165"}, - }) + c.Drive().New(NewOptions( + Option{Key: "name", Value: "ssh"}, + Option{Key: "transport", Value: "ssh://claude@10.69.69.165"}, + )) r := c.Drive().Get("ssh") assert.True(t, r.OK) handle := r.Value.(*DriveHandle) @@ -49,16 +49,16 @@ func TestDrive_Get_Bad(t *testing.T) { func TestDrive_Has_Good(t *testing.T) { c := New() - c.Drive().New(Options{{Key: "name", Value: "mcp"}, {Key: "transport", Value: "mcp://mcp.lthn.sh"}}) + c.Drive().New(NewOptions(Option{Key: "name", Value: "mcp"}, Option{Key: "transport", Value: "mcp://mcp.lthn.sh"})) assert.True(t, c.Drive().Has("mcp")) assert.False(t, c.Drive().Has("missing")) } func TestDrive_Names_Good(t *testing.T) { c := New() - c.Drive().New(Options{{Key: "name", Value: "api"}, {Key: "transport", Value: "https://api.lthn.ai"}}) - c.Drive().New(Options{{Key: "name", Value: "ssh"}, {Key: "transport", Value: "ssh://claude@10.69.69.165"}}) - c.Drive().New(Options{{Key: "name", Value: "mcp"}, {Key: "transport", Value: "mcp://mcp.lthn.sh"}}) + c.Drive().New(NewOptions(Option{Key: "name", Value: "api"}, Option{Key: "transport", Value: "https://api.lthn.ai"})) + c.Drive().New(NewOptions(Option{Key: "name", Value: "ssh"}, Option{Key: "transport", Value: "ssh://claude@10.69.69.165"})) + c.Drive().New(NewOptions(Option{Key: "name", Value: "mcp"}, Option{Key: "transport", Value: "mcp://mcp.lthn.sh"})) names := c.Drive().Names() assert.Len(t, names, 3) assert.Contains(t, names, "api") @@ -68,11 +68,11 @@ func TestDrive_Names_Good(t *testing.T) { func TestDrive_OptionsPreserved_Good(t *testing.T) { c := New() - c.Drive().New(Options{ - {Key: "name", Value: "api"}, - {Key: "transport", Value: "https://api.lthn.ai"}, - {Key: "timeout", Value: 30}, - }) + c.Drive().New(NewOptions( + Option{Key: "name", Value: "api"}, + Option{Key: "transport", Value: "https://api.lthn.ai"}, + Option{Key: "timeout", Value: 30}, + )) r := c.Drive().Get("api") assert.True(t, r.OK) handle := r.Value.(*DriveHandle) diff --git a/embed.go b/embed.go index e6a5766..7951543 100644 --- a/embed.go +++ b/embed.go @@ -396,7 +396,7 @@ func (s *Embed) ReadDir(name string) Result { if !r.OK { return r } - return Result{}.Result(fs.ReadDir(s.fsys, r.Value.(string))) + return Result{}.New(fs.ReadDir(s.fsys, r.Value.(string))) } // ReadFile reads the named file. diff --git a/embed_test.go b/embed_test.go index 99fc7cd..e666a6e 100644 --- a/embed_test.go +++ b/embed_test.go @@ -13,6 +13,14 @@ import ( // --- Mount --- +func mustMountTestFS(t *testing.T, basedir string) *Embed { + t.Helper() + + r := Mount(testFS, basedir) + assert.True(t, r.OK) + return r.Value.(*Embed) +} + func TestMount_Good(t *testing.T) { r := Mount(testFS, "testdata") assert.True(t, r.OK) @@ -26,34 +34,34 @@ func TestMount_Bad(t *testing.T) { // --- Embed methods --- func TestEmbed_ReadFile_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.ReadFile("test.txt") assert.True(t, r.OK) assert.Equal(t, "hello from testdata\n", string(r.Value.([]byte))) } func TestEmbed_ReadString_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.ReadString("test.txt") assert.True(t, r.OK) assert.Equal(t, "hello from testdata\n", r.Value.(string)) } func TestEmbed_Open_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.Open("test.txt") assert.True(t, r.OK) } func TestEmbed_ReadDir_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.ReadDir(".") assert.True(t, r.OK) assert.NotEmpty(t, r.Value) } func TestEmbed_Sub_Good(t *testing.T) { - emb := Mount(testFS, ".").Value.(*Embed) + emb := mustMountTestFS(t, ".") r := emb.Sub("testdata") assert.True(t, r.OK) sub := r.Value.(*Embed) @@ -62,17 +70,17 @@ func TestEmbed_Sub_Good(t *testing.T) { } func TestEmbed_BaseDir_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") assert.Equal(t, "testdata", emb.BaseDirectory()) } func TestEmbed_FS_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") assert.NotNil(t, emb.FS()) } func TestEmbed_EmbedFS_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") efs := emb.EmbedFS() _, err := efs.ReadFile("testdata/test.txt") assert.NoError(t, err) @@ -204,13 +212,13 @@ func TestExtract_BadTargetDir_Ugly(t *testing.T) { } func TestEmbed_PathTraversal_Ugly(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.ReadFile("../../etc/passwd") assert.False(t, r.OK) } func TestEmbed_Sub_BaseDir_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.Sub("scantest") assert.True(t, r.OK) sub := r.Value.(*Embed) @@ -218,19 +226,19 @@ func TestEmbed_Sub_BaseDir_Good(t *testing.T) { } func TestEmbed_Open_Bad(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.Open("nonexistent.txt") assert.False(t, r.OK) } func TestEmbed_ReadDir_Bad(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") r := emb.ReadDir("nonexistent") assert.False(t, r.OK) } func TestEmbed_EmbedFS_Original_Good(t *testing.T) { - emb := Mount(testFS, "testdata").Value.(*Embed) + emb := mustMountTestFS(t, "testdata") efs := emb.EmbedFS() _, err := efs.ReadFile("testdata/test.txt") assert.NoError(t, err) diff --git a/fs.go b/fs.go index 249ddaf..c528308 100644 --- a/fs.go +++ b/fs.go @@ -13,6 +13,18 @@ type Fs struct { root string } +// New initialises an Fs with the given root directory. +// Root "/" means unrestricted access. Empty root defaults to "/". +// +// fs := (&core.Fs{}).New("/") +func (m *Fs) New(root string) *Fs { + if root == "" { + root = "/" + } + m.root = root + return m +} + // path sanitises and returns the full path. // Absolute paths are sandboxed under root (unless root is "/"). // Empty root defaults to "/" — the zero value of Fs is usable. @@ -190,7 +202,7 @@ func (m *Fs) List(p string) Result { if !vp.OK { return vp } - return Result{}.Result(os.ReadDir(vp.Value.(string))) + return Result{}.New(os.ReadDir(vp.Value.(string))) } // Stat returns file info. @@ -199,7 +211,7 @@ func (m *Fs) Stat(p string) Result { if !vp.OK { return vp } - return Result{}.Result(os.Stat(vp.Value.(string))) + return Result{}.New(os.Stat(vp.Value.(string))) } // Open opens the named file for reading. @@ -208,7 +220,7 @@ func (m *Fs) Open(p string) Result { if !vp.OK { return vp } - return Result{}.Result(os.Open(vp.Value.(string))) + return Result{}.New(os.Open(vp.Value.(string))) } // Create creates or truncates the named file. @@ -221,7 +233,7 @@ func (m *Fs) Create(p string) Result { if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { return Result{err, false} } - return Result{}.Result(os.Create(full)) + return Result{}.New(os.Create(full)) } // Append opens the named file for appending, creating it if it doesn't exist. @@ -234,7 +246,7 @@ func (m *Fs) Append(p string) Result { if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { return Result{err, false} } - return Result{}.Result(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)) + return Result{}.New(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)) } // ReadStream returns a reader for the file content. diff --git a/i18n_test.go b/i18n_test.go index 4520721..956ee12 100644 --- a/i18n_test.go +++ b/i18n_test.go @@ -16,11 +16,11 @@ func TestI18n_Good(t *testing.T) { func TestI18n_AddLocales_Good(t *testing.T) { c := New() - r := c.Data().New(Options{ - {Key: "name", Value: "lang"}, - {Key: "source", Value: testFS}, - {Key: "path", Value: "testdata"}, - }) + r := c.Data().New(NewOptions( + Option{Key: "name", Value: "lang"}, + Option{Key: "source", Value: testFS}, + Option{Key: "path", Value: "testdata"}, + )) if r.OK { c.I18n().AddLocales(r.Value.(*Embed)) } diff --git a/ipc.go b/ipc.go index 5f22c6f..6f0f99f 100644 --- a/ipc.go +++ b/ipc.go @@ -12,6 +12,8 @@ import ( ) // Ipc holds IPC dispatch data. +// +// ipc := (&core.Ipc{}).New() type Ipc struct { ipcMu sync.RWMutex ipcHandlers []func(*Core, Message) Result diff --git a/lock.go b/lock.go index a87181d..539aaab 100644 --- a/lock.go +++ b/lock.go @@ -8,27 +8,27 @@ import ( "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 Mutex *sync.RWMutex + mu sync.Mutex // protects locks map + locks map[string]*sync.RWMutex // per-Core named mutexes } // Lock returns a named Lock, creating the mutex if needed. +// Locks are per-Core — separate Core instances do not share mutexes. func (c *Core) Lock(name string) *Lock { - lockMu.Lock() - m, ok := lockMap[name] + c.lock.mu.Lock() + if c.lock.locks == nil { + c.lock.locks = make(map[string]*sync.RWMutex) + } + m, ok := c.lock.locks[name] if !ok { m = &sync.RWMutex{} - lockMap[name] = m + c.lock.locks[name] = m } - lockMu.Unlock() + c.lock.mu.Unlock() return &Lock{Name: name, Mutex: m} } diff --git a/options.go b/options.go index 4d4c5f8..cf730a2 100644 --- a/options.go +++ b/options.go @@ -2,42 +2,24 @@ // Core primitives: Option, Options, Result. // -// Option is a single key-value pair. Options is a collection. -// Any function that returns Result can accept Options. +// Options is the universal input type. Result is the universal output type. +// All Core operations accept Options and return Result. // -// Create options: -// -// opts := core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "path", Value: "prompts"}, -// } -// -// Read options: -// -// name := opts.String("name") -// port := opts.Int("port") -// ok := opts.Has("debug") -// -// Use with subsystems: -// -// c.Drive().New(core.Options{ -// {Key: "name", Value: "brain"}, -// {Key: "source", Value: brainFS}, -// {Key: "path", Value: "prompts"}, -// }) -// -// Use with New: -// -// c := core.New(core.Options{ -// {Key: "name", Value: "myapp"}, -// }) +// opts := core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "path", Value: "prompts"}, +// ) +// r := c.Drive().New(opts) +// if !r.OK { log.Fatal(r.Error()) } package core +// --- Result: Universal Output --- + // Result is the universal return type for Core operations. // Replaces the (value, error) pattern — errors flow through Core internally. // -// r := c.Data().New(core.Options{{Key: "name", Value: "brain"}}) -// if r.OK { use(r.Result()) } +// r := c.Data().New(opts) +// if !r.OK { core.Error("failed", "err", r.Error()) } type Result struct { Value any OK bool @@ -53,18 +35,43 @@ func (r Result) Result(args ...any) Result { if len(args) == 0 { return r } + return r.New(args...) +} - if len(args) == 1 { - return Result{args[0], true} +func (r Result) New(args ...any) Result { + if len(args) == 0 { + return r } - if err, ok := args[len(args)-1].(error); ok { - if err != nil { - return Result{err, false} + if len(args) > 1 { + if err, ok := args[len(args)-1].(error); ok { + if err != nil { + return Result{Value: err, OK: false} + } + r.Value = args[0] + r.OK = true + return r } - return Result{args[0], true} } - return Result{args[0], true} + + r.Value = args[0] + + if err, ok := r.Value.(error); ok { + if err != nil { + return Result{Value: err, OK: false} + } + return Result{OK: true} + } + + r.OK = true + return r +} + +func (r Result) Get() Result { + if r.OK { + return r + } + return Result{Value: r.Value, OK: false} } // Option is a single key-value configuration pair. @@ -76,19 +83,51 @@ type Option struct { Value any } -// Options is a collection of Option items. -// The universal input type for Core operations. +// --- Options: Universal Input --- + +// Options is the universal input type for Core operations. +// A structured collection of key-value pairs with typed accessors. // -// opts := core.Options{{Key: "name", Value: "myapp"}} +// opts := core.NewOptions( +// core.Option{Key: "name", Value: "myapp"}, +// core.Option{Key: "port", Value: 8080}, +// ) // name := opts.String("name") -type Options []Option +type Options struct { + items []Option +} + +// NewOptions creates an Options collection from key-value pairs. +// +// opts := core.NewOptions( +// core.Option{Key: "name", Value: "brain"}, +// core.Option{Key: "path", Value: "prompts"}, +// ) +func NewOptions(items ...Option) Options { + cp := make([]Option, len(items)) + copy(cp, items) + return Options{items: cp} +} + +// Set adds or updates a key-value pair. +// +// opts.Set("port", 8080) +func (o *Options) Set(key string, value any) { + for i, opt := range o.items { + if opt.Key == key { + o.items[i].Value = value + return + } + } + o.items = append(o.items, Option{Key: key, Value: value}) +} // Get retrieves a value by key. // // r := opts.Get("name") // if r.OK { name := r.Value.(string) } func (o Options) Get(key string) Result { - for _, opt := range o { + for _, opt := range o.items { if opt.Key == key { return Result{opt.Value, true} } @@ -138,3 +177,15 @@ func (o Options) Bool(key string) bool { b, _ := r.Value.(bool) return b } + +// Len returns the number of options. +func (o Options) Len() int { + return len(o.items) +} + +// Items returns a copy of the underlying option slice. +func (o Options) Items() []Option { + cp := make([]Option, len(o.items)) + copy(cp, o.items) + return cp +} diff --git a/options_test.go b/options_test.go index 4556062..751d008 100644 --- a/options_test.go +++ b/options_test.go @@ -7,75 +7,121 @@ import ( "github.com/stretchr/testify/assert" ) -// --- Option / Options --- +// --- NewOptions --- + +func TestNewOptions_Good(t *testing.T) { + opts := NewOptions( + Option{Key: "name", Value: "brain"}, + Option{Key: "port", Value: 8080}, + ) + assert.Equal(t, 2, opts.Len()) +} + +func TestNewOptions_Empty_Good(t *testing.T) { + opts := NewOptions() + assert.Equal(t, 0, opts.Len()) + assert.False(t, opts.Has("anything")) +} + +// --- Options.Set --- + +func TestOptions_Set_Good(t *testing.T) { + opts := NewOptions() + opts.Set("name", "brain") + assert.Equal(t, "brain", opts.String("name")) +} + +func TestOptions_Set_Update_Good(t *testing.T) { + opts := NewOptions(Option{Key: "name", Value: "old"}) + opts.Set("name", "new") + assert.Equal(t, "new", opts.String("name")) + assert.Equal(t, 1, opts.Len()) +} + +// --- Options.Get --- func TestOptions_Get_Good(t *testing.T) { - opts := Options{ - {Key: "name", Value: "brain"}, - {Key: "port", Value: 8080}, - } + opts := NewOptions( + Option{Key: "name", Value: "brain"}, + Option{Key: "port", Value: 8080}, + ) r := opts.Get("name") assert.True(t, r.OK) assert.Equal(t, "brain", r.Value) } func TestOptions_Get_Bad(t *testing.T) { - opts := Options{{Key: "name", Value: "brain"}} + opts := NewOptions(Option{Key: "name", Value: "brain"}) r := opts.Get("missing") assert.False(t, r.OK) assert.Nil(t, r.Value) } +// --- Options.Has --- + func TestOptions_Has_Good(t *testing.T) { - opts := Options{{Key: "debug", Value: true}} + opts := NewOptions(Option{Key: "debug", Value: true}) assert.True(t, opts.Has("debug")) assert.False(t, opts.Has("missing")) } +// --- Options.String --- + func TestOptions_String_Good(t *testing.T) { - opts := Options{{Key: "name", Value: "brain"}} + opts := NewOptions(Option{Key: "name", Value: "brain"}) assert.Equal(t, "brain", opts.String("name")) } func TestOptions_String_Bad(t *testing.T) { - opts := Options{{Key: "port", Value: 8080}} - // Wrong type — returns empty string + opts := NewOptions(Option{Key: "port", Value: 8080}) assert.Equal(t, "", opts.String("port")) - // Missing key — returns empty string assert.Equal(t, "", opts.String("missing")) } +// --- Options.Int --- + func TestOptions_Int_Good(t *testing.T) { - opts := Options{{Key: "port", Value: 8080}} + opts := NewOptions(Option{Key: "port", Value: 8080}) assert.Equal(t, 8080, opts.Int("port")) } func TestOptions_Int_Bad(t *testing.T) { - opts := Options{{Key: "name", Value: "brain"}} + opts := NewOptions(Option{Key: "name", Value: "brain"}) assert.Equal(t, 0, opts.Int("name")) assert.Equal(t, 0, opts.Int("missing")) } +// --- Options.Bool --- + func TestOptions_Bool_Good(t *testing.T) { - opts := Options{{Key: "debug", Value: true}} + opts := NewOptions(Option{Key: "debug", Value: true}) assert.True(t, opts.Bool("debug")) } func TestOptions_Bool_Bad(t *testing.T) { - opts := Options{{Key: "name", Value: "brain"}} + opts := NewOptions(Option{Key: "name", Value: "brain"}) assert.False(t, opts.Bool("name")) assert.False(t, opts.Bool("missing")) } +// --- Options.Items --- + +func TestOptions_Items_Good(t *testing.T) { + opts := NewOptions(Option{Key: "a", Value: 1}, Option{Key: "b", Value: 2}) + items := opts.Items() + assert.Len(t, items, 2) +} + +// --- Options with typed struct --- + func TestOptions_TypedStruct_Good(t *testing.T) { - // Packages plug typed structs into Option.Value type BrainConfig struct { Name string OllamaURL string Collection string } cfg := BrainConfig{Name: "brain", OllamaURL: "http://localhost:11434", Collection: "openbrain"} - opts := Options{{Key: "config", Value: cfg}} + opts := NewOptions(Option{Key: "config", Value: cfg}) r := opts.Get("config") assert.True(t, r.OK) @@ -85,10 +131,47 @@ func TestOptions_TypedStruct_Good(t *testing.T) { assert.Equal(t, "http://localhost:11434", bc.OllamaURL) } -func TestOptions_Empty_Good(t *testing.T) { - opts := Options{} - assert.False(t, opts.Has("anything")) - assert.Equal(t, "", opts.String("anything")) - assert.Equal(t, 0, opts.Int("anything")) - assert.False(t, opts.Bool("anything")) +// --- Result --- + +func TestResult_New_Good(t *testing.T) { + r := Result{}.New("value") + assert.Equal(t, "value", r.Value) +} + +func TestResult_New_Error_Bad(t *testing.T) { + err := E("test", "failed", nil) + r := Result{}.New(err) + assert.False(t, r.OK) + assert.Equal(t, err, r.Value) +} + +func TestResult_Result_Good(t *testing.T) { + r := Result{Value: "hello", OK: true} + assert.Equal(t, r, r.Result()) +} + +func TestResult_Result_WithArgs_Good(t *testing.T) { + r := Result{}.Result("value") + assert.Equal(t, "value", r.Value) +} + +func TestResult_Get_Good(t *testing.T) { + r := Result{Value: "hello", OK: true} + assert.True(t, r.Get().OK) +} + +func TestResult_Get_Bad(t *testing.T) { + r := Result{Value: "err", OK: false} + assert.False(t, r.Get().OK) +} + +// --- WithOption --- + +func TestWithOption_Good(t *testing.T) { + c := New( + WithOption("name", "myapp"), + WithOption("port", 8080), + ) + assert.Equal(t, "myapp", c.App().Name) + assert.Equal(t, 8080, c.Options().Int("port")) } diff --git a/runtime.go b/runtime.go index 952001d..a0fab83 100644 --- a/runtime.go +++ b/runtime.go @@ -106,7 +106,7 @@ type ServiceFactory func() Result // NewWithFactories creates a Runtime with the provided service factories. func NewWithFactories(app any, factories map[string]ServiceFactory) Result { - c := New(Options{{Key: "name", Value: "core"}}) + c := New(WithOptions(NewOptions(Option{Key: "name", Value: "core"}))) c.app.Runtime = app names := slices.Sorted(maps.Keys(factories)) diff --git a/service.go b/service.go index 1e82dd6..14324db 100644 --- a/service.go +++ b/service.go @@ -2,9 +2,13 @@ // Service registry for the Core framework. // -// Register a service: +// Register a service (DTO with lifecycle hooks): // -// c.Service("auth", core.Service{}) +// c.Service("auth", core.Service{OnStart: startFn}) +// +// Register a service instance (auto-discovers Startable/Stoppable/HandleIPCEvents): +// +// c.RegisterService("display", displayInstance) // // Get a service: // @@ -13,11 +17,12 @@ package core -// No imports needed — uses package-level string helpers. +import "context" // Service is a managed component with optional lifecycle. type Service struct { Name string + Instance any // the raw service instance (for interface discovery) Options Options OnStart func() Result OnStop func() Result @@ -40,9 +45,16 @@ type serviceRegistry struct { func (c *Core) Service(name string, service ...Service) Result { if len(service) == 0 { c.Lock("srv").Mutex.RLock() - v, ok := c.services.services[name] + svc, ok := c.services.services[name] c.Lock("srv").Mutex.RUnlock() - return Result{v, ok} + if !ok || svc == nil { + return Result{} + } + // Return the instance if available, otherwise the Service DTO + if svc.Instance != nil { + return Result{svc.Instance, true} + } + return Result{svc, true} } if name == "" { @@ -66,6 +78,85 @@ func (c *Core) Service(name string, service ...Service) Result { return Result{OK: true} } +// RegisterService registers a service instance by name. +// Auto-discovers Startable, Stoppable, and HandleIPCEvents interfaces +// on the instance and wires them into the lifecycle and IPC bus. +// +// c.RegisterService("display", displayInstance) +func (c *Core) RegisterService(name string, instance any) Result { + if name == "" { + return Result{E("core.RegisterService", "service name cannot be empty", nil), false} + } + + c.Lock("srv").Mutex.Lock() + defer c.Lock("srv").Mutex.Unlock() + + if c.services.locked { + return Result{E("core.RegisterService", Concat("service \"", name, "\" not permitted — registry locked"), nil), false} + } + if _, exists := c.services.services[name]; exists { + return Result{E("core.RegisterService", Join(" ", "service", name, "already registered"), nil), false} + } + + srv := &Service{Name: name, Instance: instance} + + // Auto-discover lifecycle interfaces + if s, ok := instance.(Startable); ok { + srv.OnStart = func() Result { + if err := s.OnStartup(c.context); err != nil { + return Result{err, false} + } + return Result{OK: true} + } + } + if s, ok := instance.(Stoppable); ok { + srv.OnStop = func() Result { + if err := s.OnShutdown(context.Background()); err != nil { + return Result{err, false} + } + return Result{OK: true} + } + } + + c.services.services[name] = srv + + // Auto-discover IPC handler + if handler, ok := instance.(interface { + HandleIPCEvents(*Core, Message) Result + }); ok { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler.HandleIPCEvents) + c.ipc.ipcMu.Unlock() + } + + return Result{OK: true} +} + +// ServiceFor retrieves a registered service by name and asserts its type. +// +// prep, ok := core.ServiceFor[*agentic.PrepSubsystem](c, "agentic") +func ServiceFor[T any](c *Core, name string) (T, bool) { + var zero T + r := c.Service(name) + if !r.OK { + return zero, false + } + typed, ok := r.Value.(T) + return typed, ok +} + +// MustServiceFor retrieves a registered service by name and asserts its type. +// Panics if the service is not found or the type assertion fails. +// +// cli := core.MustServiceFor[*Cli](c, "cli") +func MustServiceFor[T any](c *Core, name string) T { + v, ok := ServiceFor[T](c, name) + if !ok { + panic(E("core.MustServiceFor", Sprintf("service %q not found or wrong type", name), nil)) + } + return v +} + // Services returns all registered service names. // // names := c.Services() diff --git a/service_test.go b/service_test.go index 0b1a609..6bc2617 100644 --- a/service_test.go +++ b/service_test.go @@ -1,6 +1,7 @@ package core_test import ( + "context" "testing" . "dappco.re/go/core" @@ -47,9 +48,9 @@ func TestService_Names_Good(t *testing.T) { c.Service("a", Service{}) c.Service("b", Service{}) names := c.Services() - assert.Len(t, names, 2) assert.Contains(t, names, "a") assert.Contains(t, names, "b") + assert.Contains(t, names, "cli") // auto-registered by CliRegister in New() } // --- Service Lifecycle --- @@ -77,3 +78,113 @@ func TestService_Lifecycle_Good(t *testing.T) { stoppables[0].OnStop() assert.True(t, stopped) } + +type autoLifecycleService struct { + started bool + stopped bool + messages []Message +} + +func (s *autoLifecycleService) OnStartup(_ context.Context) error { + s.started = true + return nil +} + +func (s *autoLifecycleService) OnShutdown(_ context.Context) error { + s.stopped = true + return nil +} + +func (s *autoLifecycleService) HandleIPCEvents(_ *Core, msg Message) Result { + s.messages = append(s.messages, msg) + return Result{OK: true} +} + +func TestService_RegisterService_Bad(t *testing.T) { + t.Run("EmptyName", func(t *testing.T) { + c := New() + r := c.RegisterService("", "value") + assert.False(t, r.OK) + + err, ok := r.Value.(error) + if assert.True(t, ok) { + assert.Equal(t, "core.RegisterService", Operation(err)) + } + }) + + t.Run("DuplicateName", func(t *testing.T) { + c := New() + assert.True(t, c.RegisterService("svc", "first").OK) + + r := c.RegisterService("svc", "second") + assert.False(t, r.OK) + }) + + t.Run("LockedRegistry", func(t *testing.T) { + c := New() + c.LockEnable() + c.LockApply() + + r := c.RegisterService("blocked", "value") + assert.False(t, r.OK) + }) +} + +func TestService_RegisterService_Ugly(t *testing.T) { + t.Run("AutoDiscoversLifecycleAndIPCHandlers", func(t *testing.T) { + c := New() + svc := &autoLifecycleService{} + + r := c.RegisterService("auto", svc) + assert.True(t, r.OK) + assert.True(t, c.ServiceStartup(context.Background(), nil).OK) + assert.True(t, c.ACTION("ping").OK) + assert.True(t, c.ServiceShutdown(context.Background()).OK) + assert.True(t, svc.started) + assert.True(t, svc.stopped) + assert.Contains(t, svc.messages, Message("ping")) + }) + + t.Run("NilInstanceReturnsServiceDTO", func(t *testing.T) { + c := New() + assert.True(t, c.RegisterService("nil", nil).OK) + + r := c.Service("nil") + if assert.True(t, r.OK) { + svc, ok := r.Value.(*Service) + if assert.True(t, ok) { + assert.Equal(t, "nil", svc.Name) + assert.Nil(t, svc.Instance) + } + } + }) +} + +func TestService_ServiceFor_Bad(t *testing.T) { + typed, ok := ServiceFor[string](New(), "missing") + assert.False(t, ok) + assert.Equal(t, "", typed) +} + +func TestService_ServiceFor_Ugly(t *testing.T) { + c := New() + assert.True(t, c.RegisterService("value", "hello").OK) + + typed, ok := ServiceFor[int](c, "value") + assert.False(t, ok) + assert.Equal(t, 0, typed) +} + +func TestService_MustServiceFor_Bad(t *testing.T) { + c := New() + assert.PanicsWithError(t, `core.MustServiceFor: service "missing" not found or wrong type`, func() { + _ = MustServiceFor[string](c, "missing") + }) +} + +func TestService_MustServiceFor_Ugly(t *testing.T) { + var c *Core + assert.Panics(t, func() { + _ = MustServiceFor[string](c, "missing") + }) +}