From 177f73cc99a4f7fd1718f9096d747eba0dabd769 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 24 Mar 2026 20:02:53 +0000 Subject: [PATCH] feat: WithService with v0.3.3 name discovery + IPC handler auto-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WithService now calls factory, discovers service name from package path via reflect/runtime (last path segment, _test suffix stripped, lowercased), and calls RegisterService — which handles Startable/Stoppable/HandleIPCEvents - If factory returns nil Value (self-registered), WithService returns OK without a second registration - Add contract_test.go with _Good/_Bad tests covering all three code paths - Fix core.go Cli() accessor: use ServiceFor[*Cli](c, "cli") (was cli.New()) - Fix pre-existing })) → }}) syntax errors in command_test, service_test, lock_test - Fix pre-existing Options{...} → NewOptions(...) in core_test, data_test, drive_test, i18n_test (Options is a struct, not a slice) Co-Authored-By: Virgil --- command_test.go | 20 +++++++------- contract.go | 53 ++++++++++++++++++++++++++++++++++--- contract_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ core.go | 5 +++- core_test.go | 10 +++---- data_test.go | 10 +++---- drive_test.go | 32 +++++++++++----------- i18n_test.go | 10 +++---- lock_test.go | 4 +-- service_test.go | 2 +- 10 files changed, 167 insertions(+), 48 deletions(-) create mode 100644 contract_test.go diff --git a/command_test.go b/command_test.go index b3cd62d..6827fdf 100644 --- a/command_test.go +++ b/command_test.go @@ -13,13 +13,13 @@ func TestCommand_Register_Good(t *testing.T) { c := New().Value.(*Core) r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{Value: "deployed", OK: true} - })) + }}) assert.True(t, r.OK) } func TestCommand_Get_Good(t *testing.T) { c := New().Value.(*Core) - c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} })) + c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }}) r := c.Command("deploy") assert.True(t, r.OK) assert.NotNil(t, r.Value) @@ -35,7 +35,7 @@ func TestCommand_Run_Good(t *testing.T) { c := New().Value.(*Core) c.Command("greet", Command{Action: func(opts Options) Result { return Result{Value: Concat("hello ", opts.String("name")), OK: true} - })) + }}) cmd := c.Command("greet").Value.(*Command) r := cmd.Run(NewOptions(Option{Key: "name", Value: "world"})) assert.True(t, r.OK) @@ -56,7 +56,7 @@ func TestCommand_Nested_Good(t *testing.T) { c := New().Value.(*Core) c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{Value: "deployed to homelab", OK: true} - })) + }}) r := c.Command("deploy/to/homelab") assert.True(t, r.OK) @@ -68,9 +68,9 @@ func TestCommand_Nested_Good(t *testing.T) { func TestCommand_Paths_Good(t *testing.T) { c := New().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.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{OK: true} })) + 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.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{OK: true} }}) paths := c.Commands() assert.Contains(t, paths, "deploy") @@ -108,7 +108,7 @@ func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) { c := New().Value.(*Core) c.Command("serve", Command{Action: func(_ Options) Result { return Result{Value: "running", OK: true} - })) + }}) cmd := c.Command("serve").Value.(*Command) r := cmd.Start(NewOptions()) @@ -178,8 +178,8 @@ func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) { func TestCommand_Duplicate_Bad(t *testing.T) { c := New().Value.(*Core) - c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} })) - r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} })) + c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }}) + r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }}) assert.False(t, r.OK) } diff --git a/contract.go b/contract.go index 3fce199..81f11bc 100644 --- a/contract.go +++ b/contract.go @@ -6,6 +6,9 @@ package core import ( "context" + "reflect" + "runtime" + "strings" ) // Message is the type for IPC broadcasts (fire-and-forget). @@ -131,17 +134,61 @@ func WithOptions(opts Options) CoreOption { } // WithService registers a service via its factory function. -// The factory receives *Core and is responsible for calling c.Service() -// to register itself, and c.RegisterAction() for IPC handlers. +// 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 factory's package path. + name := serviceNameFromFactory(factory) + return c.RegisterService(name, r.Value) } } +// serviceNameFromFactory derives a canonical service name from a factory +// function's fully-qualified package path. +// +// "dappco.re/go/agentic.Register" → "agentic" +// "dappco.re/go/core_test.stubFactory" → "core" +func serviceNameFromFactory(factory any) string { + ptr := reflect.ValueOf(factory).Pointer() + fn := runtime.FuncForPC(ptr) + if fn == nil { + return "unknown" + } + full := fn.Name() // e.g. "dappco.re/go/agentic.Register" + + // Take the last path segment ("agentic.Register" or "core_test.stubFactory"). + if idx := strings.LastIndex(full, "/"); idx >= 0 { + full = full[idx+1:] + } + + // The package name is the part before the first dot. + if idx := strings.Index(full, "."); idx >= 0 { + full = full[:idx] + } + + // Strip the Go test package suffix so "core_test" → "core". + full = strings.TrimSuffix(full, "_test") + + return strings.ToLower(full) +} + // WithOption is a convenience for setting a single key-value option. // // core.New( diff --git a/contract_test.go b/contract_test.go new file mode 100644 index 0000000..e4d9794 --- /dev/null +++ b/contract_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package core_test + +import ( + "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() + // "core" is the name derived from package "core_test" (test suffix stripped). + assert.Contains(t, names, "core", "expected service registered under discovered package name 'core'") +} + +// 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") +} + +// 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 3b63fb8..a80e092 100644 --- a/core.go +++ b/core.go @@ -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 cli.New() } +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 601c37b..d5745e8 100644 --- a/core_test.go +++ b/core_test.go @@ -97,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_test.go b/data_test.go index 61972d9..da4c8d6 100644 --- a/data_test.go +++ b/data_test.go @@ -16,11 +16,11 @@ var testFS embed.FS 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) } diff --git a/drive_test.go b/drive_test.go index caa8440..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) @@ -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/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/lock_test.go b/lock_test.go index 7f19123..93b574a 100644 --- a/lock_test.go +++ b/lock_test.go @@ -40,7 +40,7 @@ func TestLockEnable_Good(t *testing.T) { func TestStartables_Good(t *testing.T) { c := New().Value.(*Core) - c.Service("s", Service{OnStart: func() Result { return Result{OK: true} })) + c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }}) r := c.Startables() assert.True(t, r.OK) assert.Len(t, r.Value.([]*Service), 1) @@ -48,7 +48,7 @@ func TestStartables_Good(t *testing.T) { func TestStoppables_Good(t *testing.T) { c := New().Value.(*Core) - c.Service("s", Service{OnStop: func() Result { return Result{OK: true} })) + c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }}) r := c.Stoppables() assert.True(t, r.OK) assert.Len(t, r.Value.([]*Service), 1) diff --git a/service_test.go b/service_test.go index 8dd3123..ddd32fd 100644 --- a/service_test.go +++ b/service_test.go @@ -30,7 +30,7 @@ func TestService_Register_Empty_Bad(t *testing.T) { func TestService_Get_Good(t *testing.T) { c := New().Value.(*Core) - c.Service("brain", Service{OnStart: func() Result { return Result{OK: true} })) + c.Service("brain", Service{OnStart: func() Result { return Result{OK: true} }}) r := c.Service("brain") assert.True(t, r.OK) assert.NotNil(t, r.Value)