Co-authored-by: Snider <snider@host.uk.com> Co-authored-by: Virgil <virgil@lthn.ai> Reviewed-on: #38
This commit is contained in:
parent
1450179b9c
commit
a6be0df3ea
26 changed files with 1114 additions and 273 deletions
52
app.go
52
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}
|
||||
|
|
|
|||
41
app_test.go
41
app_test.go
|
|
@ -7,14 +7,41 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- App ---
|
||||
// --- App.New ---
|
||||
|
||||
func TestApp_Good(t *testing.T) {
|
||||
c := New(Options{{Key: "name", Value: "myapp"}})
|
||||
func TestApp_New_Good(t *testing.T) {
|
||||
app := App{}.New(NewOptions(
|
||||
Option{Key: "name", Value: "myapp"},
|
||||
Option{Key: "version", Value: "1.0.0"},
|
||||
Option{Key: "description", Value: "test app"},
|
||||
))
|
||||
assert.Equal(t, "myapp", app.Name)
|
||||
assert.Equal(t, "1.0.0", app.Version)
|
||||
assert.Equal(t, "test app", app.Description)
|
||||
}
|
||||
|
||||
func TestApp_New_Empty_Good(t *testing.T) {
|
||||
app := App{}.New(NewOptions())
|
||||
assert.Equal(t, "", app.Name)
|
||||
assert.Equal(t, "", app.Version)
|
||||
}
|
||||
|
||||
func TestApp_New_Partial_Good(t *testing.T) {
|
||||
app := App{}.New(NewOptions(
|
||||
Option{Key: "name", Value: "myapp"},
|
||||
))
|
||||
assert.Equal(t, "myapp", app.Name)
|
||||
assert.Equal(t, "", app.Version)
|
||||
}
|
||||
|
||||
// --- App via Core ---
|
||||
|
||||
func TestApp_Core_Good(t *testing.T) {
|
||||
c := New(WithOption("name", "myapp"))
|
||||
assert.Equal(t, "myapp", c.App().Name)
|
||||
}
|
||||
|
||||
func TestApp_Empty_Good(t *testing.T) {
|
||||
func TestApp_Core_Empty_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.App())
|
||||
assert.Equal(t, "", c.App().Name)
|
||||
|
|
@ -26,14 +53,16 @@ func TestApp_Runtime_Good(t *testing.T) {
|
|||
assert.NotNil(t, c.App().Runtime)
|
||||
}
|
||||
|
||||
// --- App.Find ---
|
||||
|
||||
func TestApp_Find_Good(t *testing.T) {
|
||||
r := Find("go", "go")
|
||||
r := App{}.Find("go", "go")
|
||||
assert.True(t, r.OK)
|
||||
app := r.Value.(*App)
|
||||
assert.NotEmpty(t, app.Path)
|
||||
}
|
||||
|
||||
func TestApp_Find_Bad(t *testing.T) {
|
||||
r := Find("nonexistent-binary-xyz", "test")
|
||||
r := App{}.Find("nonexistent-binary-xyz", "test")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
|
|
|||
69
cli.go
69
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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
156
contract.go
156
contract.go
|
|
@ -6,6 +6,7 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// Message is the type for IPC broadcasts (fire-and-forget).
|
||||
|
|
@ -66,20 +67,36 @@ type ActionTaskCompleted struct {
|
|||
|
||||
// --- Constructor ---
|
||||
|
||||
// New creates a Core instance.
|
||||
// CoreOption is a functional option applied during Core construction.
|
||||
// Returns Result — if !OK, New() stops and returns the error.
|
||||
//
|
||||
// c := core.New(core.Options{
|
||||
// {Key: "name", Value: "myapp"},
|
||||
// })
|
||||
func New(opts ...Options) *Core {
|
||||
// core.New(
|
||||
// core.WithService(agentic.Register),
|
||||
// core.WithService(monitor.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
type CoreOption func(*Core) Result
|
||||
|
||||
// New initialises a Core instance by applying options in order.
|
||||
// Services registered here form the application conclave — they share
|
||||
// IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown).
|
||||
//
|
||||
// r := core.New(
|
||||
// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"})),
|
||||
// core.WithService(auth.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
// if !r.OK { log.Fatal(r.Value) }
|
||||
// c := r.Value.(*Core)
|
||||
func New(opts ...CoreOption) *Core {
|
||||
c := &Core{
|
||||
app: &App{},
|
||||
data: &Data{},
|
||||
drive: &Drive{},
|
||||
fs: &Fs{root: "/"},
|
||||
config: &Config{ConfigOptions: &ConfigOptions{}},
|
||||
fs: (&Fs{}).New("/"),
|
||||
config: (&Config{}).New(),
|
||||
error: &ErrorPanic{},
|
||||
log: &ErrorLog{log: Default()},
|
||||
log: &ErrorLog{},
|
||||
lock: &Lock{},
|
||||
ipc: &Ipc{},
|
||||
info: systemInfo,
|
||||
|
|
@ -89,18 +106,123 @@ func New(opts ...Options) *Core {
|
|||
}
|
||||
c.context, c.cancel = context.WithCancel(context.Background())
|
||||
|
||||
if len(opts) > 0 {
|
||||
cp := make(Options, len(opts[0]))
|
||||
copy(cp, opts[0])
|
||||
c.options = &cp
|
||||
name := cp.String("name")
|
||||
if name != "" {
|
||||
c.app.Name = name
|
||||
// Core services
|
||||
CliRegister(c)
|
||||
|
||||
for _, opt := range opts {
|
||||
if r := opt(c); !r.OK {
|
||||
Error("core.New failed", "err", r.Value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Init Cli surface with Core reference
|
||||
c.cli = &Cli{core: c}
|
||||
// Apply service lock after all opts — v0.3.3 parity
|
||||
c.LockApply()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// WithOptions applies key-value configuration to Core.
|
||||
//
|
||||
// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"}))
|
||||
func WithOptions(opts Options) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
c.options = &opts
|
||||
if name := opts.String("name"); name != "" {
|
||||
c.app.Name = name
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
|
||||
// WithService registers a service via its factory function.
|
||||
// If the factory returns a non-nil Value, WithService auto-discovers the
|
||||
// service name from the factory's package path (last path segment, lowercase,
|
||||
// with any "_test" suffix stripped) and calls RegisterService on the instance.
|
||||
// IPC handler auto-registration is handled by RegisterService.
|
||||
//
|
||||
// If the factory returns nil Value (it registered itself), WithService
|
||||
// returns success without a second registration.
|
||||
//
|
||||
// core.WithService(agentic.Register)
|
||||
// core.WithService(display.Register(nil))
|
||||
func WithService(factory func(*Core) Result) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
r := factory(c)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
if r.Value == nil {
|
||||
// Factory self-registered — nothing more to do.
|
||||
return Result{OK: true}
|
||||
}
|
||||
// Auto-discover the service name from the instance's package path.
|
||||
instance := r.Value
|
||||
typeOf := reflect.TypeOf(instance)
|
||||
if typeOf.Kind() == reflect.Ptr {
|
||||
typeOf = typeOf.Elem()
|
||||
}
|
||||
pkgPath := typeOf.PkgPath()
|
||||
parts := Split(pkgPath, "/")
|
||||
name := Lower(parts[len(parts)-1])
|
||||
if name == "" {
|
||||
return Result{E("core.WithService", Sprintf("service name could not be discovered for type %T", instance), nil), false}
|
||||
}
|
||||
|
||||
// RegisterService handles Startable/Stoppable/HandleIPCEvents discovery
|
||||
return c.RegisterService(name, instance)
|
||||
}
|
||||
}
|
||||
|
||||
// WithName registers a service with an explicit name (no reflect discovery).
|
||||
//
|
||||
// core.WithName("ws", func(c *Core) Result {
|
||||
// return Result{Value: hub, OK: true}
|
||||
// })
|
||||
func WithName(name string, factory func(*Core) Result) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
r := factory(c)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
if r.Value == nil {
|
||||
return Result{E("core.WithName", Sprintf("failed to create service %q", name), nil), false}
|
||||
}
|
||||
return c.RegisterService(name, r.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// WithOption is a convenience for setting a single key-value option.
|
||||
//
|
||||
// core.New(
|
||||
// core.WithOption("name", "myapp"),
|
||||
// core.WithOption("port", 8080),
|
||||
// )
|
||||
func WithOption(key string, value any) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
if c.options == nil {
|
||||
opts := NewOptions()
|
||||
c.options = &opts
|
||||
}
|
||||
c.options.Set(key, value)
|
||||
if key == "name" {
|
||||
if s, ok := value.(string); ok {
|
||||
c.app.Name = s
|
||||
}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceLock prevents further service registration after construction.
|
||||
//
|
||||
// core.New(
|
||||
// core.WithService(auth.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
func WithServiceLock() CoreOption {
|
||||
return func(c *Core) Result {
|
||||
c.LockEnable()
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
contract_test.go
Normal file
133
contract_test.go
Normal 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
54
core.go
|
|
@ -7,6 +7,7 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
|
@ -15,15 +16,15 @@ import (
|
|||
|
||||
// Core is the central application object that manages services, assets, and communication.
|
||||
type Core struct {
|
||||
options *Options // c.Options() — Input configuration used to create this Core
|
||||
app *App // c.App() — Application identity + optional GUI runtime
|
||||
data *Data // c.Data() — Embedded/stored content from packages
|
||||
drive *Drive // c.Drive() — Resource handle registry (transports)
|
||||
fs *Fs // c.Fs() — Local filesystem I/O (sandboxable)
|
||||
config *Config // c.Config() — Configuration, settings, feature flags
|
||||
error *ErrorPanic // c.Error() — Panic recovery and crash reporting
|
||||
log *ErrorLog // c.Log() — Structured logging + error wrapping
|
||||
cli *Cli // c.Cli() — CLI surface layer
|
||||
options *Options // c.Options() — Input configuration used to create this Core
|
||||
app *App // c.App() — Application identity + optional GUI runtime
|
||||
data *Data // c.Data() — Embedded/stored content from packages
|
||||
drive *Drive // c.Drive() — Resource handle registry (transports)
|
||||
fs *Fs // c.Fs() — Local filesystem I/O (sandboxable)
|
||||
config *Config // c.Config() — Configuration, settings, feature flags
|
||||
error *ErrorPanic // c.Error() — Panic recovery and crash reporting
|
||||
log *ErrorLog // c.Log() — Structured logging + error wrapping
|
||||
// cli accessed via ServiceFor[*Cli](c, "cli")
|
||||
commands *commandRegistry // c.Command("path") — Command tree
|
||||
services *serviceRegistry // c.Service("name") — Service registry
|
||||
lock *Lock // c.Lock("name") — Named mutexes
|
||||
|
|
@ -49,13 +50,46 @@ func (c *Core) Fs() *Fs { return c.fs }
|
|||
func (c *Core) Config() *Config { return c.config }
|
||||
func (c *Core) Error() *ErrorPanic { return c.error }
|
||||
func (c *Core) Log() *ErrorLog { return c.log }
|
||||
func (c *Core) Cli() *Cli { return c.cli }
|
||||
func (c *Core) Cli() *Cli {
|
||||
cl, _ := ServiceFor[*Cli](c, "cli")
|
||||
return cl
|
||||
}
|
||||
func (c *Core) IPC() *Ipc { return c.ipc }
|
||||
func (c *Core) I18n() *I18n { return c.i18n }
|
||||
func (c *Core) Env(key string) string { return Env(key) }
|
||||
func (c *Core) Context() context.Context { return c.context }
|
||||
func (c *Core) Core() *Core { return c }
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
// Run starts all services, runs the CLI, then shuts down.
|
||||
// This is the standard application lifecycle for CLI apps.
|
||||
//
|
||||
// c := core.New(core.WithService(myService.Register)).Value.(*Core)
|
||||
// c.Run()
|
||||
func (c *Core) Run() {
|
||||
r := c.ServiceStartup(c.context, nil)
|
||||
if !r.OK {
|
||||
if err, ok := r.Value.(error); ok {
|
||||
Error(err.Error())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if cli := c.Cli(); cli != nil {
|
||||
r = cli.Run()
|
||||
}
|
||||
|
||||
c.ServiceShutdown(context.Background())
|
||||
|
||||
if !r.OK {
|
||||
if err, ok := r.Value.(error); ok {
|
||||
Error(err.Error())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- IPC (uppercase aliases) ---
|
||||
|
||||
func (c *Core) ACTION(msg Message) Result { return c.Action(msg) }
|
||||
|
|
|
|||
142
core_test.go
142
core_test.go
|
|
@ -1,6 +1,10 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -15,17 +19,64 @@ func TestNew_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestNew_WithOptions_Good(t *testing.T) {
|
||||
c := New(Options{{Key: "name", Value: "myapp"}})
|
||||
c := New(WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})))
|
||||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "myapp", c.App().Name)
|
||||
}
|
||||
|
||||
func TestNew_WithOptions_Bad(t *testing.T) {
|
||||
// Empty options — should still create a valid Core
|
||||
c := New(Options{})
|
||||
c := New(WithOptions(NewOptions()))
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestNew_WithService_Good(t *testing.T) {
|
||||
started := false
|
||||
c := New(
|
||||
WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})),
|
||||
WithService(func(c *Core) Result {
|
||||
c.Service("test", Service{
|
||||
OnStart: func() Result { started = true; return Result{OK: true} },
|
||||
})
|
||||
return Result{OK: true}
|
||||
}),
|
||||
)
|
||||
|
||||
svc := c.Service("test")
|
||||
assert.True(t, svc.OK)
|
||||
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
assert.True(t, started)
|
||||
}
|
||||
|
||||
func TestNew_WithServiceLock_Good(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
c.Service("allowed", Service{})
|
||||
return Result{OK: true}
|
||||
}),
|
||||
WithServiceLock(),
|
||||
)
|
||||
|
||||
// Registration after lock should fail
|
||||
reg := c.Service("blocked", Service{})
|
||||
assert.False(t, reg.OK)
|
||||
}
|
||||
|
||||
func TestNew_WithService_Bad_FailingOption(t *testing.T) {
|
||||
secondCalled := false
|
||||
_ = New(
|
||||
WithService(func(c *Core) Result {
|
||||
return Result{Value: E("test", "intentional failure", nil), OK: false}
|
||||
}),
|
||||
WithService(func(c *Core) Result {
|
||||
secondCalled = true
|
||||
return Result{OK: true}
|
||||
}),
|
||||
)
|
||||
assert.False(t, secondCalled, "second option should not run after first fails")
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
func TestAccessors_Good(t *testing.T) {
|
||||
|
|
@ -44,11 +95,11 @@ func TestAccessors_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestOptions_Accessor_Good(t *testing.T) {
|
||||
c := New(Options{
|
||||
{Key: "name", Value: "testapp"},
|
||||
{Key: "port", Value: 8080},
|
||||
{Key: "debug", Value: true},
|
||||
})
|
||||
c := New(WithOptions(NewOptions(
|
||||
Option{Key: "name", Value: "testapp"},
|
||||
Option{Key: "port", Value: 8080},
|
||||
Option{Key: "debug", Value: true},
|
||||
)))
|
||||
opts := c.Options()
|
||||
assert.NotNil(t, opts)
|
||||
assert.Equal(t, "testapp", opts.String("name"))
|
||||
|
|
@ -68,7 +119,7 @@ func TestCore_LogError_Good(t *testing.T) {
|
|||
c := New()
|
||||
cause := assert.AnError
|
||||
r := c.LogError(cause, "test.Operation", "something broke")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
err, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
|
|
@ -77,7 +128,7 @@ func TestCore_LogError_Good(t *testing.T) {
|
|||
func TestCore_LogWarn_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.LogWarn(assert.AnError, "test.Operation", "heads up")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
_, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
|
@ -95,3 +146,76 @@ func TestCore_Must_Nil_Good(t *testing.T) {
|
|||
c.Must(nil, "test.Operation", "no error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCore_Run_HelperProcess(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
switch os.Getenv("CORE_RUN_MODE") {
|
||||
case "startup-fail":
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("broken", Service{
|
||||
OnStart: func() Result {
|
||||
return Result{Value: NewError("startup failed"), OK: false}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
c.Run()
|
||||
case "cli-fail":
|
||||
shutdownFile := os.Getenv("CORE_RUN_SHUTDOWN_FILE")
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("cleanup", Service{
|
||||
OnStop: func() Result {
|
||||
if err := os.WriteFile(shutdownFile, []byte("stopped"), 0o600); err != nil {
|
||||
return Result{Value: err, OK: false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
c.Command("explode", Command{
|
||||
Action: func(_ Options) Result {
|
||||
return Result{Value: NewError("cli failed"), OK: false}
|
||||
},
|
||||
})
|
||||
os.Args = []string{"core-test", "explode"}
|
||||
c.Run()
|
||||
default:
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCore_Run_Bad(t *testing.T) {
|
||||
err := runCoreRunHelper(t, "startup-fail")
|
||||
var exitErr *exec.ExitError
|
||||
if assert.ErrorAs(t, err, &exitErr) {
|
||||
assert.Equal(t, 1, exitErr.ExitCode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCore_Run_Ugly(t *testing.T) {
|
||||
shutdownFile := filepath.Join(t.TempDir(), "shutdown.txt")
|
||||
err := runCoreRunHelper(t, "cli-fail", "CORE_RUN_SHUTDOWN_FILE="+shutdownFile)
|
||||
var exitErr *exec.ExitError
|
||||
if assert.ErrorAs(t, err, &exitErr) {
|
||||
assert.Equal(t, 1, exitErr.ExitCode())
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(shutdownFile)
|
||||
assert.NoError(t, readErr)
|
||||
assert.Equal(t, "stopped", string(data))
|
||||
}
|
||||
|
||||
func runCoreRunHelper(t *testing.T, mode string, extraEnv ...string) error {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestCore_Run_HelperProcess$")
|
||||
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "CORE_RUN_MODE="+mode)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
|
|
|||
20
data.go
20
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 == "" {
|
||||
|
|
|
|||
51
data_test.go
51
data_test.go
|
|
@ -14,13 +14,24 @@ var testFS embed.FS
|
|||
|
||||
// --- Data (Embedded Content Mounts) ---
|
||||
|
||||
func mountTestData(t *testing.T, c *Core, name string) {
|
||||
t.Helper()
|
||||
|
||||
r := c.Data().New(NewOptions(
|
||||
Option{Key: "name", Value: name},
|
||||
Option{Key: "source", Value: testFS},
|
||||
Option{Key: "path", Value: "testdata"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_New_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "test"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
r := c.Data().New(NewOptions(
|
||||
Option{Key: "name", Value: "test"},
|
||||
Option{Key: "source", Value: testFS},
|
||||
Option{Key: "path", Value: "testdata"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
}
|
||||
|
|
@ -28,19 +39,19 @@ func TestData_New_Good(t *testing.T) {
|
|||
func TestData_New_Bad(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
r := c.Data().New(Options{{Key: "source", Value: testFS}})
|
||||
r := c.Data().New(NewOptions(Option{Key: "source", Value: testFS}))
|
||||
assert.False(t, r.OK)
|
||||
|
||||
r = c.Data().New(Options{{Key: "name", Value: "test"}})
|
||||
r = c.Data().New(NewOptions(Option{Key: "name", Value: "test"}))
|
||||
assert.False(t, r.OK)
|
||||
|
||||
r = c.Data().New(Options{{Key: "name", Value: "test"}, {Key: "source", Value: "not-an-fs"}})
|
||||
r = c.Data().New(NewOptions(Option{Key: "name", Value: "test"}, Option{Key: "source", Value: "not-an-fs"}))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ReadString_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().ReadString("app/test.txt")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello from testdata\n", r.Value.(string))
|
||||
|
|
@ -54,7 +65,7 @@ func TestData_ReadString_Bad(t *testing.T) {
|
|||
|
||||
func TestData_ReadFile_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().ReadFile("app/test.txt")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello from testdata\n", string(r.Value.([]byte)))
|
||||
|
|
@ -62,7 +73,7 @@ func TestData_ReadFile_Good(t *testing.T) {
|
|||
|
||||
func TestData_Get_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "brain"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
mountTestData(t, c, "brain")
|
||||
gr := c.Data().Get("brain")
|
||||
assert.True(t, gr.OK)
|
||||
emb := gr.Value.(*Embed)
|
||||
|
|
@ -83,22 +94,22 @@ func TestData_Get_Bad(t *testing.T) {
|
|||
|
||||
func TestData_Mounts_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "a"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
c.Data().New(Options{{Key: "name", Value: "b"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
mountTestData(t, c, "a")
|
||||
mountTestData(t, c, "b")
|
||||
mounts := c.Data().Mounts()
|
||||
assert.Len(t, mounts, 2)
|
||||
}
|
||||
|
||||
func TestEmbed_Legacy_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
mountTestData(t, c, "app")
|
||||
assert.NotNil(t, c.Embed())
|
||||
}
|
||||
|
||||
func TestData_List_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().List("app/testdata")
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().List("app/.")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
|
|
@ -110,16 +121,16 @@ func TestData_List_Bad(t *testing.T) {
|
|||
|
||||
func TestData_ListNames_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().ListNames("app/testdata")
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().ListNames("app/.")
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.([]string), "test")
|
||||
}
|
||||
|
||||
func TestData_Extract_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().Extract("app/testdata", t.TempDir(), nil)
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().Extract("app/.", t.TempDir(), nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
|
|
|
|||
36
drive.go
36
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
embed.go
2
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.
|
||||
|
|
|
|||
|
|
@ -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
22
fs.go
|
|
@ -13,6 +13,18 @@ type Fs struct {
|
|||
root string
|
||||
}
|
||||
|
||||
// New initialises an Fs with the given root directory.
|
||||
// Root "/" means unrestricted access. Empty root defaults to "/".
|
||||
//
|
||||
// fs := (&core.Fs{}).New("/")
|
||||
func (m *Fs) New(root string) *Fs {
|
||||
if root == "" {
|
||||
root = "/"
|
||||
}
|
||||
m.root = root
|
||||
return m
|
||||
}
|
||||
|
||||
// path sanitises and returns the full path.
|
||||
// Absolute paths are sandboxed under root (unless root is "/").
|
||||
// Empty root defaults to "/" — the zero value of Fs is usable.
|
||||
|
|
@ -190,7 +202,7 @@ func (m *Fs) List(p string) Result {
|
|||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return Result{}.Result(os.ReadDir(vp.Value.(string)))
|
||||
return Result{}.New(os.ReadDir(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// Stat returns file info.
|
||||
|
|
@ -199,7 +211,7 @@ func (m *Fs) Stat(p string) Result {
|
|||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return Result{}.Result(os.Stat(vp.Value.(string)))
|
||||
return Result{}.New(os.Stat(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// Open opens the named file for reading.
|
||||
|
|
@ -208,7 +220,7 @@ func (m *Fs) Open(p string) Result {
|
|||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return Result{}.Result(os.Open(vp.Value.(string)))
|
||||
return Result{}.New(os.Open(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// Create creates or truncates the named file.
|
||||
|
|
@ -221,7 +233,7 @@ func (m *Fs) Create(p string) Result {
|
|||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{}.Result(os.Create(full))
|
||||
return Result{}.New(os.Create(full))
|
||||
}
|
||||
|
||||
// Append opens the named file for appending, creating it if it doesn't exist.
|
||||
|
|
@ -234,7 +246,7 @@ func (m *Fs) Append(p string) Result {
|
|||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{}.Result(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644))
|
||||
return Result{}.New(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644))
|
||||
}
|
||||
|
||||
// ReadStream returns a reader for the file content.
|
||||
|
|
|
|||
10
i18n_test.go
10
i18n_test.go
|
|
@ -16,11 +16,11 @@ func TestI18n_Good(t *testing.T) {
|
|||
|
||||
func TestI18n_AddLocales_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "lang"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
r := c.Data().New(NewOptions(
|
||||
Option{Key: "name", Value: "lang"},
|
||||
Option{Key: "source", Value: testFS},
|
||||
Option{Key: "path", Value: "testdata"},
|
||||
))
|
||||
if r.OK {
|
||||
c.I18n().AddLocales(r.Value.(*Embed))
|
||||
}
|
||||
|
|
|
|||
2
ipc.go
2
ipc.go
|
|
@ -12,6 +12,8 @@ import (
|
|||
)
|
||||
|
||||
// Ipc holds IPC dispatch data.
|
||||
//
|
||||
// ipc := (&core.Ipc{}).New()
|
||||
type Ipc struct {
|
||||
ipcMu sync.RWMutex
|
||||
ipcHandlers []func(*Core, Message) Result
|
||||
|
|
|
|||
20
lock.go
20
lock.go
|
|
@ -8,27 +8,27 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// package-level mutex infrastructure
|
||||
var (
|
||||
lockMu sync.Mutex
|
||||
lockMap = make(map[string]*sync.RWMutex)
|
||||
)
|
||||
|
||||
// Lock is the DTO for a named mutex.
|
||||
type Lock struct {
|
||||
Name string
|
||||
Mutex *sync.RWMutex
|
||||
mu sync.Mutex // protects locks map
|
||||
locks map[string]*sync.RWMutex // per-Core named mutexes
|
||||
}
|
||||
|
||||
// Lock returns a named Lock, creating the mutex if needed.
|
||||
// Locks are per-Core — separate Core instances do not share mutexes.
|
||||
func (c *Core) Lock(name string) *Lock {
|
||||
lockMu.Lock()
|
||||
m, ok := lockMap[name]
|
||||
c.lock.mu.Lock()
|
||||
if c.lock.locks == nil {
|
||||
c.lock.locks = make(map[string]*sync.RWMutex)
|
||||
}
|
||||
m, ok := c.lock.locks[name]
|
||||
if !ok {
|
||||
m = &sync.RWMutex{}
|
||||
lockMap[name] = m
|
||||
c.lock.locks[name] = m
|
||||
}
|
||||
lockMu.Unlock()
|
||||
c.lock.mu.Unlock()
|
||||
return &Lock{Name: name, Mutex: m}
|
||||
}
|
||||
|
||||
|
|
|
|||
135
options.go
135
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
|
||||
}
|
||||
|
|
|
|||
129
options_test.go
129
options_test.go
|
|
@ -7,75 +7,121 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Option / Options ---
|
||||
// --- NewOptions ---
|
||||
|
||||
func TestNewOptions_Good(t *testing.T) {
|
||||
opts := NewOptions(
|
||||
Option{Key: "name", Value: "brain"},
|
||||
Option{Key: "port", Value: 8080},
|
||||
)
|
||||
assert.Equal(t, 2, opts.Len())
|
||||
}
|
||||
|
||||
func TestNewOptions_Empty_Good(t *testing.T) {
|
||||
opts := NewOptions()
|
||||
assert.Equal(t, 0, opts.Len())
|
||||
assert.False(t, opts.Has("anything"))
|
||||
}
|
||||
|
||||
// --- Options.Set ---
|
||||
|
||||
func TestOptions_Set_Good(t *testing.T) {
|
||||
opts := NewOptions()
|
||||
opts.Set("name", "brain")
|
||||
assert.Equal(t, "brain", opts.String("name"))
|
||||
}
|
||||
|
||||
func TestOptions_Set_Update_Good(t *testing.T) {
|
||||
opts := NewOptions(Option{Key: "name", Value: "old"})
|
||||
opts.Set("name", "new")
|
||||
assert.Equal(t, "new", opts.String("name"))
|
||||
assert.Equal(t, 1, opts.Len())
|
||||
}
|
||||
|
||||
// --- Options.Get ---
|
||||
|
||||
func TestOptions_Get_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
{Key: "name", Value: "brain"},
|
||||
{Key: "port", Value: 8080},
|
||||
}
|
||||
opts := NewOptions(
|
||||
Option{Key: "name", Value: "brain"},
|
||||
Option{Key: "port", Value: 8080},
|
||||
)
|
||||
r := opts.Get("name")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "brain", r.Value)
|
||||
}
|
||||
|
||||
func TestOptions_Get_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
opts := NewOptions(Option{Key: "name", Value: "brain"})
|
||||
r := opts.Get("missing")
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
// --- Options.Has ---
|
||||
|
||||
func TestOptions_Has_Good(t *testing.T) {
|
||||
opts := Options{{Key: "debug", Value: true}}
|
||||
opts := NewOptions(Option{Key: "debug", Value: true})
|
||||
assert.True(t, opts.Has("debug"))
|
||||
assert.False(t, opts.Has("missing"))
|
||||
}
|
||||
|
||||
// --- Options.String ---
|
||||
|
||||
func TestOptions_String_Good(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
opts := NewOptions(Option{Key: "name", Value: "brain"})
|
||||
assert.Equal(t, "brain", opts.String("name"))
|
||||
}
|
||||
|
||||
func TestOptions_String_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "port", Value: 8080}}
|
||||
// Wrong type — returns empty string
|
||||
opts := NewOptions(Option{Key: "port", Value: 8080})
|
||||
assert.Equal(t, "", opts.String("port"))
|
||||
// Missing key — returns empty string
|
||||
assert.Equal(t, "", opts.String("missing"))
|
||||
}
|
||||
|
||||
// --- Options.Int ---
|
||||
|
||||
func TestOptions_Int_Good(t *testing.T) {
|
||||
opts := Options{{Key: "port", Value: 8080}}
|
||||
opts := NewOptions(Option{Key: "port", Value: 8080})
|
||||
assert.Equal(t, 8080, opts.Int("port"))
|
||||
}
|
||||
|
||||
func TestOptions_Int_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
opts := NewOptions(Option{Key: "name", Value: "brain"})
|
||||
assert.Equal(t, 0, opts.Int("name"))
|
||||
assert.Equal(t, 0, opts.Int("missing"))
|
||||
}
|
||||
|
||||
// --- Options.Bool ---
|
||||
|
||||
func TestOptions_Bool_Good(t *testing.T) {
|
||||
opts := Options{{Key: "debug", Value: true}}
|
||||
opts := NewOptions(Option{Key: "debug", Value: true})
|
||||
assert.True(t, opts.Bool("debug"))
|
||||
}
|
||||
|
||||
func TestOptions_Bool_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
opts := NewOptions(Option{Key: "name", Value: "brain"})
|
||||
assert.False(t, opts.Bool("name"))
|
||||
assert.False(t, opts.Bool("missing"))
|
||||
}
|
||||
|
||||
// --- Options.Items ---
|
||||
|
||||
func TestOptions_Items_Good(t *testing.T) {
|
||||
opts := NewOptions(Option{Key: "a", Value: 1}, Option{Key: "b", Value: 2})
|
||||
items := opts.Items()
|
||||
assert.Len(t, items, 2)
|
||||
}
|
||||
|
||||
// --- Options with typed struct ---
|
||||
|
||||
func TestOptions_TypedStruct_Good(t *testing.T) {
|
||||
// Packages plug typed structs into Option.Value
|
||||
type BrainConfig struct {
|
||||
Name string
|
||||
OllamaURL string
|
||||
Collection string
|
||||
}
|
||||
cfg := BrainConfig{Name: "brain", OllamaURL: "http://localhost:11434", Collection: "openbrain"}
|
||||
opts := Options{{Key: "config", Value: cfg}}
|
||||
opts := NewOptions(Option{Key: "config", Value: cfg})
|
||||
|
||||
r := opts.Get("config")
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -85,10 +131,47 @@ func TestOptions_TypedStruct_Good(t *testing.T) {
|
|||
assert.Equal(t, "http://localhost:11434", bc.OllamaURL)
|
||||
}
|
||||
|
||||
func TestOptions_Empty_Good(t *testing.T) {
|
||||
opts := Options{}
|
||||
assert.False(t, opts.Has("anything"))
|
||||
assert.Equal(t, "", opts.String("anything"))
|
||||
assert.Equal(t, 0, opts.Int("anything"))
|
||||
assert.False(t, opts.Bool("anything"))
|
||||
// --- Result ---
|
||||
|
||||
func TestResult_New_Good(t *testing.T) {
|
||||
r := Result{}.New("value")
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_New_Error_Bad(t *testing.T) {
|
||||
err := E("test", "failed", nil)
|
||||
r := Result{}.New(err)
|
||||
assert.False(t, r.OK)
|
||||
assert.Equal(t, err, r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_Good(t *testing.T) {
|
||||
r := Result{Value: "hello", OK: true}
|
||||
assert.Equal(t, r, r.Result())
|
||||
}
|
||||
|
||||
func TestResult_Result_WithArgs_Good(t *testing.T) {
|
||||
r := Result{}.Result("value")
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Get_Good(t *testing.T) {
|
||||
r := Result{Value: "hello", OK: true}
|
||||
assert.True(t, r.Get().OK)
|
||||
}
|
||||
|
||||
func TestResult_Get_Bad(t *testing.T) {
|
||||
r := Result{Value: "err", OK: false}
|
||||
assert.False(t, r.Get().OK)
|
||||
}
|
||||
|
||||
// --- WithOption ---
|
||||
|
||||
func TestWithOption_Good(t *testing.T) {
|
||||
c := New(
|
||||
WithOption("name", "myapp"),
|
||||
WithOption("port", 8080),
|
||||
)
|
||||
assert.Equal(t, "myapp", c.App().Name)
|
||||
assert.Equal(t, 8080, c.Options().Int("port"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
101
service.go
101
service.go
|
|
@ -2,9 +2,13 @@
|
|||
|
||||
// Service registry for the Core framework.
|
||||
//
|
||||
// Register a service:
|
||||
// Register a service (DTO with lifecycle hooks):
|
||||
//
|
||||
// c.Service("auth", core.Service{})
|
||||
// c.Service("auth", core.Service{OnStart: startFn})
|
||||
//
|
||||
// Register a service instance (auto-discovers Startable/Stoppable/HandleIPCEvents):
|
||||
//
|
||||
// c.RegisterService("display", displayInstance)
|
||||
//
|
||||
// Get a service:
|
||||
//
|
||||
|
|
@ -13,11 +17,12 @@
|
|||
|
||||
package core
|
||||
|
||||
// No imports needed — uses package-level string helpers.
|
||||
import "context"
|
||||
|
||||
// Service is a managed component with optional lifecycle.
|
||||
type Service struct {
|
||||
Name string
|
||||
Instance any // the raw service instance (for interface discovery)
|
||||
Options Options
|
||||
OnStart func() Result
|
||||
OnStop func() Result
|
||||
|
|
@ -40,9 +45,16 @@ type serviceRegistry struct {
|
|||
func (c *Core) Service(name string, service ...Service) Result {
|
||||
if len(service) == 0 {
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
v, ok := c.services.services[name]
|
||||
svc, ok := c.services.services[name]
|
||||
c.Lock("srv").Mutex.RUnlock()
|
||||
return Result{v, ok}
|
||||
if !ok || svc == nil {
|
||||
return Result{}
|
||||
}
|
||||
// Return the instance if available, otherwise the Service DTO
|
||||
if svc.Instance != nil {
|
||||
return Result{svc.Instance, true}
|
||||
}
|
||||
return Result{svc, true}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
|
|
@ -66,6 +78,85 @@ func (c *Core) Service(name string, service ...Service) Result {
|
|||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// RegisterService registers a service instance by name.
|
||||
// Auto-discovers Startable, Stoppable, and HandleIPCEvents interfaces
|
||||
// on the instance and wires them into the lifecycle and IPC bus.
|
||||
//
|
||||
// c.RegisterService("display", displayInstance)
|
||||
func (c *Core) RegisterService(name string, instance any) Result {
|
||||
if name == "" {
|
||||
return Result{E("core.RegisterService", "service name cannot be empty", nil), false}
|
||||
}
|
||||
|
||||
c.Lock("srv").Mutex.Lock()
|
||||
defer c.Lock("srv").Mutex.Unlock()
|
||||
|
||||
if c.services.locked {
|
||||
return Result{E("core.RegisterService", Concat("service \"", name, "\" not permitted — registry locked"), nil), false}
|
||||
}
|
||||
if _, exists := c.services.services[name]; exists {
|
||||
return Result{E("core.RegisterService", Join(" ", "service", name, "already registered"), nil), false}
|
||||
}
|
||||
|
||||
srv := &Service{Name: name, Instance: instance}
|
||||
|
||||
// Auto-discover lifecycle interfaces
|
||||
if s, ok := instance.(Startable); ok {
|
||||
srv.OnStart = func() Result {
|
||||
if err := s.OnStartup(c.context); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
if s, ok := instance.(Stoppable); ok {
|
||||
srv.OnStop = func() Result {
|
||||
if err := s.OnShutdown(context.Background()); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
|
||||
c.services.services[name] = srv
|
||||
|
||||
// Auto-discover IPC handler
|
||||
if handler, ok := instance.(interface {
|
||||
HandleIPCEvents(*Core, Message) Result
|
||||
}); ok {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler.HandleIPCEvents)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// ServiceFor retrieves a registered service by name and asserts its type.
|
||||
//
|
||||
// prep, ok := core.ServiceFor[*agentic.PrepSubsystem](c, "agentic")
|
||||
func ServiceFor[T any](c *Core, name string) (T, bool) {
|
||||
var zero T
|
||||
r := c.Service(name)
|
||||
if !r.OK {
|
||||
return zero, false
|
||||
}
|
||||
typed, ok := r.Value.(T)
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
// MustServiceFor retrieves a registered service by name and asserts its type.
|
||||
// Panics if the service is not found or the type assertion fails.
|
||||
//
|
||||
// cli := core.MustServiceFor[*Cli](c, "cli")
|
||||
func MustServiceFor[T any](c *Core, name string) T {
|
||||
v, ok := ServiceFor[T](c, name)
|
||||
if !ok {
|
||||
panic(E("core.MustServiceFor", Sprintf("service %q not found or wrong type", name), nil))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Services returns all registered service names.
|
||||
//
|
||||
// names := c.Services()
|
||||
|
|
|
|||
113
service_test.go
113
service_test.go
|
|
@ -1,6 +1,7 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -47,9 +48,9 @@ func TestService_Names_Good(t *testing.T) {
|
|||
c.Service("a", Service{})
|
||||
c.Service("b", Service{})
|
||||
names := c.Services()
|
||||
assert.Len(t, names, 2)
|
||||
assert.Contains(t, names, "a")
|
||||
assert.Contains(t, names, "b")
|
||||
assert.Contains(t, names, "cli") // auto-registered by CliRegister in New()
|
||||
}
|
||||
|
||||
// --- Service Lifecycle ---
|
||||
|
|
@ -77,3 +78,113 @@ func TestService_Lifecycle_Good(t *testing.T) {
|
|||
stoppables[0].OnStop()
|
||||
assert.True(t, stopped)
|
||||
}
|
||||
|
||||
type autoLifecycleService struct {
|
||||
started bool
|
||||
stopped bool
|
||||
messages []Message
|
||||
}
|
||||
|
||||
func (s *autoLifecycleService) OnStartup(_ context.Context) error {
|
||||
s.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *autoLifecycleService) OnShutdown(_ context.Context) error {
|
||||
s.stopped = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *autoLifecycleService) HandleIPCEvents(_ *Core, msg Message) Result {
|
||||
s.messages = append(s.messages, msg)
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
func TestService_RegisterService_Bad(t *testing.T) {
|
||||
t.Run("EmptyName", func(t *testing.T) {
|
||||
c := New()
|
||||
r := c.RegisterService("", "value")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
err, ok := r.Value.(error)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "core.RegisterService", Operation(err))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DuplicateName", func(t *testing.T) {
|
||||
c := New()
|
||||
assert.True(t, c.RegisterService("svc", "first").OK)
|
||||
|
||||
r := c.RegisterService("svc", "second")
|
||||
assert.False(t, r.OK)
|
||||
})
|
||||
|
||||
t.Run("LockedRegistry", func(t *testing.T) {
|
||||
c := New()
|
||||
c.LockEnable()
|
||||
c.LockApply()
|
||||
|
||||
r := c.RegisterService("blocked", "value")
|
||||
assert.False(t, r.OK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_RegisterService_Ugly(t *testing.T) {
|
||||
t.Run("AutoDiscoversLifecycleAndIPCHandlers", func(t *testing.T) {
|
||||
c := New()
|
||||
svc := &autoLifecycleService{}
|
||||
|
||||
r := c.RegisterService("auto", svc)
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||
assert.True(t, c.ACTION("ping").OK)
|
||||
assert.True(t, c.ServiceShutdown(context.Background()).OK)
|
||||
assert.True(t, svc.started)
|
||||
assert.True(t, svc.stopped)
|
||||
assert.Contains(t, svc.messages, Message("ping"))
|
||||
})
|
||||
|
||||
t.Run("NilInstanceReturnsServiceDTO", func(t *testing.T) {
|
||||
c := New()
|
||||
assert.True(t, c.RegisterService("nil", nil).OK)
|
||||
|
||||
r := c.Service("nil")
|
||||
if assert.True(t, r.OK) {
|
||||
svc, ok := r.Value.(*Service)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "nil", svc.Name)
|
||||
assert.Nil(t, svc.Instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ServiceFor_Bad(t *testing.T) {
|
||||
typed, ok := ServiceFor[string](New(), "missing")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", typed)
|
||||
}
|
||||
|
||||
func TestService_ServiceFor_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
assert.True(t, c.RegisterService("value", "hello").OK)
|
||||
|
||||
typed, ok := ServiceFor[int](c, "value")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, 0, typed)
|
||||
}
|
||||
|
||||
func TestService_MustServiceFor_Bad(t *testing.T) {
|
||||
c := New()
|
||||
assert.PanicsWithError(t, `core.MustServiceFor: service "missing" not found or wrong type`, func() {
|
||||
_ = MustServiceFor[string](c, "missing")
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_MustServiceFor_Ugly(t *testing.T) {
|
||||
var c *Core
|
||||
assert.Panics(t, func() {
|
||||
_ = MustServiceFor[string](c, "missing")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue