feat: WithService with v0.3.3 name discovery + IPC handler auto-registration

- 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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-24 20:02:53 +00:00
parent 198ab839a8
commit 177f73cc99
10 changed files with 167 additions and 48 deletions

View file

@ -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)
}

View file

@ -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(

69
contract_test.go Normal file
View file

@ -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")
}

View file

@ -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) }

View file

@ -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"))

View file

@ -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)
}

View file

@ -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)

View file

@ -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))
}

View file

@ -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)

View file

@ -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)