dev #38

Merged
Snider merged 29 commits from dev into main 2026-03-24 23:26:43 +00:00
26 changed files with 1114 additions and 273 deletions

52
app.go
View file

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

View file

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

69
cli.go
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

133
contract_test.go Normal file
View file

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

54
core.go
View file

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

View file

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

20
data.go
View file

@ -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 == "" {

View file

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

View file

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

View file

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

View file

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

View file

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

22
fs.go
View file

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

View file

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

2
ipc.go
View file

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

20
lock.go
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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