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 406cdb4..c3b5e44 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(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core) +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")).Value.(*Core) assert.Equal(t, "myapp", c.App().Name) } -func TestApp_Empty_Good(t *testing.T) { +func TestApp_Core_Empty_Good(t *testing.T) { c := New().Value.(*Core) 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 f29a467..d55792d 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(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core) + c := New(WithOption("name", "myapp")).Value.(*Core) 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(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core) + c := New(WithOption("name", "myapp")).Value.(*Core) 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 3e0bb91..6827fdf 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().Value.(*Core) 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/contract.go b/contract.go index ddd9904..b32e7e5 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). @@ -81,7 +82,7 @@ type CoreOption func(*Core) Result // IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown). // // r := core.New( -// core.WithOptions(core.Options{{Key: "name", Value: "myapp"}}), +// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})), // core.WithService(auth.Register), // core.WithServiceLock(), // ) @@ -104,7 +105,9 @@ func New(opts ...CoreOption) Result { commands: &commandRegistry{commands: make(map[string]*Command)}, } c.context, c.cancel = context.WithCancel(context.Background()) - c.cli = &Cli{core: c} + + // Core services + CliRegister(c) for _, opt := range opts { if r := opt(c); !r.OK { @@ -112,12 +115,15 @@ func New(opts ...CoreOption) Result { } } + // Apply service lock after all opts — v0.3.3 parity + c.LockApply() + return Result{c, true} } // WithOptions applies key-value configuration to Core. // -// core.WithOptions(core.Options{{Key: "name", Value: "myapp"}}) +// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})) func WithOptions(opts Options) CoreOption { return func(c *Core) Result { c.options = &opts @@ -129,16 +135,89 @@ func WithOptions(opts Options) CoreOption { } // WithService registers a service via its factory function. -// The factory receives *Core so the service can wire IPC handlers -// and access other subsystems during construction. -// Service name is auto-discovered from the package path. -// If the service implements HandleIPCEvents, it is auto-registered. +// 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 { - return factory(c) + 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} + } + + // IPC handler discovery + instanceValue := reflect.ValueOf(instance) + handlerMethod := instanceValue.MethodByName("HandleIPCEvents") + if handlerMethod.IsValid() { + if handler, ok := handlerMethod.Interface().(func(*Core, Message) Result); ok { + c.RegisterAction(handler) + } + } + + 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} } } @@ -151,7 +230,6 @@ func WithService(factory func(*Core) Result) CoreOption { func WithServiceLock() CoreOption { return func(c *Core) Result { c.LockEnable() - c.LockApply() return Result{OK: true} } } diff --git a/contract_test.go b/contract_test.go new file mode 100644 index 0000000..4b109a6 --- /dev/null +++ b/contract_test.go @@ -0,0 +1,135 @@ +// 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) { + r := New(WithService(stubFactory)) + assert.True(t, r.OK) + c := r.Value.(*Core) + + 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} + } + + r := New(WithService(selfReg)) + assert.True(t, r.OK) + c := r.Value.(*Core) + + // "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) { + r := New( + WithName("custom", func(c *Core) Result { + return Result{Value: &stubNamedService{}, OK: true} + }), + ) + assert.True(t, r.OK) + c := r.Value.(*Core) + 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{} + r := New( + WithService(func(c *Core) Result { + return Result{Value: svc, OK: true} + }), + ) + assert.True(t, r.OK) + c := r.Value.(*Core) + + 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{} + r := New( + WithService(func(c *Core) Result { + return Result{Value: svc, OK: true} + }), + ) + assert.True(t, r.OK) + c := r.Value.(*Core) + + c.ACTION("ping") + assert.Equal(t, "ping", svc.received) +} + +// --- Error --- + +// TestWithService_FactoryError_Bad verifies that a factory returning an error +// causes New() to stop and propagate the failure. +func TestWithService_FactoryError_Bad(t *testing.T) { + r := New(WithService(func(c *Core) Result { + return Result{Value: E("test", "factory failed", nil), OK: false} + })) + assert.False(t, r.OK, "expected New() to fail when factory returns error") +} diff --git a/core.go b/core.go index fb9c5d9..a80e092 100644 --- a/core.go +++ b/core.go @@ -15,15 +15,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,7 +49,10 @@ 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) } diff --git a/core_test.go b/core_test.go index 4cf6c29..d5745e8 100644 --- a/core_test.go +++ b/core_test.go @@ -16,21 +16,21 @@ func TestNew_Good(t *testing.T) { } func TestNew_WithOptions_Good(t *testing.T) { - c := New(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core) + c := New(WithOptions(NewOptions(Option{Key: "name", Value: "myapp"}))).Value.(*Core) 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(WithOptions(Options{})).Value.(*Core) + c := New(WithOptions(NewOptions())).Value.(*Core) assert.NotNil(t, c) } func TestNew_WithService_Good(t *testing.T) { started := false r := New( - WithOptions(Options{{Key: "name", Value: "myapp"}}), + 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} }, @@ -64,6 +64,21 @@ func TestNew_WithServiceLock_Good(t *testing.T) { assert.False(t, reg.OK) } +func TestNew_WithService_Bad_FailingOption(t *testing.T) { + secondCalled := false + r := 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, r.OK) + assert.False(t, secondCalled, "second option should not run after first fails") +} + // --- Accessors --- func TestAccessors_Good(t *testing.T) { @@ -82,11 +97,11 @@ func TestAccessors_Good(t *testing.T) { } func TestOptions_Accessor_Good(t *testing.T) { - c := New(WithOptions(Options{ - {Key: "name", Value: "testapp"}, - {Key: "port", Value: 8080}, - {Key: "debug", Value: true}, - })).Value.(*Core) + c := New(WithOptions(NewOptions( + Option{Key: "name", Value: "testapp"}, + Option{Key: "port", Value: 8080}, + Option{Key: "debug", Value: true}, + ))).Value.(*Core) opts := c.Options() assert.NotNil(t, opts) assert.Equal(t, "testapp", opts.String("name")) 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 81ade81..4715b20 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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 afd6a34..bd79559 100644 --- a/drive_test.go +++ b/drive_test.go @@ -11,10 +11,10 @@ import ( func TestDrive_New_Good(t *testing.T) { c := New().Value.(*Core) - 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().Value.(*Core) // 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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().Value.(*Core) - 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..5a28d21 100644 --- a/fs.go +++ b/fs.go @@ -190,7 +190,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 +199,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 +208,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 +221,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 +234,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 3e6d8ca..3cb36c3 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().Value.(*Core) - 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/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..c839a3d 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), + ).Value.(*Core) + assert.Equal(t, "myapp", c.App().Name) + assert.Equal(t, 8080, c.Options().Int("port")) } diff --git a/runtime.go b/runtime.go index 3e48afb..f98840f 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 { - r := New(WithOptions(Options{{Key: "name", Value: "core"}})) + r := New(WithOptions(NewOptions(Option{Key: "name", Value: "core"}))) if !r.OK { return r } diff --git a/service.go b/service.go index 1e82dd6..1a420d9 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,10 @@ package core -// No imports needed — uses package-level string helpers. - // 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 @@ -66,6 +69,89 @@ 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(c.context); 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 + } + svc := r.Value.(*Service) + if svc.Instance == nil { + return zero, false + } + typed, ok := svc.Instance.(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 ddd32fd..63b49b8 100644 --- a/service_test.go +++ b/service_test.go @@ -47,9 +47,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 ---