Compare commits
95 commits
agent/fix-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f229dbe2a6 | ||
|
|
5be20af4b0 | ||
|
|
921b4f2b21 | ||
|
|
1cafdff227 | ||
|
|
9cba5a8048 | ||
|
|
48a9bd6606 | ||
|
|
e65cbde97e | ||
|
|
a2fa841772 | ||
|
|
8b905f3a4a | ||
|
|
ecf6485f95 | ||
|
|
0911d5ad7b | ||
|
|
8626710f9d | ||
|
|
12adc97bbd | ||
|
|
1f0c618b7a | ||
|
|
ba77e029c8 | ||
|
|
cd452791e5 | ||
|
|
c91f96d89d | ||
|
|
340b8173a4 | ||
|
|
7b68ead3b0 | ||
|
|
da2e5477ea | ||
|
|
d33765c868 | ||
|
|
377afa0cbe | ||
|
|
7069def5b8 | ||
|
|
b0e54a850a | ||
|
|
a26d9437bb | ||
|
|
390b392dec | ||
|
|
fe46e33ddf | ||
|
|
77563beecf | ||
|
|
693dde08a9 | ||
|
|
ec423cfe46 | ||
|
|
14cd9c6adb | ||
|
|
1d174a93ce | ||
|
|
028ec84c5e | ||
|
|
c5c16a7a21 | ||
|
|
2dff772a40 | ||
|
|
0704a7a65b | ||
|
|
9cd83daaae | ||
|
|
f7e91f0970 | ||
|
|
c6403853f1 | ||
|
|
93c21cfd53 | ||
|
|
21c1a3e92b | ||
|
|
ef548d07bc | ||
|
|
1ef8846f29 | ||
|
|
caa1dea83d | ||
|
|
20f3ee30b8 | ||
|
|
a06af7b6ad | ||
|
|
c847b5d274 | ||
|
|
630f1d5d6b | ||
|
|
f23e4d2be5 | ||
|
|
2167f0c6ab | ||
|
|
6709b0bb1a | ||
|
|
ecd27e3cc9 | ||
|
|
42fc6fa931 | ||
|
|
881c8f2ae8 | ||
|
|
59dcbc2a31 | ||
|
|
b130309c3d | ||
|
|
79fd8c4760 | ||
|
|
5211d97d66 | ||
|
|
68b7530072 | ||
|
|
7a9f9dfbd1 | ||
|
|
773e9ee015 | ||
|
|
8f7a1223ef | ||
|
|
76714fa292 | ||
|
|
ec17e3da07 | ||
|
|
f65884075b | ||
|
|
1455764e3c | ||
|
|
e7c3b3a69c | ||
| f6ed40dfdc | |||
|
|
d982193ed3 | ||
| 5855a6136d | |||
|
|
95076be4b3 | ||
| f72c5782fd | |||
|
|
5362a9965c | ||
|
|
af1cee244a | ||
|
|
7608808bb0 | ||
|
|
7f4c4348c0 | ||
|
|
9c5cc6ea00 | ||
|
|
94e1f405fc | ||
|
|
ae4825426f | ||
|
|
2303c27df0 | ||
|
|
05d0a64b08 | ||
|
|
d1579f678f | ||
|
|
001e90ed13 | ||
|
|
b03c1a3a3c | ||
|
|
177f73cc99 | ||
|
|
198ab839a8 | ||
|
|
f69be963bc | ||
|
|
85faedf6c0 | ||
|
|
2a81b4f576 | ||
|
|
a49bc46bc7 | ||
|
|
74f78c83a2 | ||
|
|
64e6a26ea8 | ||
|
|
9b5f6df6da | ||
|
|
2d017980dd | ||
| 9f6caa3c90 |
80 changed files with 7965 additions and 1654 deletions
87
CLAUDE.md
87
CLAUDE.md
|
|
@ -4,16 +4,15 @@ Guidance for Claude Code and Codex when working with this repository.
|
|||
|
||||
## Module
|
||||
|
||||
`dappco.re/go/core` — dependency injection, service lifecycle, command routing, and message-passing for Go.
|
||||
`dappco.re/go/core` — dependency injection, service lifecycle, permission, and message-passing for Go.
|
||||
|
||||
Source files live at the module root (not `pkg/core/`). Tests live in `tests/`.
|
||||
Source files and tests live at the module root. No `pkg/` nesting.
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
go test ./tests/... # run all tests
|
||||
go build . # verify compilation
|
||||
GOWORK=off go test ./tests/ # test without workspace
|
||||
go test ./... -count=1 # run all tests (483 tests, 84.7% coverage)
|
||||
go build ./... # verify compilation
|
||||
```
|
||||
|
||||
Or via the Core CLI:
|
||||
|
|
@ -25,28 +24,23 @@ core go qa # fmt + vet + lint + test
|
|||
|
||||
## API Shape
|
||||
|
||||
CoreGO uses the DTO/Options/Result pattern, not functional options:
|
||||
|
||||
```go
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "myapp"},
|
||||
})
|
||||
|
||||
c.Service("cache", core.Service{
|
||||
OnStart: func() core.Result { return core.Result{OK: true} },
|
||||
OnStop: func() core.Result { return core.Result{OK: true} },
|
||||
})
|
||||
|
||||
c.Command("deploy/to/homelab", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return core.Result{Value: "deployed", OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Cli().Run("deploy", "to", "homelab")
|
||||
c := core.New(
|
||||
core.WithOption("name", "myapp"),
|
||||
core.WithService(mypackage.Register),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.Run() // or: if err := c.RunE(); err != nil { ... }
|
||||
```
|
||||
|
||||
**Do not use:** `WithService`, `WithName`, `WithApp`, `WithServiceLock`, `Must*`, `ServiceFor[T]` — these no longer exist.
|
||||
Service factory:
|
||||
|
||||
```go
|
||||
func Register(c *core.Core) core.Result {
|
||||
svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, MyOpts{})}
|
||||
return core.Result{Value: svc, OK: true}
|
||||
}
|
||||
```
|
||||
|
||||
## Subsystems
|
||||
|
||||
|
|
@ -54,26 +48,37 @@ r := c.Cli().Run("deploy", "to", "homelab")
|
|||
|----------|---------|---------|
|
||||
| `c.Options()` | `*Options` | Input configuration |
|
||||
| `c.App()` | `*App` | Application identity |
|
||||
| `c.Data()` | `*Data` | Embedded filesystem mounts |
|
||||
| `c.Drive()` | `*Drive` | Named transport handles |
|
||||
| `c.Fs()` | `*Fs` | Local filesystem I/O |
|
||||
| `c.Config()` | `*Config` | Runtime settings |
|
||||
| `c.Cli()` | `*Cli` | CLI surface |
|
||||
| `c.Command("path")` | `Result` | Command tree |
|
||||
| `c.Service("name")` | `Result` | Service registry |
|
||||
| `c.Lock("name")` | `*Lock` | Named mutexes |
|
||||
| `c.IPC()` | `*Ipc` | Message bus |
|
||||
| `c.I18n()` | `*I18n` | Locale + translation |
|
||||
| `c.Config()` | `*Config` | Runtime settings, feature flags |
|
||||
| `c.Data()` | `*Data` | Embedded assets (Registry[*Embed]) |
|
||||
| `c.Drive()` | `*Drive` | Transport handles (Registry[*DriveHandle]) |
|
||||
| `c.Fs()` | `*Fs` | Filesystem I/O (sandboxable) |
|
||||
| `c.Cli()` | `*Cli` | CLI command framework |
|
||||
| `c.IPC()` | `*Ipc` | Message bus internals |
|
||||
| `c.Process()` | `*Process` | Managed execution (Action sugar) |
|
||||
| `c.API()` | `*API` | Remote streams (protocol handlers) |
|
||||
| `c.Action(name)` | `*Action` | Named callable (register/invoke) |
|
||||
| `c.Task(name)` | `*Task` | Composed Action sequence |
|
||||
| `c.Entitled(name)` | `Entitlement` | Permission check |
|
||||
| `c.RegistryOf(n)` | `*Registry` | Cross-cutting queries |
|
||||
| `c.I18n()` | `*I18n` | Internationalisation |
|
||||
|
||||
## Messaging
|
||||
|
||||
| Method | Pattern |
|
||||
|--------|---------|
|
||||
| `c.ACTION(msg)` | Broadcast to all handlers |
|
||||
| `c.ACTION(msg)` | Broadcast to all handlers (panic recovery per handler) |
|
||||
| `c.QUERY(q)` | First responder wins |
|
||||
| `c.QUERYALL(q)` | Collect all responses |
|
||||
| `c.PERFORM(task)` | First executor wins |
|
||||
| `c.PerformAsync(task)` | Background goroutine |
|
||||
| `c.PerformAsync(action, opts)` | Background goroutine with progress |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```go
|
||||
type Startable interface { OnStartup(ctx context.Context) Result }
|
||||
type Stoppable interface { OnShutdown(ctx context.Context) Result }
|
||||
```
|
||||
|
||||
`RunE()` always calls `defer ServiceShutdown` — even on startup failure or panic.
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
|
@ -83,13 +88,15 @@ Use `core.E()` for structured errors:
|
|||
return core.E("service.Method", "what failed", underlyingErr)
|
||||
```
|
||||
|
||||
## Test Naming
|
||||
**Never** use `fmt.Errorf`, `errors.New`, `os/exec`, or `unsafe.Pointer` on Core types.
|
||||
|
||||
`_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases).
|
||||
## Test Naming (AX-7)
|
||||
|
||||
`TestFile_Function_{Good,Bad,Ugly}` — 100% compliance.
|
||||
|
||||
## Docs
|
||||
|
||||
Full documentation in `docs/`. Start with `docs/getting-started.md`.
|
||||
Full API contract: `docs/RFC.md` (1476 lines, 21 sections).
|
||||
|
||||
## Go Workspace
|
||||
|
||||
|
|
|
|||
38
FINDINGS.md
Normal file
38
FINDINGS.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Specification Mismatches
|
||||
|
||||
## Scope
|
||||
Findings are mismatches between current repository source behavior and existing docs/spec pages under `docs/`.
|
||||
|
||||
### 1) `docs/getting-started.md` uses deprecated constructor pattern
|
||||
- Example and prose show `core.New(core.Options{...})` and say constructor reads only the first `core.Options`.
|
||||
- Current code uses variadic `core.New(...CoreOption)` only; passing `core.Options` requires `core.WithOptions(core.NewOptions(...))`.
|
||||
- References: `docs/getting-started.md:18`, `docs/getting-started.md:26`, `docs/getting-started.md:142`.
|
||||
|
||||
### 2) `docs/testing.md` and `docs/configuration.md` repeat outdated constructor usage
|
||||
- Both files document `core.New(core.Options{...})` examples.
|
||||
- Current constructor is variadic `CoreOption` values.
|
||||
- References: `docs/testing.md:29`, `docs/configuration.md:16`.
|
||||
|
||||
### 3) `docs/lifecycle.md` claims registry order is map-backed and unstable
|
||||
- File states `Startables()/Stoppables()` are built from a map-backed registry and therefore non-deterministic.
|
||||
- Current `Registry` stores an explicit insertion-order slice and iterates in insertion order.
|
||||
- References: `docs/lifecycle.md:64-67`.
|
||||
|
||||
### 4) `docs/services.md` stale ordering and lock-name behavior
|
||||
- Claims registry is map-backed; actual behavior is insertion-order iteration.
|
||||
- States default service lock name is `"srv"`, but `LockEnable`/`LockApply` do not expose/use a default namespace in implementation.
|
||||
- References: `docs/services.md:53`, `docs/services.md:86-88`.
|
||||
|
||||
### 5) `docs/commands.md` documents removed managed lifecycle field
|
||||
- Section “Lifecycle Commands” shows `Lifecycle` field with `Start/Stop/Restart/Reload/Signal` callbacks.
|
||||
- Current `Command` struct has `Managed string` and no `Lifecycle` field.
|
||||
- References: `docs/commands.md:155-159`.
|
||||
|
||||
### 6) `docs/subsystems.md` documents legacy options creation call for subsystem registration
|
||||
- Uses `c.Data().New(core.Options{...})` and `c.Drive().New(core.Options{...})`.
|
||||
- `Data.New` and `Drive.New` expect `core.Options` via varargs usage helpers (`core.NewOptions` in current docs/usage pattern).
|
||||
- References: `docs/subsystems.md:44`, `docs/subsystems.md:75`, `docs/subsystems.md:80`.
|
||||
|
||||
### 7) `docs/index.md` RFC summary is stale
|
||||
- Claims `docs/RFC.md` is 21 sections, 1476 lines, but current RFC content has expanded sections/size.
|
||||
- Reference: `docs/index.md` table header note.
|
||||
117
README.md
117
README.md
|
|
@ -1,8 +1,6 @@
|
|||
# CoreGO
|
||||
|
||||
Dependency injection, service lifecycle, command routing, and message-passing for Go.
|
||||
|
||||
Import path:
|
||||
Dependency injection, service lifecycle, permission, and message-passing for Go.
|
||||
|
||||
```go
|
||||
import "dappco.re/go/core"
|
||||
|
|
@ -14,75 +12,24 @@ CoreGO is the foundation layer for the Core ecosystem. It gives you:
|
|||
- one input shape: `Options`
|
||||
- one output shape: `Result`
|
||||
- one command tree: `Command`
|
||||
- one message bus: `ACTION`, `QUERY`, `PERFORM`
|
||||
|
||||
## Why It Exists
|
||||
|
||||
Most non-trivial Go systems end up needing the same small set of infrastructure:
|
||||
|
||||
- a place to keep runtime state and shared subsystems
|
||||
- a predictable way to start and stop managed components
|
||||
- a clean command surface for CLI-style workflows
|
||||
- decoupled communication between components without tight imports
|
||||
|
||||
CoreGO keeps those pieces small and explicit.
|
||||
- one message bus: `ACTION`, `QUERY` + named `Action` callables
|
||||
- one permission gate: `Entitled`
|
||||
- one collection primitive: `Registry[T]`
|
||||
|
||||
## Quick Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
type flushCacheTask struct {
|
||||
Name string
|
||||
}
|
||||
import "dappco.re/go/core"
|
||||
|
||||
func main() {
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
|
||||
c.Service("cache", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("cache started", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("cache stopped", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
c.RegisterTask(func(_ *core.Core, task core.Task) core.Result {
|
||||
switch t := task.(type) {
|
||||
case flushCacheTask:
|
||||
return core.Result{Value: "cache flushed for " + t.Name, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
c.Command("cache/flush", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return c.PERFORM(flushCacheTask{
|
||||
Name: opts.String("name"),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if !c.ServiceStartup(context.Background(), nil).OK {
|
||||
panic("startup failed")
|
||||
}
|
||||
|
||||
r := c.Cli().Run("cache", "flush", "--name=session-store")
|
||||
fmt.Println(r.Value)
|
||||
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
c := core.New(
|
||||
core.WithOption("name", "agent-workbench"),
|
||||
core.WithService(cache.Register),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.Run()
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -93,22 +40,16 @@ func main() {
|
|||
| `Core` | Central container and access point |
|
||||
| `Service` | Managed lifecycle component |
|
||||
| `Command` | Path-based executable operation |
|
||||
| `Cli` | CLI surface over the command tree |
|
||||
| `Action` | Named callable with panic recovery + entitlement |
|
||||
| `Task` | Composed sequence of Actions |
|
||||
| `Registry[T]` | Thread-safe named collection |
|
||||
| `Process` | Managed execution (Action sugar) |
|
||||
| `API` | Remote streams (protocol handlers) |
|
||||
| `Entitlement` | Permission check result |
|
||||
| `Data` | Embedded filesystem mounts |
|
||||
| `Drive` | Named transport handles |
|
||||
| `Fs` | Local filesystem operations |
|
||||
| `Fs` | Local filesystem (sandboxable) |
|
||||
| `Config` | Runtime settings and feature flags |
|
||||
| `I18n` | Locale collection and translation delegation |
|
||||
| `E`, `Wrap`, `ErrorLog`, `ErrorPanic` | Structured failures and panic recovery |
|
||||
|
||||
## AX-Friendly Model
|
||||
|
||||
CoreGO follows the same design direction as the AX spec:
|
||||
|
||||
- predictable names over compressed names
|
||||
- paths as documentation, such as `deploy/to/homelab`
|
||||
- one repeated vocabulary across the framework
|
||||
- examples that show how to call real APIs
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -121,30 +62,12 @@ Requires Go 1.26 or later.
|
|||
## Test
|
||||
|
||||
```bash
|
||||
core go test
|
||||
```
|
||||
|
||||
Or with the standard toolchain:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test ./... # 483 tests, 84.7% coverage
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
The full documentation set lives in `docs/`.
|
||||
|
||||
| Path | Covers |
|
||||
|------|--------|
|
||||
| `docs/getting-started.md` | First runnable CoreGO app |
|
||||
| `docs/primitives.md` | `Options`, `Result`, `Service`, `Message`, `Query`, `Task` |
|
||||
| `docs/services.md` | Service registry, runtime helpers, service locks |
|
||||
| `docs/commands.md` | Path-based commands and CLI execution |
|
||||
| `docs/messaging.md` | `ACTION`, `QUERY`, `QUERYALL`, `PERFORM`, `PerformAsync` |
|
||||
| `docs/lifecycle.md` | Startup, shutdown, context, and task draining |
|
||||
| `docs/subsystems.md` | `App`, `Data`, `Drive`, `Fs`, `I18n`, `Cli` |
|
||||
| `docs/errors.md` | Structured errors, logging helpers, panic recovery |
|
||||
| `docs/testing.md` | Test naming and framework testing patterns |
|
||||
The authoritative API contract is `docs/RFC.md` (21 sections).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
233
action.go
Normal file
233
action.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Named action system for the Core framework.
|
||||
// Actions are the atomic unit of work — named, registered, invokable,
|
||||
// and inspectable. The Action registry IS the capability map.
|
||||
//
|
||||
// Register a named action:
|
||||
//
|
||||
// c.Action("git.log", func(ctx context.Context, opts core.Options) core.Result {
|
||||
// dir := opts.String("dir")
|
||||
// return c.Process().RunIn(ctx, dir, "git", "log")
|
||||
// })
|
||||
//
|
||||
// Invoke by name:
|
||||
//
|
||||
// r := c.Action("git.log").Run(ctx, core.NewOptions(
|
||||
// core.Option{Key: "dir", Value: "/path/to/repo"},
|
||||
// ))
|
||||
//
|
||||
// Check capability:
|
||||
//
|
||||
// if c.Action("process.run").Exists() { ... }
|
||||
//
|
||||
// List all:
|
||||
//
|
||||
// names := c.Actions() // ["process.run", "agentic.dispatch", ...]
|
||||
package core
|
||||
|
||||
import "context"
|
||||
|
||||
// ActionHandler is the function signature for all named actions.
|
||||
//
|
||||
// func(ctx context.Context, opts core.Options) core.Result
|
||||
type ActionHandler func(context.Context, Options) Result
|
||||
|
||||
// Action is a registered named action.
|
||||
//
|
||||
// action := c.Action("process.run")
|
||||
// action.Description // "Execute a command"
|
||||
// action.Schema // expected input keys
|
||||
type Action struct {
|
||||
Name string
|
||||
Handler ActionHandler
|
||||
Description string
|
||||
Schema Options // declares expected input keys (optional)
|
||||
enabled bool
|
||||
core *Core // for entitlement checks during Run()
|
||||
}
|
||||
|
||||
// Run executes the action with panic recovery.
|
||||
// Returns Result{OK: false} if the action has no handler (not registered).
|
||||
//
|
||||
// r := c.Action("process.run").Run(ctx, opts)
|
||||
func (a *Action) Run(ctx context.Context, opts Options) (result Result) {
|
||||
if a == nil || a.Handler == nil {
|
||||
return Result{E("action.Run", Concat("action not registered: ", a.safeName()), nil), false}
|
||||
}
|
||||
if !a.enabled {
|
||||
return Result{E("action.Run", Concat("action disabled: ", a.Name), nil), false}
|
||||
}
|
||||
// Entitlement check — permission boundary
|
||||
if a.core != nil {
|
||||
if e := a.core.Entitled(a.Name); !e.Allowed {
|
||||
return Result{E("action.Run", Concat("not entitled: ", a.Name, " — ", e.Reason), nil), false}
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
result = Result{E("action.Run", Sprint("panic in action ", a.Name, ": ", r), nil), false}
|
||||
}
|
||||
}()
|
||||
return a.Handler(ctx, opts)
|
||||
}
|
||||
|
||||
// Exists returns true if this action has a registered handler.
|
||||
//
|
||||
// if c.Action("process.run").Exists() { ... }
|
||||
func (a *Action) Exists() bool {
|
||||
return a != nil && a.Handler != nil
|
||||
}
|
||||
|
||||
func (a *Action) safeName() string {
|
||||
if a == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// --- Core accessor ---
|
||||
|
||||
// Action gets or registers a named action.
|
||||
// With a handler argument: registers the action.
|
||||
// Without: returns the action for invocation.
|
||||
//
|
||||
// c.Action("process.run", handler) // register
|
||||
// c.Action("process.run").Run(ctx, opts) // invoke
|
||||
// c.Action("process.run").Exists() // check
|
||||
func (c *Core) Action(name string, handler ...ActionHandler) *Action {
|
||||
if len(handler) > 0 {
|
||||
def := &Action{Name: name, Handler: handler[0], enabled: true, core: c}
|
||||
c.ipc.actions.Set(name, def)
|
||||
return def
|
||||
}
|
||||
r := c.ipc.actions.Get(name)
|
||||
if !r.OK {
|
||||
return &Action{Name: name} // no handler — Exists() returns false
|
||||
}
|
||||
return r.Value.(*Action)
|
||||
}
|
||||
|
||||
// Actions returns all registered named action names in registration order.
|
||||
//
|
||||
// names := c.Actions() // ["process.run", "agentic.dispatch"]
|
||||
func (c *Core) Actions() []string {
|
||||
return c.ipc.actions.Names()
|
||||
}
|
||||
|
||||
// --- Task Composition ---
|
||||
|
||||
// Step is a single step in a Task — references an Action by name.
|
||||
//
|
||||
// core.Step{Action: "agentic.qa"}
|
||||
// core.Step{Action: "agentic.poke", Async: true}
|
||||
// core.Step{Action: "agentic.verify", Input: "previous"}
|
||||
type Step struct {
|
||||
Action string // name of the Action to invoke
|
||||
With Options // static options (merged with runtime opts)
|
||||
Async bool // run in background, don't block
|
||||
Input string // "previous" = output of last step piped as input
|
||||
}
|
||||
|
||||
// Task is a named sequence of Steps.
|
||||
//
|
||||
// c.Task("agent.completion", core.Task{
|
||||
// Steps: []core.Step{
|
||||
// {Action: "agentic.qa"},
|
||||
// {Action: "agentic.auto-pr"},
|
||||
// {Action: "agentic.verify"},
|
||||
// {Action: "agentic.poke", Async: true},
|
||||
// },
|
||||
// })
|
||||
type Task struct {
|
||||
Name string
|
||||
Description string
|
||||
Steps []Step
|
||||
}
|
||||
|
||||
// Run executes the task's steps in order. Sync steps run sequentially —
|
||||
// if any fails, the chain stops. Async steps are dispatched and don't block.
|
||||
// The "previous" input pipes the last sync step's output to the next step.
|
||||
//
|
||||
// r := c.Task("deploy").Run(ctx, opts)
|
||||
func (t *Task) Run(ctx context.Context, c *Core, opts Options) Result {
|
||||
if t == nil || len(t.Steps) == 0 {
|
||||
return Result{E("task.Run", Concat("task has no steps: ", t.safeName()), nil), false}
|
||||
}
|
||||
|
||||
var lastResult Result
|
||||
for _, step := range t.Steps {
|
||||
// Use step's own options, or runtime options if step has none
|
||||
stepOpts := stepOptions(step)
|
||||
if stepOpts.Len() == 0 {
|
||||
stepOpts = opts
|
||||
}
|
||||
|
||||
// Pipe previous result as input
|
||||
if step.Input == "previous" && lastResult.OK {
|
||||
stepOpts.Set("_input", lastResult.Value)
|
||||
}
|
||||
|
||||
action := c.Action(step.Action)
|
||||
if !action.Exists() {
|
||||
return Result{E("task.Run", Concat("action not found: ", step.Action), nil), false}
|
||||
}
|
||||
|
||||
if step.Async {
|
||||
// Fire and forget — don't block the chain
|
||||
go func(a *Action, o Options) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
Error("async task step panicked", "action", a.Name, "panic", r)
|
||||
}
|
||||
}()
|
||||
a.Run(ctx, o)
|
||||
}(action, stepOpts)
|
||||
continue
|
||||
}
|
||||
|
||||
lastResult = action.Run(ctx, stepOpts)
|
||||
if !lastResult.OK {
|
||||
return lastResult
|
||||
}
|
||||
}
|
||||
return lastResult
|
||||
}
|
||||
|
||||
func (t *Task) safeName() string {
|
||||
if t == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
// mergeStepOptions returns the step's With options — runtime opts are passed directly.
|
||||
// Step.With provides static defaults that the step was registered with.
|
||||
func stepOptions(step Step) Options {
|
||||
return step.With
|
||||
}
|
||||
|
||||
// Task gets or registers a named task.
|
||||
// With a Task argument: registers the task.
|
||||
// Without: returns the task for invocation.
|
||||
//
|
||||
// c.Task("deploy", core.Task{Steps: steps}) // register
|
||||
// c.Task("deploy").Run(ctx, c, opts) // invoke
|
||||
func (c *Core) Task(name string, def ...Task) *Task {
|
||||
if len(def) > 0 {
|
||||
d := def[0]
|
||||
d.Name = name
|
||||
c.ipc.tasks.Set(name, &d)
|
||||
return &d
|
||||
}
|
||||
r := c.ipc.tasks.Get(name)
|
||||
if !r.OK {
|
||||
return &Task{Name: name}
|
||||
}
|
||||
return r.Value.(*Task)
|
||||
}
|
||||
|
||||
// Tasks returns all registered task names.
|
||||
func (c *Core) Tasks() []string {
|
||||
return c.ipc.tasks.Names()
|
||||
}
|
||||
59
action_example_test.go
Normal file
59
action_example_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleAction_Run() {
|
||||
c := New()
|
||||
c.Action("double", func(_ context.Context, opts Options) Result {
|
||||
return Result{Value: opts.Int("n") * 2, OK: true}
|
||||
})
|
||||
|
||||
r := c.Action("double").Run(context.Background(), NewOptions(
|
||||
Option{Key: "n", Value: 21},
|
||||
))
|
||||
Println(r.Value)
|
||||
// Output: 42
|
||||
}
|
||||
|
||||
func ExampleAction_Exists() {
|
||||
c := New()
|
||||
Println(c.Action("missing").Exists())
|
||||
|
||||
c.Action("present", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
Println(c.Action("present").Exists())
|
||||
// Output:
|
||||
// false
|
||||
// true
|
||||
}
|
||||
|
||||
func ExampleAction_Run_panicRecovery() {
|
||||
c := New()
|
||||
c.Action("boom", func(_ context.Context, _ Options) Result {
|
||||
panic("explosion")
|
||||
})
|
||||
|
||||
r := c.Action("boom").Run(context.Background(), NewOptions())
|
||||
Println(r.OK)
|
||||
// Output: false
|
||||
}
|
||||
|
||||
func ExampleAction_Run_entitlementDenied() {
|
||||
c := New()
|
||||
c.Action("premium", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "secret", OK: true}
|
||||
})
|
||||
c.SetEntitlementChecker(func(action string, _ int, _ context.Context) Entitlement {
|
||||
if action == "premium" {
|
||||
return Entitlement{Allowed: false, Reason: "upgrade"}
|
||||
}
|
||||
return Entitlement{Allowed: true, Unlimited: true}
|
||||
})
|
||||
|
||||
r := c.Action("premium").Run(context.Background(), NewOptions())
|
||||
Println(r.OK)
|
||||
// Output: false
|
||||
}
|
||||
246
action_test.go
Normal file
246
action_test.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- NamedAction Register ---
|
||||
|
||||
func TestAction_NamedAction_Good_Register(t *testing.T) {
|
||||
c := New()
|
||||
def := c.Action("process.run", func(_ context.Context, opts Options) Result {
|
||||
return Result{Value: "output", OK: true}
|
||||
})
|
||||
assert.NotNil(t, def)
|
||||
assert.Equal(t, "process.run", def.Name)
|
||||
assert.True(t, def.Exists())
|
||||
}
|
||||
|
||||
func TestAction_NamedAction_Good_Invoke(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("git.log", func(_ context.Context, opts Options) Result {
|
||||
dir := opts.String("dir")
|
||||
return Result{Value: Concat("log from ", dir), OK: true}
|
||||
})
|
||||
|
||||
r := c.Action("git.log").Run(context.Background(), NewOptions(
|
||||
Option{Key: "dir", Value: "/repo"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "log from /repo", r.Value)
|
||||
}
|
||||
|
||||
func TestAction_NamedAction_Bad_NotRegistered(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Action("missing.action").Run(context.Background(), NewOptions())
|
||||
assert.False(t, r.OK, "invoking unregistered action must fail")
|
||||
}
|
||||
|
||||
func TestAction_NamedAction_Good_Exists(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("brain.recall", func(_ context.Context, _ Options) Result {
|
||||
return Result{OK: true}
|
||||
})
|
||||
assert.True(t, c.Action("brain.recall").Exists())
|
||||
assert.False(t, c.Action("brain.forget").Exists())
|
||||
}
|
||||
|
||||
func TestAction_NamedAction_Ugly_PanicRecovery(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("explode", func(_ context.Context, _ Options) Result {
|
||||
panic("boom")
|
||||
})
|
||||
r := c.Action("explode").Run(context.Background(), NewOptions())
|
||||
assert.False(t, r.OK, "panicking action must return !OK, not crash")
|
||||
err, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, err.Error(), "panic")
|
||||
}
|
||||
|
||||
func TestAction_NamedAction_Ugly_NilAction(t *testing.T) {
|
||||
var def *Action
|
||||
r := def.Run(context.Background(), NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
assert.False(t, def.Exists())
|
||||
}
|
||||
|
||||
// --- Actions listing ---
|
||||
|
||||
func TestAction_Actions_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
c.Action("process.kill", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
c.Action("agentic.dispatch", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
|
||||
names := c.Actions()
|
||||
assert.Len(t, names, 3)
|
||||
assert.Equal(t, []string{"process.run", "process.kill", "agentic.dispatch"}, names)
|
||||
}
|
||||
|
||||
func TestAction_Actions_Bad_Empty(t *testing.T) {
|
||||
c := New()
|
||||
assert.Empty(t, c.Actions())
|
||||
}
|
||||
|
||||
// --- Action fields ---
|
||||
|
||||
func TestAction_NamedAction_Good_DescriptionAndSchema(t *testing.T) {
|
||||
c := New()
|
||||
def := c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
def.Description = "Execute a command synchronously"
|
||||
def.Schema = NewOptions(
|
||||
Option{Key: "command", Value: "string"},
|
||||
Option{Key: "args", Value: "[]string"},
|
||||
)
|
||||
|
||||
retrieved := c.Action("process.run")
|
||||
assert.Equal(t, "Execute a command synchronously", retrieved.Description)
|
||||
assert.True(t, retrieved.Schema.Has("command"))
|
||||
}
|
||||
|
||||
// --- Permission by registration ---
|
||||
|
||||
func TestAction_NamedAction_Good_PermissionModel(t *testing.T) {
|
||||
// Full Core — process registered
|
||||
full := New()
|
||||
full.Action("process.run", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "executed", OK: true}
|
||||
})
|
||||
|
||||
// Sandboxed Core — no process
|
||||
sandboxed := New()
|
||||
|
||||
// Full can execute
|
||||
r := full.Action("process.run").Run(context.Background(), NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
|
||||
// Sandboxed returns not-registered
|
||||
r = sandboxed.Action("process.run").Run(context.Background(), NewOptions())
|
||||
assert.False(t, r.OK, "sandboxed Core must not have process capability")
|
||||
}
|
||||
|
||||
// --- Action overwrite ---
|
||||
|
||||
func TestAction_NamedAction_Good_Overwrite(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("hot.reload", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "v1", OK: true}
|
||||
})
|
||||
c.Action("hot.reload", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "v2", OK: true}
|
||||
})
|
||||
|
||||
r := c.Action("hot.reload").Run(context.Background(), NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "v2", r.Value, "latest handler wins")
|
||||
}
|
||||
|
||||
// --- Task Composition ---
|
||||
|
||||
func TestAction_Task_Good_Sequential(t *testing.T) {
|
||||
c := New()
|
||||
var order []string
|
||||
c.Action("step.a", func(_ context.Context, _ Options) Result {
|
||||
order = append(order, "a")
|
||||
return Result{Value: "output-a", OK: true}
|
||||
})
|
||||
c.Action("step.b", func(_ context.Context, _ Options) Result {
|
||||
order = append(order, "b")
|
||||
return Result{Value: "output-b", OK: true}
|
||||
})
|
||||
|
||||
c.Task("pipeline", Task{
|
||||
Steps: []Step{
|
||||
{Action: "step.a"},
|
||||
{Action: "step.b"},
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Task("pipeline").Run(context.Background(), c, NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, []string{"a", "b"}, order, "steps must run in order")
|
||||
assert.Equal(t, "output-b", r.Value, "last step's result is returned")
|
||||
}
|
||||
|
||||
func TestAction_Task_Bad_StepFails(t *testing.T) {
|
||||
c := New()
|
||||
var order []string
|
||||
c.Action("step.ok", func(_ context.Context, _ Options) Result {
|
||||
order = append(order, "ok")
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.Action("step.fail", func(_ context.Context, _ Options) Result {
|
||||
order = append(order, "fail")
|
||||
return Result{Value: NewError("broke"), OK: false}
|
||||
})
|
||||
c.Action("step.never", func(_ context.Context, _ Options) Result {
|
||||
order = append(order, "never")
|
||||
return Result{OK: true}
|
||||
})
|
||||
|
||||
c.Task("broken", Task{
|
||||
Steps: []Step{
|
||||
{Action: "step.ok"},
|
||||
{Action: "step.fail"},
|
||||
{Action: "step.never"},
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Task("broken").Run(context.Background(), c, NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
assert.Equal(t, []string{"ok", "fail"}, order, "chain stops on failure, step.never skipped")
|
||||
}
|
||||
|
||||
func TestAction_Task_Bad_MissingAction(t *testing.T) {
|
||||
c := New()
|
||||
c.Task("missing", Task{
|
||||
Steps: []Step{
|
||||
{Action: "nonexistent"},
|
||||
},
|
||||
})
|
||||
r := c.Task("missing").Run(context.Background(), c, NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestAction_Task_Good_PreviousInput(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("produce", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "data-from-step-1", OK: true}
|
||||
})
|
||||
c.Action("consume", func(_ context.Context, opts Options) Result {
|
||||
input := opts.Get("_input")
|
||||
if !input.OK {
|
||||
return Result{Value: "no input", OK: true}
|
||||
}
|
||||
return Result{Value: "got: " + input.Value.(string), OK: true}
|
||||
})
|
||||
|
||||
c.Task("pipe", Task{
|
||||
Steps: []Step{
|
||||
{Action: "produce"},
|
||||
{Action: "consume", Input: "previous"},
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Task("pipe").Run(context.Background(), c, NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "got: data-from-step-1", r.Value)
|
||||
}
|
||||
|
||||
func TestAction_Task_Ugly_EmptySteps(t *testing.T) {
|
||||
c := New()
|
||||
c.Task("empty", Task{})
|
||||
r := c.Task("empty").Run(context.Background(), c, NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestAction_Tasks_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Task("deploy", Task{Steps: []Step{{Action: "x"}}})
|
||||
c.Task("review", Task{Steps: []Step{{Action: "y"}}})
|
||||
assert.Equal(t, []string{"deploy", "review"}, c.Tasks())
|
||||
}
|
||||
157
api.go
Normal file
157
api.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Remote communication primitive for the Core framework.
|
||||
// API manages named streams to remote endpoints. The transport protocol
|
||||
// (HTTP, WebSocket, SSE, MCP, TCP) is handled by protocol handlers
|
||||
// registered by consumer packages.
|
||||
//
|
||||
// Drive is the phone book (WHERE to connect).
|
||||
// API is the phone (HOW to connect).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// // Configure endpoint
|
||||
// c.Drive().New(core.NewOptions(
|
||||
// core.Option{Key: "name", Value: "charon"},
|
||||
// core.Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"},
|
||||
// ))
|
||||
//
|
||||
// // Open stream
|
||||
// s := c.API().Stream("charon")
|
||||
// if s.OK { stream := s.Value.(Stream) }
|
||||
//
|
||||
// // Remote Action dispatch
|
||||
// r := c.API().Call("charon", "agentic.status", opts)
|
||||
package core
|
||||
|
||||
import "context"
|
||||
|
||||
// Stream is a bidirectional connection to a remote endpoint.
|
||||
// Consumers implement this for each transport protocol.
|
||||
//
|
||||
// type httpStream struct { ... }
|
||||
// func (s *httpStream) Send(data []byte) error { ... }
|
||||
// func (s *httpStream) Receive() ([]byte, error) { ... }
|
||||
// func (s *httpStream) Close() error { ... }
|
||||
type Stream interface {
|
||||
Send(data []byte) error
|
||||
Receive() ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// StreamFactory creates a Stream from a DriveHandle's transport config.
|
||||
// Registered per-protocol by consumer packages.
|
||||
type StreamFactory func(handle *DriveHandle) (Stream, error)
|
||||
|
||||
// API manages remote streams and protocol handlers.
|
||||
type API struct {
|
||||
core *Core
|
||||
protocols *Registry[StreamFactory]
|
||||
}
|
||||
|
||||
// API returns the remote communication primitive.
|
||||
//
|
||||
// c.API().Stream("charon")
|
||||
func (c *Core) API() *API {
|
||||
return c.api
|
||||
}
|
||||
|
||||
// RegisterProtocol registers a stream factory for a URL scheme.
|
||||
// Consumer packages call this during OnStartup.
|
||||
//
|
||||
// c.API().RegisterProtocol("http", httpStreamFactory)
|
||||
// c.API().RegisterProtocol("https", httpStreamFactory)
|
||||
// c.API().RegisterProtocol("mcp", mcpStreamFactory)
|
||||
func (a *API) RegisterProtocol(scheme string, factory StreamFactory) {
|
||||
a.protocols.Set(scheme, factory)
|
||||
}
|
||||
|
||||
// Stream opens a connection to a named endpoint.
|
||||
// Looks up the endpoint in Drive, extracts the protocol from the transport URL,
|
||||
// and delegates to the registered protocol handler.
|
||||
//
|
||||
// r := c.API().Stream("charon")
|
||||
// if r.OK { stream := r.Value.(Stream) }
|
||||
func (a *API) Stream(name string) Result {
|
||||
r := a.core.Drive().Get(name)
|
||||
if !r.OK {
|
||||
return Result{E("api.Stream", Concat("endpoint not found in Drive: ", name), nil), false}
|
||||
}
|
||||
|
||||
handle := r.Value.(*DriveHandle)
|
||||
scheme := extractScheme(handle.Transport)
|
||||
|
||||
fr := a.protocols.Get(scheme)
|
||||
if !fr.OK {
|
||||
return Result{E("api.Stream", Concat("no protocol handler for scheme: ", scheme), nil), false}
|
||||
}
|
||||
|
||||
factory := fr.Value.(StreamFactory)
|
||||
stream, err := factory(handle)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{stream, true}
|
||||
}
|
||||
|
||||
// Call invokes a named Action on a remote endpoint.
|
||||
// This is the remote equivalent of c.Action("name").Run(ctx, opts).
|
||||
//
|
||||
// r := c.API().Call("charon", "agentic.status", opts)
|
||||
func (a *API) Call(endpoint string, action string, opts Options) Result {
|
||||
r := a.Stream(endpoint)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
|
||||
stream := r.Value.(Stream)
|
||||
defer stream.Close()
|
||||
|
||||
// Encode the action call as JSON-RPC (MCP compatible)
|
||||
payload := Concat(`{"action":"`, action, `","options":`, JSONMarshalString(opts), `}`)
|
||||
|
||||
if err := stream.Send([]byte(payload)); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
response, err := stream.Receive()
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
return Result{string(response), true}
|
||||
}
|
||||
|
||||
// Protocols returns all registered protocol scheme names.
|
||||
func (a *API) Protocols() []string {
|
||||
return a.protocols.Names()
|
||||
}
|
||||
|
||||
// extractScheme pulls the protocol from a transport URL.
|
||||
// "http://host:port/path" → "http"
|
||||
// "mcp://host:port" → "mcp"
|
||||
func extractScheme(transport string) string {
|
||||
for i, c := range transport {
|
||||
if c == ':' {
|
||||
return transport[:i]
|
||||
}
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
|
||||
// RemoteAction resolves "host:action.name" syntax for transparent remote dispatch.
|
||||
// If the action name contains ":", the prefix is the endpoint and the suffix is the action.
|
||||
//
|
||||
// c.Action("charon:agentic.status") // → c.API().Call("charon", "agentic.status", opts)
|
||||
func (c *Core) RemoteAction(name string, ctx context.Context, opts Options) Result {
|
||||
for i, ch := range name {
|
||||
if ch == ':' {
|
||||
endpoint := name[:i]
|
||||
action := name[i+1:]
|
||||
return c.API().Call(endpoint, action, opts)
|
||||
}
|
||||
}
|
||||
// No ":" — local action
|
||||
return c.Action(name).Run(ctx, opts)
|
||||
}
|
||||
49
api_example_test.go
Normal file
49
api_example_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleAPI_RegisterProtocol() {
|
||||
c := New()
|
||||
c.API().RegisterProtocol("http", func(h *DriveHandle) (Stream, error) {
|
||||
return &mockStream{response: []byte("pong")}, nil
|
||||
})
|
||||
Println(c.API().Protocols())
|
||||
// Output: [http]
|
||||
}
|
||||
|
||||
func ExampleAPI_Stream() {
|
||||
c := New()
|
||||
c.API().RegisterProtocol("http", func(h *DriveHandle) (Stream, error) {
|
||||
return &mockStream{response: []byte(Concat("connected to ", h.Name))}, nil
|
||||
})
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "charon"},
|
||||
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
|
||||
))
|
||||
|
||||
r := c.API().Stream("charon")
|
||||
if r.OK {
|
||||
stream := r.Value.(Stream)
|
||||
resp, _ := stream.Receive()
|
||||
Println(string(resp))
|
||||
stream.Close()
|
||||
}
|
||||
// Output: connected to charon
|
||||
}
|
||||
|
||||
func ExampleCore_RemoteAction() {
|
||||
c := New()
|
||||
// Local action
|
||||
c.Action("status", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "running", OK: true}
|
||||
})
|
||||
|
||||
// No colon — resolves locally
|
||||
r := c.RemoteAction("status", context.Background(), NewOptions())
|
||||
Println(r.Value)
|
||||
// Output: running
|
||||
}
|
||||
156
api_test.go
Normal file
156
api_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- mock stream for testing ---
|
||||
|
||||
type mockStream struct {
|
||||
sent []byte
|
||||
response []byte
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *mockStream) Send(data []byte) error {
|
||||
s.sent = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStream) Receive() ([]byte, error) {
|
||||
return s.response, nil
|
||||
}
|
||||
|
||||
func (s *mockStream) Close() error {
|
||||
s.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func mockFactory(response string) StreamFactory {
|
||||
return func(handle *DriveHandle) (Stream, error) {
|
||||
return &mockStream{response: []byte(response)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
func TestApi_API_Good_Accessor(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.API())
|
||||
}
|
||||
|
||||
// --- RegisterProtocol ---
|
||||
|
||||
func TestApi_RegisterProtocol_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.API().RegisterProtocol("http", mockFactory("ok"))
|
||||
assert.Contains(t, c.API().Protocols(), "http")
|
||||
}
|
||||
|
||||
// --- Stream ---
|
||||
|
||||
func TestApi_Stream_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.API().RegisterProtocol("http", mockFactory("pong"))
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "charon"},
|
||||
Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"},
|
||||
))
|
||||
|
||||
r := c.API().Stream("charon")
|
||||
assert.True(t, r.OK)
|
||||
|
||||
stream := r.Value.(Stream)
|
||||
stream.Send([]byte("ping"))
|
||||
resp, err := stream.Receive()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pong", string(resp))
|
||||
stream.Close()
|
||||
}
|
||||
|
||||
func TestApi_Stream_Bad_EndpointNotFound(t *testing.T) {
|
||||
c := New()
|
||||
r := c.API().Stream("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestApi_Stream_Bad_NoProtocolHandler(t *testing.T) {
|
||||
c := New()
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "unknown"},
|
||||
Option{Key: "transport", Value: "grpc://host:port"},
|
||||
))
|
||||
|
||||
r := c.API().Stream("unknown")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Call ---
|
||||
|
||||
func TestApi_Call_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.API().RegisterProtocol("http", mockFactory(`{"status":"ok"}`))
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "charon"},
|
||||
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
|
||||
))
|
||||
|
||||
r := c.API().Call("charon", "agentic.status", NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "ok")
|
||||
}
|
||||
|
||||
func TestApi_Call_Bad_EndpointNotFound(t *testing.T) {
|
||||
c := New()
|
||||
r := c.API().Call("missing", "action", NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- RemoteAction ---
|
||||
|
||||
func TestApi_RemoteAction_Good_Local(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("local.action", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "local", OK: true}
|
||||
})
|
||||
|
||||
r := c.RemoteAction("local.action", context.Background(), NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "local", r.Value)
|
||||
}
|
||||
|
||||
func TestApi_RemoteAction_Good_Remote(t *testing.T) {
|
||||
c := New()
|
||||
c.API().RegisterProtocol("http", mockFactory(`{"value":"remote"}`))
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "charon"},
|
||||
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
|
||||
))
|
||||
|
||||
r := c.RemoteAction("charon:agentic.status", context.Background(), NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "remote")
|
||||
}
|
||||
|
||||
func TestApi_RemoteAction_Ugly_NoColon(t *testing.T) {
|
||||
c := New()
|
||||
// No colon — falls through to local action (which doesn't exist)
|
||||
r := c.RemoteAction("nonexistent", context.Background(), NewOptions())
|
||||
assert.False(t, r.OK, "non-existent local action should fail")
|
||||
}
|
||||
|
||||
// --- extractScheme ---
|
||||
|
||||
func TestApi_Ugly_SchemeExtraction(t *testing.T) {
|
||||
c := New()
|
||||
// Verify scheme parsing works by registering different protocols
|
||||
c.API().RegisterProtocol("http", mockFactory("http"))
|
||||
c.API().RegisterProtocol("mcp", mockFactory("mcp"))
|
||||
c.API().RegisterProtocol("ws", mockFactory("ws"))
|
||||
|
||||
assert.Equal(t, 3, len(c.API().Protocols()))
|
||||
}
|
||||
102
app.go
102
app.go
|
|
@ -1,53 +1,93 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Application identity for the Core framework.
|
||||
// Based on leaanthony/sail — Name, Filename, Path.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// Uses os.Stat to search PATH directories — no os/exec dependency.
|
||||
//
|
||||
// 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 {
|
||||
path, err := exec.LookPath(filename)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
func (a App) Find(filename, name string) Result {
|
||||
// If filename contains a separator, check it directly
|
||||
if Contains(filename, string(os.PathSeparator)) {
|
||||
abs, err := filepath.Abs(filename)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if isExecutable(abs) {
|
||||
return Result{&App{Name: name, Filename: filename, Path: abs}, true}
|
||||
}
|
||||
return Result{E("app.Find", Concat(filename, " not found"), nil), false}
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
|
||||
// Search PATH
|
||||
pathEnv := os.Getenv("PATH")
|
||||
if pathEnv == "" {
|
||||
return Result{E("app.Find", "PATH is empty", nil), false}
|
||||
}
|
||||
return Result{&App{
|
||||
Name: name,
|
||||
Filename: filename,
|
||||
Path: abs,
|
||||
}, true}
|
||||
for _, dir := range Split(pathEnv, string(os.PathListSeparator)) {
|
||||
candidate := filepath.Join(dir, filename)
|
||||
if isExecutable(candidate) {
|
||||
abs, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return Result{&App{Name: name, Filename: filename, Path: abs}, true}
|
||||
}
|
||||
}
|
||||
return Result{E("app.Find", Concat(filename, " not found on PATH"), nil), false}
|
||||
}
|
||||
|
||||
// isExecutable checks if a path exists and is executable.
|
||||
func isExecutable(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Regular file with at least one execute bit
|
||||
return !info.IsDir() && info.Mode()&0111 != 0
|
||||
}
|
||||
|
|
|
|||
45
app_test.go
45
app_test.go
|
|
@ -7,33 +7,62 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- App ---
|
||||
// --- App.New ---
|
||||
|
||||
func TestApp_Good(t *testing.T) {
|
||||
c := New(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core)
|
||||
func TestApp_New_Good(t *testing.T) {
|
||||
app := App{}.New(NewOptions(
|
||||
Option{Key: "name", Value: "myapp"},
|
||||
Option{Key: "version", Value: "1.0.0"},
|
||||
Option{Key: "description", Value: "test app"},
|
||||
))
|
||||
assert.Equal(t, "myapp", app.Name)
|
||||
assert.Equal(t, "1.0.0", app.Version)
|
||||
assert.Equal(t, "test app", app.Description)
|
||||
}
|
||||
|
||||
func TestApp_New_Empty_Good(t *testing.T) {
|
||||
app := App{}.New(NewOptions())
|
||||
assert.Equal(t, "", app.Name)
|
||||
assert.Equal(t, "", app.Version)
|
||||
}
|
||||
|
||||
func TestApp_New_Partial_Good(t *testing.T) {
|
||||
app := App{}.New(NewOptions(
|
||||
Option{Key: "name", Value: "myapp"},
|
||||
))
|
||||
assert.Equal(t, "myapp", app.Name)
|
||||
assert.Equal(t, "", app.Version)
|
||||
}
|
||||
|
||||
// --- App via Core ---
|
||||
|
||||
func TestApp_Core_Good(t *testing.T) {
|
||||
c := New(WithOption("name", "myapp"))
|
||||
assert.Equal(t, "myapp", c.App().Name)
|
||||
}
|
||||
|
||||
func TestApp_Empty_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestApp_Core_Empty_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.App())
|
||||
assert.Equal(t, "", c.App().Name)
|
||||
}
|
||||
|
||||
func TestApp_Runtime_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.App().Runtime = &struct{ Name string }{Name: "wails"}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
41
array_example_test.go
Normal file
41
array_example_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleNewArray() {
|
||||
a := NewArray[string]()
|
||||
a.Add("alpha")
|
||||
a.Add("bravo")
|
||||
a.Add("charlie")
|
||||
|
||||
Println(a.Len())
|
||||
Println(a.Contains("bravo"))
|
||||
// Output:
|
||||
// 3
|
||||
// true
|
||||
}
|
||||
|
||||
func ExampleArray_AddUnique() {
|
||||
a := NewArray[string]()
|
||||
a.AddUnique("alpha")
|
||||
a.AddUnique("alpha") // no duplicate
|
||||
a.AddUnique("bravo")
|
||||
|
||||
Println(a.Len())
|
||||
// Output: 2
|
||||
}
|
||||
|
||||
func ExampleArray_Filter() {
|
||||
a := NewArray[int]()
|
||||
a.Add(1)
|
||||
a.Add(2)
|
||||
a.Add(3)
|
||||
a.Add(4)
|
||||
|
||||
r := a.Filter(func(n int) bool { return n%2 == 0 })
|
||||
Println(r.OK)
|
||||
// Output: true
|
||||
}
|
||||
75
cli.go
75
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,19 +55,16 @@ 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()
|
||||
|
||||
if cmdCount == 0 {
|
||||
if c.commands.Len() == 0 {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
|
|
@ -72,16 +75,14 @@ func (cl *Cli) Run(args ...string) Result {
|
|||
var cmd *Command
|
||||
var remaining []string
|
||||
|
||||
cl.core.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 r := c.commands.Get(path); r.OK {
|
||||
cmd = r.Value.(*Command)
|
||||
remaining = clean[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
cl.core.commands.mu.RUnlock()
|
||||
|
||||
if cmd == nil {
|
||||
if cl.banner != nil {
|
||||
|
|
@ -92,26 +93,23 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Action != nil {
|
||||
return cmd.Run(opts)
|
||||
}
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Start(opts)
|
||||
}
|
||||
return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
|
||||
}
|
||||
|
||||
|
|
@ -119,13 +117,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,21 +132,18 @@ func (cl *Cli) PrintHelp() {
|
|||
cl.Print("Commands:")
|
||||
}
|
||||
|
||||
cl.core.commands.mu.RLock()
|
||||
defer cl.core.commands.mu.RUnlock()
|
||||
|
||||
for path, cmd := range cl.core.commands.commands {
|
||||
if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) {
|
||||
continue
|
||||
c.commands.Each(func(path string, cmd *Command) {
|
||||
if cmd.Hidden || (cmd.Action == nil && !cmd.IsManaged()) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
cl.Print(" %-30s %s", path, desc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SetBanner sets the banner function.
|
||||
|
|
@ -162,8 +158,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 ""
|
||||
}
|
||||
|
|
|
|||
18
cli_test.go
18
cli_test.go
|
|
@ -11,23 +11,23 @@ import (
|
|||
// --- Cli Surface ---
|
||||
|
||||
func TestCli_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.NotNil(t, c.Cli())
|
||||
}
|
||||
|
||||
func TestCli_Banner_Good(t *testing.T) {
|
||||
c := New(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core)
|
||||
c := New(WithOption("name", "myapp"))
|
||||
assert.Equal(t, "myapp", c.Cli().Banner())
|
||||
}
|
||||
|
||||
func TestCli_SetBanner_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Cli().SetBanner(func(_ *Cli) string { return "Custom Banner" })
|
||||
assert.Equal(t, "Custom Banner", c.Cli().Banner())
|
||||
}
|
||||
|
||||
func TestCli_Run_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
executed := false
|
||||
c.Command("hello", Command{Action: func(_ Options) Result {
|
||||
executed = true
|
||||
|
|
@ -40,7 +40,7 @@ func TestCli_Run_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCli_Run_Nested_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
executed := false
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
|
||||
executed = true
|
||||
|
|
@ -52,7 +52,7 @@ func TestCli_Run_Nested_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCli_Run_WithFlags_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
var received Options
|
||||
c.Command("serve", Command{Action: func(opts Options) Result {
|
||||
received = opts
|
||||
|
|
@ -64,20 +64,20 @@ func TestCli_Run_WithFlags_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCli_Run_NoCommand_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Cli().Run()
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCli_PrintHelp_Good(t *testing.T) {
|
||||
c := New(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core)
|
||||
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()
|
||||
}
|
||||
|
||||
func TestCli_SetOutput_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
var buf bytes.Buffer
|
||||
c.Cli().SetOutput(&buf)
|
||||
c.Cli().Print("hello %s", "world")
|
||||
|
|
|
|||
129
command.go
129
command.go
|
|
@ -20,37 +20,31 @@
|
|||
// "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CommandAction is the function signature for command handlers.
|
||||
//
|
||||
// func(opts core.Options) core.Result
|
||||
type CommandAction func(Options) Result
|
||||
|
||||
// CommandLifecycle is implemented by commands that support managed lifecycle.
|
||||
// Basic commands only need an action. Daemon commands implement Start/Stop/Signal
|
||||
// via go-process.
|
||||
type CommandLifecycle interface {
|
||||
Start(Options) Result
|
||||
Stop() Result
|
||||
Restart() Result
|
||||
Reload() Result
|
||||
Signal(string) Result
|
||||
}
|
||||
|
||||
// Command is the DTO for an executable operation.
|
||||
// Commands are declarative — they carry enough information for multiple consumers:
|
||||
// - core.Cli() runs the Action
|
||||
// - core/cli adds rich help, completion, man pages
|
||||
// - go-process wraps Managed commands with lifecycle (PID, health, signals)
|
||||
//
|
||||
// c.Command("serve", core.Command{
|
||||
// Action: handler,
|
||||
// Managed: "process.daemon", // go-process provides start/stop/restart
|
||||
// })
|
||||
type Command struct {
|
||||
Name string
|
||||
Description string // i18n key — derived from path if empty
|
||||
Path string // "deploy/to/homelab"
|
||||
Action CommandAction // business logic
|
||||
Lifecycle CommandLifecycle // optional — provided by go-process
|
||||
Flags Options // declared flags
|
||||
Description string // i18n key — derived from path if empty
|
||||
Path string // "deploy/to/homelab"
|
||||
Action CommandAction // business logic
|
||||
Managed string // "" = one-shot, "process.daemon" = managed lifecycle
|
||||
Flags Options // declared flags
|
||||
Hidden bool
|
||||
commands map[string]*Command // child commands (internal)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// I18nKey returns the i18n key for this command's description.
|
||||
|
|
@ -69,7 +63,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}
|
||||
|
|
@ -77,52 +71,19 @@ func (cmd *Command) Run(opts Options) Result {
|
|||
return cmd.Action(opts)
|
||||
}
|
||||
|
||||
// Start delegates to the lifecycle implementation if available.
|
||||
func (cmd *Command) Start(opts Options) Result {
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Lifecycle.Start(opts)
|
||||
}
|
||||
return cmd.Run(opts)
|
||||
}
|
||||
|
||||
// Stop delegates to the lifecycle implementation.
|
||||
func (cmd *Command) Stop() Result {
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Lifecycle.Stop()
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Restart delegates to the lifecycle implementation.
|
||||
func (cmd *Command) Restart() Result {
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Lifecycle.Restart()
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Reload delegates to the lifecycle implementation.
|
||||
func (cmd *Command) Reload() Result {
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Lifecycle.Reload()
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Signal delegates to the lifecycle implementation.
|
||||
func (cmd *Command) Signal(sig string) Result {
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Lifecycle.Signal(sig)
|
||||
}
|
||||
return Result{}
|
||||
// IsManaged returns true if this command has a managed lifecycle.
|
||||
//
|
||||
// if cmd.IsManaged() { /* go-process handles start/stop */ }
|
||||
func (cmd *Command) IsManaged() bool {
|
||||
return cmd.Managed != ""
|
||||
}
|
||||
|
||||
// --- Command Registry (on Core) ---
|
||||
|
||||
// commandRegistry holds the command tree.
|
||||
type commandRegistry struct {
|
||||
commands map[string]*Command
|
||||
mu sync.RWMutex
|
||||
// CommandRegistry holds the command tree. Embeds Registry[*Command]
|
||||
// for thread-safe named storage with insertion order.
|
||||
type CommandRegistry struct {
|
||||
*Registry[*Command]
|
||||
}
|
||||
|
||||
// Command gets or registers a command by path.
|
||||
|
|
@ -131,21 +92,19 @@ type commandRegistry struct {
|
|||
// r := c.Command("deploy")
|
||||
func (c *Core) Command(path string, command ...Command) Result {
|
||||
if len(command) == 0 {
|
||||
c.commands.mu.RLock()
|
||||
cmd, ok := c.commands.commands[path]
|
||||
c.commands.mu.RUnlock()
|
||||
return Result{cmd, ok}
|
||||
return c.commands.Get(path)
|
||||
}
|
||||
|
||||
if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") {
|
||||
return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false}
|
||||
}
|
||||
|
||||
c.commands.mu.Lock()
|
||||
defer c.commands.mu.Unlock()
|
||||
|
||||
if existing, exists := c.commands.commands[path]; exists && (existing.Action != nil || existing.Lifecycle != nil) {
|
||||
return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false}
|
||||
// Check for duplicate executable command
|
||||
if r := c.commands.Get(path); r.OK {
|
||||
existing := r.Value.(*Command)
|
||||
if existing.Action != nil || existing.IsManaged() {
|
||||
return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := &command[0]
|
||||
|
|
@ -156,7 +115,8 @@ func (c *Core) Command(path string, command ...Command) Result {
|
|||
}
|
||||
|
||||
// Preserve existing subtree when overwriting a placeholder parent
|
||||
if existing, exists := c.commands.commands[path]; exists {
|
||||
if r := c.commands.Get(path); r.OK {
|
||||
existing := r.Value.(*Command)
|
||||
for k, v := range existing.commands {
|
||||
if _, has := cmd.commands[k]; !has {
|
||||
cmd.commands[k] = v
|
||||
|
|
@ -164,40 +124,35 @@ func (c *Core) Command(path string, command ...Command) Result {
|
|||
}
|
||||
}
|
||||
|
||||
c.commands.commands[path] = cmd
|
||||
c.commands.Set(path, cmd)
|
||||
|
||||
// Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing
|
||||
parts := Split(path, "/")
|
||||
for i := len(parts) - 1; i > 0; i-- {
|
||||
parentPath := JoinPath(parts[:i]...)
|
||||
if _, exists := c.commands.commands[parentPath]; !exists {
|
||||
c.commands.commands[parentPath] = &Command{
|
||||
if !c.commands.Has(parentPath) {
|
||||
c.commands.Set(parentPath, &Command{
|
||||
Name: parts[i-1],
|
||||
Path: parentPath,
|
||||
commands: make(map[string]*Command),
|
||||
}
|
||||
})
|
||||
}
|
||||
c.commands.commands[parentPath].commands[parts[i]] = cmd
|
||||
cmd = c.commands.commands[parentPath]
|
||||
parent := c.commands.Get(parentPath).Value.(*Command)
|
||||
parent.commands[parts[i]] = cmd
|
||||
cmd = parent
|
||||
}
|
||||
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Commands returns all registered command paths.
|
||||
// Commands returns all registered command paths in registration order.
|
||||
//
|
||||
// paths := c.Commands()
|
||||
func (c *Core) Commands() []string {
|
||||
if c.commands == nil {
|
||||
return nil
|
||||
}
|
||||
c.commands.mu.RLock()
|
||||
defer c.commands.mu.RUnlock()
|
||||
var paths []string
|
||||
for k := range c.commands.commands {
|
||||
paths = append(paths, k)
|
||||
}
|
||||
return paths
|
||||
return c.commands.Names()
|
||||
}
|
||||
|
||||
// pathName extracts the last segment of a path.
|
||||
|
|
|
|||
40
command_example_test.go
Normal file
40
command_example_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleCore_Command_register() {
|
||||
c := New()
|
||||
c.Command("deploy/to/homelab", Command{
|
||||
Description: "Deploy to homelab",
|
||||
Action: func(opts Options) Result {
|
||||
return Result{Value: "deployed", OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
Println(c.Command("deploy/to/homelab").OK)
|
||||
// Output: true
|
||||
}
|
||||
|
||||
func ExampleCore_Command_managed() {
|
||||
c := New()
|
||||
c.Command("serve", Command{
|
||||
Action: func(_ Options) Result { return Result{OK: true} },
|
||||
Managed: "process.daemon",
|
||||
})
|
||||
|
||||
cmd := c.Command("serve").Value.(*Command)
|
||||
Println(cmd.IsManaged())
|
||||
// Output: true
|
||||
}
|
||||
|
||||
func ExampleCore_Commands() {
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("test", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
|
||||
Println(c.Commands())
|
||||
// Output: [deploy test]
|
||||
}
|
||||
132
command_test.go
132
command_test.go
|
|
@ -10,7 +10,7 @@ import (
|
|||
// --- Command DTO ---
|
||||
|
||||
func TestCommand_Register_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Command("deploy", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed", OK: true}
|
||||
}})
|
||||
|
|
@ -18,7 +18,7 @@ func TestCommand_Register_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCommand_Get_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
r := c.Command("deploy")
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -26,34 +26,34 @@ func TestCommand_Get_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCommand_Get_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Command("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCommand_Run_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("greet", Command{Action: func(opts Options) Result {
|
||||
return Result{Value: Concat("hello ", opts.String("name")), OK: true}
|
||||
}})
|
||||
cmd := c.Command("greet").Value.(*Command)
|
||||
r := cmd.Run(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)
|
||||
}
|
||||
|
||||
func TestCommand_Run_NoAction_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
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)
|
||||
}
|
||||
|
||||
// --- Nested Commands ---
|
||||
|
||||
func TestCommand_Nested_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed to homelab", OK: true}
|
||||
}})
|
||||
|
|
@ -67,7 +67,7 @@ func TestCommand_Nested_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCommand_Paths_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
|
|
@ -82,127 +82,77 @@ func TestCommand_Paths_Good(t *testing.T) {
|
|||
// --- I18n Key Derivation ---
|
||||
|
||||
func TestCommand_I18nKey_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("deploy/to/homelab", Command{})
|
||||
cmd := c.Command("deploy/to/homelab").Value.(*Command)
|
||||
assert.Equal(t, "cmd.deploy.to.homelab.description", cmd.I18nKey())
|
||||
}
|
||||
|
||||
func TestCommand_I18nKey_Custom_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("deploy", Command{Description: "custom.deploy.key"})
|
||||
cmd := c.Command("deploy").Value.(*Command)
|
||||
assert.Equal(t, "custom.deploy.key", cmd.I18nKey())
|
||||
}
|
||||
|
||||
func TestCommand_I18nKey_Simple_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("serve", Command{})
|
||||
cmd := c.Command("serve").Value.(*Command)
|
||||
assert.Equal(t, "cmd.serve.description", cmd.I18nKey())
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
// --- Managed ---
|
||||
|
||||
func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Command("serve", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "running", OK: true}
|
||||
}})
|
||||
func TestCommand_IsManaged_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("serve", Command{
|
||||
Action: func(_ Options) Result { return Result{Value: "running", OK: true} },
|
||||
Managed: "process.daemon",
|
||||
})
|
||||
cmd := c.Command("serve").Value.(*Command)
|
||||
|
||||
r := cmd.Start(Options{})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "running", r.Value)
|
||||
|
||||
assert.False(t, cmd.Stop().OK)
|
||||
assert.False(t, cmd.Restart().OK)
|
||||
assert.False(t, cmd.Reload().OK)
|
||||
assert.False(t, cmd.Signal("HUP").OK)
|
||||
assert.True(t, cmd.IsManaged())
|
||||
}
|
||||
|
||||
// --- Lifecycle with Implementation ---
|
||||
|
||||
type testLifecycle struct {
|
||||
started bool
|
||||
stopped bool
|
||||
restarted bool
|
||||
reloaded bool
|
||||
signalled string
|
||||
}
|
||||
|
||||
func (l *testLifecycle) Start(opts Options) Result {
|
||||
l.started = true
|
||||
return Result{Value: "started", OK: true}
|
||||
}
|
||||
func (l *testLifecycle) Stop() Result {
|
||||
l.stopped = true
|
||||
return Result{OK: true}
|
||||
}
|
||||
func (l *testLifecycle) Restart() Result {
|
||||
l.restarted = true
|
||||
return Result{OK: true}
|
||||
}
|
||||
func (l *testLifecycle) Reload() Result {
|
||||
l.reloaded = true
|
||||
return Result{OK: true}
|
||||
}
|
||||
func (l *testLifecycle) Signal(sig string) Result {
|
||||
l.signalled = sig
|
||||
return Result{Value: sig, OK: true}
|
||||
}
|
||||
|
||||
func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
lc := &testLifecycle{}
|
||||
c.Command("daemon", Command{Lifecycle: lc})
|
||||
cmd := c.Command("daemon").Value.(*Command)
|
||||
|
||||
r := cmd.Start(Options{})
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, lc.started)
|
||||
|
||||
assert.True(t, cmd.Stop().OK)
|
||||
assert.True(t, lc.stopped)
|
||||
|
||||
assert.True(t, cmd.Restart().OK)
|
||||
assert.True(t, lc.restarted)
|
||||
|
||||
assert.True(t, cmd.Reload().OK)
|
||||
assert.True(t, lc.reloaded)
|
||||
|
||||
r = cmd.Signal("HUP")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "HUP", lc.signalled)
|
||||
func TestCommand_IsManaged_Bad_NotManaged(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy", Command{
|
||||
Action: func(_ Options) Result { return Result{OK: true} },
|
||||
})
|
||||
cmd := c.Command("deploy").Value.(*Command)
|
||||
assert.False(t, cmd.IsManaged())
|
||||
}
|
||||
|
||||
func TestCommand_Duplicate_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCommand_InvalidPath_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.False(t, c.Command("/leading", Command{}).OK)
|
||||
assert.False(t, c.Command("trailing/", Command{}).OK)
|
||||
assert.False(t, c.Command("double//slash", Command{}).OK)
|
||||
}
|
||||
|
||||
// --- Cli Run with Lifecycle ---
|
||||
// --- Cli Run with Managed ---
|
||||
|
||||
func TestCli_Run_Lifecycle_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
lc := &testLifecycle{}
|
||||
c.Command("serve", Command{Lifecycle: lc})
|
||||
func TestCli_Run_Managed_Good(t *testing.T) {
|
||||
c := New()
|
||||
ran := false
|
||||
c.Command("serve", Command{
|
||||
Action: func(_ Options) Result { ran = true; return Result{OK: true} },
|
||||
Managed: "process.daemon",
|
||||
})
|
||||
r := c.Cli().Run("serve")
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, lc.started)
|
||||
assert.True(t, ran)
|
||||
}
|
||||
|
||||
func TestCli_Run_NoActionNoLifecycle_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestCli_Run_NoAction_Bad(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("empty", Command{})
|
||||
r := c.Cli().Run("empty")
|
||||
assert.False(t, r.OK)
|
||||
|
|
@ -211,7 +161,7 @@ func TestCli_Run_NoActionNoLifecycle_Bad(t *testing.T) {
|
|||
// --- Empty path ---
|
||||
|
||||
func TestCommand_EmptyPath_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Command("", Command{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
|
|
|||
59
config.go
59
config.go
|
|
@ -14,15 +14,34 @@ type ConfigVar[T any] struct {
|
|||
set bool
|
||||
}
|
||||
|
||||
func (v *ConfigVar[T]) Get() T { return v.val }
|
||||
func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true }
|
||||
// Get returns the current value.
|
||||
//
|
||||
// val := v.Get()
|
||||
func (v *ConfigVar[T]) Get() T { return v.val }
|
||||
|
||||
// Set sets the value and marks it as explicitly set.
|
||||
//
|
||||
// v.Set(true)
|
||||
func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true }
|
||||
|
||||
// IsSet returns true if the value was explicitly set (distinguishes "set to false" from "never set").
|
||||
//
|
||||
// if v.IsSet() { /* explicitly configured */ }
|
||||
func (v *ConfigVar[T]) IsSet() bool { return v.set }
|
||||
|
||||
// Unset resets to zero value and marks as not set.
|
||||
//
|
||||
// v.Unset()
|
||||
// v.IsSet() // false
|
||||
func (v *ConfigVar[T]) Unset() {
|
||||
v.set = false
|
||||
var zero T
|
||||
v.val = zero
|
||||
}
|
||||
|
||||
// NewConfigVar creates a ConfigVar with an initial value marked as set.
|
||||
//
|
||||
// debug := core.NewConfigVar(true)
|
||||
func NewConfigVar[T any](val T) ConfigVar[T] {
|
||||
return ConfigVar[T]{val: val, set: true}
|
||||
}
|
||||
|
|
@ -48,6 +67,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()
|
||||
|
|
@ -73,9 +101,20 @@ func (e *Config) Get(key string) Result {
|
|||
return Result{val, true}
|
||||
}
|
||||
|
||||
// String retrieves a string config value (empty string if missing).
|
||||
//
|
||||
// host := c.Config().String("database.host")
|
||||
func (e *Config) String(key string) string { return ConfigGet[string](e, key) }
|
||||
func (e *Config) Int(key string) int { return ConfigGet[int](e, key) }
|
||||
func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) }
|
||||
|
||||
// Int retrieves an int config value (0 if missing).
|
||||
//
|
||||
// port := c.Config().Int("database.port")
|
||||
func (e *Config) Int(key string) int { return ConfigGet[int](e, key) }
|
||||
|
||||
// Bool retrieves a bool config value (false if missing).
|
||||
//
|
||||
// debug := c.Config().Bool("debug")
|
||||
func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) }
|
||||
|
||||
// ConfigGet retrieves a typed configuration value.
|
||||
func ConfigGet[T any](e *Config, key string) T {
|
||||
|
|
@ -90,6 +129,9 @@ func ConfigGet[T any](e *Config, key string) T {
|
|||
|
||||
// --- Feature Flags ---
|
||||
|
||||
// Enable activates a feature flag.
|
||||
//
|
||||
// c.Config().Enable("dark-mode")
|
||||
func (e *Config) Enable(feature string) {
|
||||
e.mu.Lock()
|
||||
if e.ConfigOptions == nil {
|
||||
|
|
@ -100,6 +142,9 @@ func (e *Config) Enable(feature string) {
|
|||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// Disable deactivates a feature flag.
|
||||
//
|
||||
// c.Config().Disable("dark-mode")
|
||||
func (e *Config) Disable(feature string) {
|
||||
e.mu.Lock()
|
||||
if e.ConfigOptions == nil {
|
||||
|
|
@ -110,6 +155,9 @@ func (e *Config) Disable(feature string) {
|
|||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// Enabled returns true if a feature flag is active.
|
||||
//
|
||||
// if c.Config().Enabled("dark-mode") { ... }
|
||||
func (e *Config) Enabled(feature string) bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
|
@ -119,6 +167,9 @@ func (e *Config) Enabled(feature string) bool {
|
|||
return e.Features[feature]
|
||||
}
|
||||
|
||||
// EnabledFeatures returns all active feature flag names.
|
||||
//
|
||||
// features := c.Config().EnabledFeatures()
|
||||
func (e *Config) EnabledFeatures() []string {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
|
|
|||
41
config_example_test.go
Normal file
41
config_example_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleConfig_Set() {
|
||||
c := New()
|
||||
c.Config().Set("database.host", "localhost")
|
||||
c.Config().Set("database.port", 5432)
|
||||
|
||||
Println(c.Config().String("database.host"))
|
||||
Println(c.Config().Int("database.port"))
|
||||
// Output:
|
||||
// localhost
|
||||
// 5432
|
||||
}
|
||||
|
||||
func ExampleConfig_Enable() {
|
||||
c := New()
|
||||
c.Config().Enable("dark-mode")
|
||||
c.Config().Enable("beta-features")
|
||||
|
||||
Println(c.Config().Enabled("dark-mode"))
|
||||
Println(c.Config().EnabledFeatures())
|
||||
// Output:
|
||||
// true
|
||||
// [dark-mode beta-features]
|
||||
}
|
||||
|
||||
func ExampleConfigVar() {
|
||||
v := NewConfigVar(42)
|
||||
Println(v.Get(), v.IsSet())
|
||||
|
||||
v.Unset()
|
||||
Println(v.Get(), v.IsSet())
|
||||
// Output:
|
||||
// 42 true
|
||||
// 0 false
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
// --- Config ---
|
||||
|
||||
func TestConfig_SetGet_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Config().Set("api_url", "https://api.lthn.ai")
|
||||
c.Config().Set("max_agents", 5)
|
||||
|
||||
|
|
@ -20,14 +20,14 @@ func TestConfig_SetGet_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConfig_Get_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Config().Get("missing")
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestConfig_TypedAccessors_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Config().Set("url", "https://lthn.ai")
|
||||
c.Config().Set("port", 8080)
|
||||
c.Config().Set("debug", true)
|
||||
|
|
@ -38,7 +38,7 @@ func TestConfig_TypedAccessors_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConfig_TypedAccessors_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
// Missing keys return zero values
|
||||
assert.Equal(t, "", c.Config().String("missing"))
|
||||
assert.Equal(t, 0, c.Config().Int("missing"))
|
||||
|
|
@ -48,7 +48,7 @@ func TestConfig_TypedAccessors_Bad(t *testing.T) {
|
|||
// --- Feature Flags ---
|
||||
|
||||
func TestConfig_Features_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Config().Enable("dark-mode")
|
||||
c.Config().Enable("beta")
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ func TestConfig_Features_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConfig_Features_Disable_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Config().Enable("feature")
|
||||
assert.True(t, c.Config().Enabled("feature"))
|
||||
|
||||
|
|
@ -67,14 +67,14 @@ func TestConfig_Features_Disable_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConfig_Features_CaseSensitive(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Config().Enable("Feature")
|
||||
assert.True(t, c.Config().Enabled("Feature"))
|
||||
assert.False(t, c.Config().Enabled("feature"))
|
||||
}
|
||||
|
||||
func TestConfig_EnabledFeatures_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Config().Enable("a")
|
||||
c.Config().Enable("b")
|
||||
c.Config().Enable("c")
|
||||
|
|
@ -88,7 +88,7 @@ func TestConfig_EnabledFeatures_Good(t *testing.T) {
|
|||
|
||||
// --- ConfigVar ---
|
||||
|
||||
func TestConfigVar_Good(t *testing.T) {
|
||||
func TestConfig_ConfigVar_Good(t *testing.T) {
|
||||
v := NewConfigVar("hello")
|
||||
assert.True(t, v.IsSet())
|
||||
assert.Equal(t, "hello", v.Get())
|
||||
|
|
|
|||
157
contract.go
157
contract.go
|
|
@ -6,6 +6,8 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Message is the type for IPC broadcasts (fire-and-forget).
|
||||
|
|
@ -14,30 +16,25 @@ type Message any
|
|||
// Query is the type for read-only IPC requests.
|
||||
type Query any
|
||||
|
||||
// Task is the type for IPC requests that perform side effects.
|
||||
type Task any
|
||||
|
||||
// TaskWithIdentifier is an optional interface for tasks that need to know their assigned identifier.
|
||||
type TaskWithIdentifier interface {
|
||||
Task
|
||||
SetTaskIdentifier(id string)
|
||||
GetTaskIdentifier() string
|
||||
}
|
||||
|
||||
// QueryHandler handles Query requests. Returns Result{Value, OK}.
|
||||
type QueryHandler func(*Core, Query) Result
|
||||
|
||||
// TaskHandler handles Task requests. Returns Result{Value, OK}.
|
||||
type TaskHandler func(*Core, Task) Result
|
||||
|
||||
// Startable is implemented by services that need startup initialisation.
|
||||
//
|
||||
// func (s *MyService) OnStartup(ctx context.Context) core.Result {
|
||||
// return core.Result{OK: true}
|
||||
// }
|
||||
type Startable interface {
|
||||
OnStartup(ctx context.Context) error
|
||||
OnStartup(ctx context.Context) Result
|
||||
}
|
||||
|
||||
// Stoppable is implemented by services that need shutdown cleanup.
|
||||
//
|
||||
// func (s *MyService) OnShutdown(ctx context.Context) core.Result {
|
||||
// return core.Result{OK: true}
|
||||
// }
|
||||
type Stoppable interface {
|
||||
OnShutdown(ctx context.Context) error
|
||||
OnShutdown(ctx context.Context) Result
|
||||
}
|
||||
|
||||
// --- Action Messages ---
|
||||
|
|
@ -47,21 +44,21 @@ type ActionServiceShutdown struct{}
|
|||
|
||||
type ActionTaskStarted struct {
|
||||
TaskIdentifier string
|
||||
Task Task
|
||||
Action string
|
||||
Options Options
|
||||
}
|
||||
|
||||
type ActionTaskProgress struct {
|
||||
TaskIdentifier string
|
||||
Task Task
|
||||
Action string
|
||||
Progress float64
|
||||
Message string
|
||||
}
|
||||
|
||||
type ActionTaskCompleted struct {
|
||||
TaskIdentifier string
|
||||
Task Task
|
||||
Result any
|
||||
Error error
|
||||
Action string
|
||||
Result Result
|
||||
}
|
||||
|
||||
// --- Constructor ---
|
||||
|
|
@ -80,44 +77,52 @@ type CoreOption func(*Core) Result
|
|||
// Services registered here form the application conclave — they share
|
||||
// IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown).
|
||||
//
|
||||
// r := core.New(
|
||||
// core.WithOptions(core.Options{{Key: "name", Value: "myapp"}}),
|
||||
// c := core.New(
|
||||
// core.WithOption("name", "myapp"),
|
||||
// core.WithService(auth.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
// if !r.OK { log.Fatal(r.Value) }
|
||||
// c := r.Value.(*Core)
|
||||
func New(opts ...CoreOption) Result {
|
||||
// c.Run()
|
||||
func New(opts ...CoreOption) *Core {
|
||||
c := &Core{
|
||||
app: &App{},
|
||||
data: &Data{},
|
||||
drive: &Drive{},
|
||||
fs: &Fs{root: "/"},
|
||||
config: &Config{ConfigOptions: &ConfigOptions{}},
|
||||
data: &Data{Registry: NewRegistry[*Embed]()},
|
||||
drive: &Drive{Registry: NewRegistry[*DriveHandle]()},
|
||||
fs: (&Fs{}).New("/"),
|
||||
config: (&Config{}).New(),
|
||||
error: &ErrorPanic{},
|
||||
log: &ErrorLog{log: Default()},
|
||||
lock: &Lock{},
|
||||
ipc: &Ipc{},
|
||||
log: &ErrorLog{},
|
||||
lock: &Lock{locks: NewRegistry[*sync.RWMutex]()},
|
||||
ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*Task]()},
|
||||
info: systemInfo,
|
||||
i18n: &I18n{},
|
||||
services: &serviceRegistry{services: make(map[string]*Service)},
|
||||
commands: &commandRegistry{commands: make(map[string]*Command)},
|
||||
api: &API{protocols: NewRegistry[StreamFactory]()},
|
||||
services: &ServiceRegistry{Registry: NewRegistry[*Service]()},
|
||||
commands: &CommandRegistry{Registry: NewRegistry[*Command]()},
|
||||
entitlementChecker: defaultChecker,
|
||||
}
|
||||
c.context, c.cancel = context.WithCancel(context.Background())
|
||||
c.cli = &Cli{core: c}
|
||||
c.api.core = c
|
||||
|
||||
// Core services
|
||||
CliRegister(c)
|
||||
|
||||
for _, opt := range opts {
|
||||
if r := opt(c); !r.OK {
|
||||
return r
|
||||
Error("core.New failed", "err", r.Value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return Result{c, true}
|
||||
// Apply service lock after all opts — v0.3.3 parity
|
||||
c.LockApply()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// WithOptions applies key-value configuration to Core.
|
||||
//
|
||||
// core.WithOptions(core.Options{{Key: "name", Value: "myapp"}})
|
||||
// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"}))
|
||||
func WithOptions(opts Options) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
c.options = &opts
|
||||
|
|
@ -129,16 +134,81 @@ func WithOptions(opts Options) CoreOption {
|
|||
}
|
||||
|
||||
// WithService registers a service via its factory function.
|
||||
// The factory receives *Core so the service can wire IPC handlers
|
||||
// and access other subsystems during construction.
|
||||
// Service name is auto-discovered from the package path.
|
||||
// If the service implements HandleIPCEvents, it is auto-registered.
|
||||
// If the factory returns a non-nil Value, WithService auto-discovers the
|
||||
// service name from the factory's package path (last path segment, lowercase,
|
||||
// with any "_test" suffix stripped) and calls RegisterService on the instance.
|
||||
// IPC handler auto-registration is handled by RegisterService.
|
||||
//
|
||||
// If the factory returns nil Value (it registered itself), WithService
|
||||
// returns success without a second registration.
|
||||
//
|
||||
// core.WithService(agentic.Register)
|
||||
// core.WithService(display.Register(nil))
|
||||
func WithService(factory func(*Core) Result) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
return factory(c)
|
||||
r := factory(c)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
if r.Value == nil {
|
||||
// Factory self-registered — nothing more to do.
|
||||
return Result{OK: true}
|
||||
}
|
||||
// Auto-discover the service name from the instance's package path.
|
||||
instance := r.Value
|
||||
typeOf := reflect.TypeOf(instance)
|
||||
if typeOf.Kind() == reflect.Ptr {
|
||||
typeOf = typeOf.Elem()
|
||||
}
|
||||
pkgPath := typeOf.PkgPath()
|
||||
parts := Split(pkgPath, "/")
|
||||
name := Lower(parts[len(parts)-1])
|
||||
if name == "" {
|
||||
return Result{E("core.WithService", Sprintf("service name could not be discovered for type %T", instance), nil), false}
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +221,6 @@ func WithService(factory func(*Core) Result) CoreOption {
|
|||
func WithServiceLock() CoreOption {
|
||||
return func(c *Core) Result {
|
||||
c.LockEnable()
|
||||
c.LockApply()
|
||||
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 TestContract_WithService_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 TestContract_WithService_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 TestContract_WithName_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) Result {
|
||||
s.started = true
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
func TestContract_WithService_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 TestContract_WithService_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 TestContract_WithService_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")
|
||||
}
|
||||
214
core.go
214
core.go
|
|
@ -7,6 +7,7 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
|
@ -15,22 +16,26 @@ 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
|
||||
commands *commandRegistry // c.Command("path") — Command tree
|
||||
services *serviceRegistry // c.Service("name") — Service registry
|
||||
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
|
||||
ipc *Ipc // c.IPC() — Message bus for IPC
|
||||
api *API // c.API() — Remote streams
|
||||
info *SysInfo // c.Env("key") — Read-only system/environment information
|
||||
i18n *I18n // c.I18n() — Internationalisation and locale collection
|
||||
|
||||
entitlementChecker EntitlementChecker // default: everything permitted
|
||||
usageRecorder UsageRecorder // default: nil (no-op)
|
||||
|
||||
context context.Context
|
||||
cancel context.CancelFunc
|
||||
taskIDCounter atomic.Uint64
|
||||
|
|
@ -40,28 +45,146 @@ type Core struct {
|
|||
|
||||
// --- Accessors ---
|
||||
|
||||
func (c *Core) Options() *Options { return c.options }
|
||||
func (c *Core) App() *App { return c.app }
|
||||
func (c *Core) Data() *Data { return c.data }
|
||||
func (c *Core) Drive() *Drive { return c.drive }
|
||||
func (c *Core) Embed() Result { return c.data.Get("app") } // legacy — use Data()
|
||||
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) IPC() *Ipc { return c.ipc }
|
||||
func (c *Core) I18n() *I18n { return c.i18n }
|
||||
func (c *Core) Env(key string) string { return Env(key) }
|
||||
// Options returns the input configuration passed to core.New().
|
||||
//
|
||||
// opts := c.Options()
|
||||
// name := opts.String("name")
|
||||
func (c *Core) Options() *Options { return c.options }
|
||||
|
||||
// App returns application identity metadata.
|
||||
//
|
||||
// c.App().Name // "my-app"
|
||||
// c.App().Version // "1.0.0"
|
||||
func (c *Core) App() *App { return c.app }
|
||||
|
||||
// Data returns the embedded asset registry (Registry[*Embed]).
|
||||
//
|
||||
// r := c.Data().ReadString("prompts/coding.md")
|
||||
func (c *Core) Data() *Data { return c.data }
|
||||
|
||||
// Drive returns the transport handle registry (Registry[*DriveHandle]).
|
||||
//
|
||||
// r := c.Drive().Get("forge")
|
||||
func (c *Core) Drive() *Drive { return c.drive }
|
||||
|
||||
// Fs returns the sandboxed filesystem.
|
||||
//
|
||||
// r := c.Fs().Read("/path/to/file")
|
||||
// c.Fs().WriteAtomic("/status.json", data)
|
||||
func (c *Core) Fs() *Fs { return c.fs }
|
||||
|
||||
// Config returns runtime settings and feature flags.
|
||||
//
|
||||
// host := c.Config().String("database.host")
|
||||
// c.Config().Enable("dark-mode")
|
||||
func (c *Core) Config() *Config { return c.config }
|
||||
|
||||
// Error returns the panic recovery subsystem.
|
||||
//
|
||||
// c.Error().Recover()
|
||||
func (c *Core) Error() *ErrorPanic { return c.error }
|
||||
|
||||
// Log returns the structured logging subsystem.
|
||||
//
|
||||
// c.Log().Info("started", "port", 8080)
|
||||
func (c *Core) Log() *ErrorLog { return c.log }
|
||||
|
||||
// Cli returns the CLI command framework (registered as service "cli").
|
||||
//
|
||||
// c.Cli().Run("deploy", "to", "homelab")
|
||||
func (c *Core) Cli() *Cli {
|
||||
cl, _ := ServiceFor[*Cli](c, "cli")
|
||||
return cl
|
||||
}
|
||||
|
||||
// IPC returns the message bus internals.
|
||||
//
|
||||
// c.IPC()
|
||||
func (c *Core) IPC() *Ipc { return c.ipc }
|
||||
|
||||
// I18n returns the internationalisation subsystem.
|
||||
//
|
||||
// tr := c.I18n().Translate("cmd.deploy.description")
|
||||
func (c *Core) I18n() *I18n { return c.i18n }
|
||||
|
||||
// Env returns an environment variable by key (cached at init, falls back to os.Getenv).
|
||||
//
|
||||
// home := c.Env("DIR_HOME")
|
||||
// token := c.Env("FORGE_TOKEN")
|
||||
func (c *Core) Env(key string) string { return Env(key) }
|
||||
|
||||
// Context returns Core's lifecycle context (cancelled on shutdown).
|
||||
//
|
||||
// ctx := c.Context()
|
||||
func (c *Core) Context() context.Context { return c.context }
|
||||
func (c *Core) Core() *Core { return c }
|
||||
|
||||
// Core returns self — satisfies the ServiceRuntime interface.
|
||||
//
|
||||
// c := s.Core()
|
||||
func (c *Core) Core() *Core { return c }
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
// RunE starts all services, runs the CLI, then shuts down.
|
||||
// Returns an error instead of calling os.Exit — let main() handle the exit.
|
||||
// ServiceShutdown is always called via defer, even on startup failure or panic.
|
||||
//
|
||||
// if err := c.RunE(); err != nil {
|
||||
// os.Exit(1)
|
||||
// }
|
||||
func (c *Core) RunE() error {
|
||||
defer c.ServiceShutdown(context.Background())
|
||||
|
||||
r := c.ServiceStartup(c.context, nil)
|
||||
if !r.OK {
|
||||
if err, ok := r.Value.(error); ok {
|
||||
return err
|
||||
}
|
||||
return E("core.Run", "startup failed", nil)
|
||||
}
|
||||
|
||||
if cli := c.Cli(); cli != nil {
|
||||
r = cli.Run()
|
||||
}
|
||||
|
||||
if !r.OK {
|
||||
if err, ok := r.Value.(error); ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts all services, runs the CLI, then shuts down.
|
||||
// Calls os.Exit(1) on failure. For error handling use RunE().
|
||||
//
|
||||
// c := core.New(core.WithService(myService.Register))
|
||||
// c.Run()
|
||||
func (c *Core) Run() {
|
||||
if err := c.RunE(); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- IPC (uppercase aliases) ---
|
||||
|
||||
func (c *Core) ACTION(msg Message) Result { return c.Action(msg) }
|
||||
func (c *Core) QUERY(q Query) Result { return c.Query(q) }
|
||||
func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) }
|
||||
func (c *Core) PERFORM(t Task) Result { return c.Perform(t) }
|
||||
// ACTION broadcasts a message to all registered handlers (fire-and-forget).
|
||||
// Each handler is wrapped in panic recovery. All handlers fire regardless.
|
||||
//
|
||||
// c.ACTION(messages.AgentCompleted{Agent: "codex", Status: "completed"})
|
||||
func (c *Core) ACTION(msg Message) Result { return c.broadcast(msg) }
|
||||
|
||||
// QUERY sends a request — first handler to return OK wins.
|
||||
//
|
||||
// r := c.QUERY(MyQuery{Name: "brain"})
|
||||
func (c *Core) QUERY(q Query) Result { return c.Query(q) }
|
||||
|
||||
// QUERYALL sends a request — collects all OK responses.
|
||||
//
|
||||
// r := c.QUERYALL(countQuery{})
|
||||
// results := r.Value.([]any)
|
||||
func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) }
|
||||
|
||||
// --- Error+Log ---
|
||||
|
||||
|
|
@ -80,4 +203,37 @@ func (c *Core) Must(err error, op, msg string) {
|
|||
c.log.Must(err, op, msg)
|
||||
}
|
||||
|
||||
// --- Registry Accessor ---
|
||||
|
||||
// RegistryOf returns a named registry for cross-cutting queries.
|
||||
// Known registries: "services", "commands", "actions".
|
||||
//
|
||||
// c.RegistryOf("services").Names() // all service names
|
||||
// c.RegistryOf("actions").List("process.*") // process capabilities
|
||||
// c.RegistryOf("commands").Len() // command count
|
||||
func (c *Core) RegistryOf(name string) *Registry[any] {
|
||||
// Bridge typed registries to untyped access for cross-cutting queries.
|
||||
// Each registry is wrapped in a read-only proxy.
|
||||
switch name {
|
||||
case "services":
|
||||
return registryProxy(c.services.Registry)
|
||||
case "commands":
|
||||
return registryProxy(c.commands.Registry)
|
||||
case "actions":
|
||||
return registryProxy(c.ipc.actions)
|
||||
default:
|
||||
return NewRegistry[any]() // empty registry for unknown names
|
||||
}
|
||||
}
|
||||
|
||||
// registryProxy creates a read-only any-typed view of a typed registry.
|
||||
// Copies current state — not a live view (avoids type parameter leaking).
|
||||
func registryProxy[T any](src *Registry[T]) *Registry[any] {
|
||||
proxy := NewRegistry[any]()
|
||||
src.Each(func(name string, item T) {
|
||||
proxy.Set(name, item)
|
||||
})
|
||||
return proxy
|
||||
}
|
||||
|
||||
// --- Global Instance ---
|
||||
|
|
|
|||
168
core_test.go
168
core_test.go
|
|
@ -10,27 +10,27 @@ import (
|
|||
|
||||
// --- New ---
|
||||
|
||||
func TestNew_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestCore_New_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestNew_WithOptions_Good(t *testing.T) {
|
||||
c := New(WithOptions(Options{{Key: "name", Value: "myapp"}})).Value.(*Core)
|
||||
func TestCore_New_WithOptions_Good(t *testing.T) {
|
||||
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) {
|
||||
func TestCore_New_WithOptions_Bad(t *testing.T) {
|
||||
// Empty options — should still create a valid Core
|
||||
c := New(WithOptions(Options{})).Value.(*Core)
|
||||
c := New(WithOptions(NewOptions()))
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestNew_WithService_Good(t *testing.T) {
|
||||
func TestCore_New_WithService_Good(t *testing.T) {
|
||||
started := false
|
||||
r := New(
|
||||
WithOptions(Options{{Key: "name", Value: "myapp"}}),
|
||||
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} },
|
||||
|
|
@ -38,8 +38,6 @@ func TestNew_WithService_Good(t *testing.T) {
|
|||
return Result{OK: true}
|
||||
}),
|
||||
)
|
||||
assert.True(t, r.OK)
|
||||
c := r.Value.(*Core)
|
||||
|
||||
svc := c.Service("test")
|
||||
assert.True(t, svc.OK)
|
||||
|
|
@ -48,26 +46,38 @@ func TestNew_WithService_Good(t *testing.T) {
|
|||
assert.True(t, started)
|
||||
}
|
||||
|
||||
func TestNew_WithServiceLock_Good(t *testing.T) {
|
||||
r := New(
|
||||
func TestCore_New_WithServiceLock_Good(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
c.Service("allowed", Service{})
|
||||
return Result{OK: true}
|
||||
}),
|
||||
WithServiceLock(),
|
||||
)
|
||||
assert.True(t, r.OK)
|
||||
c := r.Value.(*Core)
|
||||
|
||||
// Registration after lock should fail
|
||||
reg := c.Service("blocked", Service{})
|
||||
assert.False(t, reg.OK)
|
||||
}
|
||||
|
||||
func TestCore_New_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) {
|
||||
c := New().Value.(*Core)
|
||||
func TestCore_Accessors_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.App())
|
||||
assert.NotNil(t, c.Data())
|
||||
assert.NotNil(t, c.Drive())
|
||||
|
|
@ -82,11 +92,11 @@ func TestAccessors_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestOptions_Accessor_Good(t *testing.T) {
|
||||
c := New(WithOptions(Options{
|
||||
{Key: "name", Value: "testapp"},
|
||||
{Key: "port", Value: 8080},
|
||||
{Key: "debug", Value: true},
|
||||
})).Value.(*Core)
|
||||
c := New(WithOptions(NewOptions(
|
||||
Option{Key: "name", Value: "testapp"},
|
||||
Option{Key: "port", Value: 8080},
|
||||
Option{Key: "debug", Value: true},
|
||||
)))
|
||||
opts := c.Options()
|
||||
assert.NotNil(t, opts)
|
||||
assert.Equal(t, "testapp", opts.String("name"))
|
||||
|
|
@ -95,7 +105,7 @@ func TestOptions_Accessor_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestOptions_Accessor_Nil(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
// No options passed — Options() returns nil
|
||||
assert.Nil(t, c.Options())
|
||||
}
|
||||
|
|
@ -103,33 +113,133 @@ func TestOptions_Accessor_Nil(t *testing.T) {
|
|||
// --- Core Error/Log Helpers ---
|
||||
|
||||
func TestCore_LogError_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
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)
|
||||
}
|
||||
|
||||
func TestCore_LogWarn_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.LogWarn(assert.AnError, "test.Operation", "heads up")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
_, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestCore_Must_Ugly(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.Panics(t, func() {
|
||||
c.Must(assert.AnError, "test.Operation", "fatal")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCore_Must_Nil_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.NotPanics(t, func() {
|
||||
c.Must(nil, "test.Operation", "no error")
|
||||
})
|
||||
}
|
||||
|
||||
// --- RegistryOf ---
|
||||
|
||||
func TestCore_RegistryOf_Good_Services(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("alpha", Service{})
|
||||
}),
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("bravo", Service{})
|
||||
}),
|
||||
)
|
||||
reg := c.RegistryOf("services")
|
||||
// cli is auto-registered + our 2
|
||||
assert.True(t, reg.Has("alpha"))
|
||||
assert.True(t, reg.Has("bravo"))
|
||||
assert.True(t, reg.Has("cli"))
|
||||
}
|
||||
|
||||
func TestCore_RegistryOf_Good_Commands(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("test", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
|
||||
reg := c.RegistryOf("commands")
|
||||
assert.True(t, reg.Has("deploy"))
|
||||
assert.True(t, reg.Has("test"))
|
||||
}
|
||||
|
||||
func TestCore_RegistryOf_Good_Actions(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
c.Action("brain.recall", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
|
||||
reg := c.RegistryOf("actions")
|
||||
assert.True(t, reg.Has("process.run"))
|
||||
assert.True(t, reg.Has("brain.recall"))
|
||||
assert.Equal(t, 2, reg.Len())
|
||||
}
|
||||
|
||||
func TestCore_RegistryOf_Bad_Unknown(t *testing.T) {
|
||||
c := New()
|
||||
reg := c.RegistryOf("nonexistent")
|
||||
assert.Equal(t, 0, reg.Len(), "unknown registry returns empty")
|
||||
}
|
||||
|
||||
// --- RunE ---
|
||||
|
||||
func TestCore_RunE_Good(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("healthy", Service{
|
||||
OnStart: func() Result { return Result{OK: true} },
|
||||
OnStop: func() Result { return Result{OK: true} },
|
||||
})
|
||||
}),
|
||||
)
|
||||
err := c.RunE()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCore_RunE_Bad_StartupFailure(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("broken", Service{
|
||||
OnStart: func() Result {
|
||||
return Result{Value: NewError("startup failed"), OK: false}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
err := c.RunE()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "startup failed")
|
||||
}
|
||||
|
||||
func TestCore_RunE_Ugly_StartupFailureCallsShutdown(t *testing.T) {
|
||||
shutdownCalled := false
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("cleanup", Service{
|
||||
OnStart: func() Result { return Result{OK: true} },
|
||||
OnStop: func() Result { shutdownCalled = true; return Result{OK: true} },
|
||||
})
|
||||
}),
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("broken", Service{
|
||||
OnStart: func() Result {
|
||||
return Result{Value: NewError("boom"), OK: false}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
err := c.RunE()
|
||||
assert.Error(t, err)
|
||||
assert.True(t, shutdownCalled, "ServiceShutdown must be called even when startup fails — cleanup service must get OnStop")
|
||||
}
|
||||
|
||||
// Run() delegates to RunE() — tested via RunE tests above.
|
||||
// os.Exit behaviour is verified by RunE returning error correctly.
|
||||
|
|
|
|||
70
data.go
70
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:
|
||||
//
|
||||
|
|
@ -25,22 +25,21 @@ package core
|
|||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Data manages mounted embedded filesystems from core packages.
|
||||
// Embeds Registry[*Embed] for thread-safe named storage.
|
||||
type Data struct {
|
||||
mounts map[string]*Embed
|
||||
mu sync.RWMutex
|
||||
*Registry[*Embed]
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
|
|
@ -62,54 +61,27 @@ func (d *Data) New(opts Options) Result {
|
|||
path = "."
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.mounts == nil {
|
||||
d.mounts = make(map[string]*Embed)
|
||||
}
|
||||
|
||||
mr := Mount(fsys, path)
|
||||
if !mr.OK {
|
||||
return mr
|
||||
}
|
||||
|
||||
emb := mr.Value.(*Embed)
|
||||
d.mounts[name] = emb
|
||||
return Result{emb, true}
|
||||
}
|
||||
|
||||
// Get returns the Embed for a named mount point.
|
||||
//
|
||||
// r := c.Data().Get("brain")
|
||||
// if r.OK { emb := r.Value.(*Embed) }
|
||||
func (d *Data) Get(name string) Result {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if d.mounts == nil {
|
||||
return Result{}
|
||||
}
|
||||
emb, ok := d.mounts[name]
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
d.Set(name, emb)
|
||||
return Result{emb, true}
|
||||
}
|
||||
|
||||
// resolve splits a path like "brain/coding.md" into mount name + relative path.
|
||||
func (d *Data) resolve(path string) (*Embed, string) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
parts := SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil, ""
|
||||
}
|
||||
if d.mounts == nil {
|
||||
r := d.Get(parts[0])
|
||||
if !r.OK {
|
||||
return nil, ""
|
||||
}
|
||||
emb := d.mounts[parts[0]]
|
||||
return emb, parts[1]
|
||||
return r.Value.(*Embed), parts[1]
|
||||
}
|
||||
|
||||
// ReadFile reads a file by full path.
|
||||
|
|
@ -188,15 +160,9 @@ func (d *Data) Extract(path, targetDir string, templateData any) Result {
|
|||
return Extract(r.Value.(*Embed).FS(), targetDir, templateData)
|
||||
}
|
||||
|
||||
// Mounts returns the names of all mounted content.
|
||||
// Mounts returns the names of all mounted content in registration order.
|
||||
//
|
||||
// names := c.Data().Mounts()
|
||||
func (d *Data) Mounts() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
var names []string
|
||||
for k := range d.mounts {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
return d.Names()
|
||||
}
|
||||
|
|
|
|||
89
data_test.go
89
data_test.go
|
|
@ -2,7 +2,6 @@ package core_test
|
|||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -14,117 +13,121 @@ var testFS embed.FS
|
|||
|
||||
// --- Data (Embedded Content Mounts) ---
|
||||
|
||||
func mountTestData(t *testing.T, c *Core, name string) {
|
||||
t.Helper()
|
||||
|
||||
r := c.Data().New(NewOptions(
|
||||
Option{Key: "name", Value: name},
|
||||
Option{Key: "source", Value: testFS},
|
||||
Option{Key: "path", Value: "testdata"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_New_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "test"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
c := New()
|
||||
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)
|
||||
}
|
||||
|
||||
func TestData_New_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
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().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
c := New()
|
||||
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))
|
||||
}
|
||||
|
||||
func TestData_ReadString_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Data().ReadString("nonexistent/file.txt")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ReadFile_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
c := New()
|
||||
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)))
|
||||
}
|
||||
|
||||
func TestData_Get_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "brain"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
c := New()
|
||||
mountTestData(t, c, "brain")
|
||||
gr := c.Data().Get("brain")
|
||||
assert.True(t, gr.OK)
|
||||
emb := gr.Value.(*Embed)
|
||||
|
||||
r := emb.Open("test.txt")
|
||||
assert.True(t, r.OK)
|
||||
file := r.Value.(io.ReadCloser)
|
||||
defer file.Close()
|
||||
content, _ := io.ReadAll(file)
|
||||
assert.Equal(t, "hello from testdata\n", string(content))
|
||||
cr := ReadAll(r.Value)
|
||||
assert.True(t, cr.OK)
|
||||
assert.Equal(t, "hello from testdata\n", cr.Value)
|
||||
}
|
||||
|
||||
func TestData_Get_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Data().Get("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_Mounts_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "a"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
c.Data().New(Options{{Key: "name", Value: "b"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
c := New()
|
||||
mountTestData(t, c, "a")
|
||||
mountTestData(t, c, "b")
|
||||
mounts := c.Data().Mounts()
|
||||
assert.Len(t, mounts, 2)
|
||||
}
|
||||
|
||||
func TestEmbed_Legacy_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
assert.NotNil(t, c.Embed())
|
||||
}
|
||||
|
||||
func TestData_List_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().List("app/testdata")
|
||||
c := New()
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().List("app/.")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_List_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Data().List("nonexistent/path")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ListNames_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().ListNames("app/testdata")
|
||||
c := New()
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().ListNames("app/.")
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.([]string), "test")
|
||||
}
|
||||
|
||||
func TestData_Extract_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().Extract("app/testdata", t.TempDir(), nil)
|
||||
c := New()
|
||||
mountTestData(t, c, "app")
|
||||
r := c.Data().Extract("app/.", t.TempDir(), nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_Extract_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Data().Extract("nonexistent/path", t.TempDir(), nil)
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
|
|
|||
2082
docs/RFC.md
Normal file
2082
docs/RFC.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -69,20 +69,15 @@ c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
|||
return core.Result{}
|
||||
})
|
||||
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
switch task := t.(type) {
|
||||
case createWorkspaceTask:
|
||||
path := "/tmp/agent-workbench/" + task.Name
|
||||
return core.Result{Value: path, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
c.Action("workspace.create", func(_ context.Context, opts core.Options) core.Result {
|
||||
name := opts.String("name")
|
||||
path := "/tmp/agent-workbench/" + name
|
||||
return core.Result{Value: path, OK: true}
|
||||
})
|
||||
|
||||
c.Command("workspace/create", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return c.PERFORM(createWorkspaceTask{
|
||||
Name: opts.String("name"),
|
||||
})
|
||||
return c.Action("workspace.create").Run(context.Background(), opts)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
|
@ -170,20 +165,15 @@ func main() {
|
|||
return core.Result{}
|
||||
})
|
||||
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
switch task := t.(type) {
|
||||
case createWorkspaceTask:
|
||||
path := c.Config().String("workspace.root") + "/" + task.Name
|
||||
return core.Result{Value: path, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
c.Action("workspace.create", func(_ context.Context, opts core.Options) core.Result {
|
||||
name := opts.String("name")
|
||||
path := c.Config().String("workspace.root") + "/" + name
|
||||
return core.Result{Value: path, OK: true}
|
||||
})
|
||||
|
||||
c.Command("workspace/create", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return c.PERFORM(createWorkspaceTask{
|
||||
Name: opts.String("name"),
|
||||
})
|
||||
return c.Action("workspace.create").Run(context.Background(), opts)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
126
docs/index.md
126
docs/index.md
|
|
@ -5,108 +5,56 @@ description: AX-first documentation for the CoreGO framework.
|
|||
|
||||
# CoreGO
|
||||
|
||||
CoreGO is the foundation layer for the Core ecosystem. It gives you one container, one command tree, one message bus, and a small set of shared primitives that repeat across the whole framework.
|
||||
CoreGO is the foundation layer for the Core ecosystem. Module: `dappco.re/go/core`.
|
||||
|
||||
The current module path is `dappco.re/go/core`.
|
||||
## What CoreGO Provides
|
||||
|
||||
## AX View
|
||||
|
||||
CoreGO already follows the main AX ideas from RFC-025:
|
||||
|
||||
- predictable names such as `Core`, `Service`, `Command`, `Options`, `Result`, `Message`
|
||||
- path-shaped command registration such as `deploy/to/homelab`
|
||||
- one repeated input shape (`Options`) and one repeated return shape (`Result`)
|
||||
- comments and examples that show real usage instead of restating the type signature
|
||||
|
||||
## What CoreGO Owns
|
||||
|
||||
| Surface | Purpose |
|
||||
|---------|---------|
|
||||
| `Core` | Central container and access point |
|
||||
| `Service` | Managed lifecycle component |
|
||||
| `Command` | Path-based command tree node |
|
||||
| `ACTION`, `QUERY`, `PERFORM` | Decoupled communication between components |
|
||||
| `Data`, `Drive`, `Fs`, `Config`, `I18n`, `Cli` | Built-in subsystems for common runtime work |
|
||||
| `E`, `Wrap`, `ErrorLog`, `ErrorPanic` | Structured failures and panic recovery |
|
||||
| Primitive | Purpose |
|
||||
|-----------|---------|
|
||||
| `Core` | Central container — everything registers here |
|
||||
| `Service` | Lifecycle-managed component (Startable/Stoppable return Result) |
|
||||
| `Action` | Named callable with panic recovery + entitlement |
|
||||
| `Task` | Composed sequence of Actions |
|
||||
| `Registry[T]` | Thread-safe named collection (universal brick) |
|
||||
| `Command` | Path-based CLI command tree |
|
||||
| `Process` | Managed execution (Action sugar over go-process) |
|
||||
| `API` | Remote streams (protocol handlers + Drive) |
|
||||
| `Entitlement` | Permission gate (default permissive, consumer replaces) |
|
||||
| `ACTION`, `QUERY` | Anonymous broadcast + request/response |
|
||||
| `Data`, `Drive`, `Fs`, `Config`, `I18n` | Built-in subsystems |
|
||||
|
||||
## Quick Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
type flushCacheTask struct {
|
||||
Name string
|
||||
}
|
||||
import "dappco.re/go/core"
|
||||
|
||||
func main() {
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
|
||||
c.Service("cache", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("cache ready", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("cache stopped", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
c.RegisterTask(func(_ *core.Core, task core.Task) core.Result {
|
||||
switch task.(type) {
|
||||
case flushCacheTask:
|
||||
return core.Result{Value: "cache flushed", OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
c.Command("cache/flush", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return c.PERFORM(flushCacheTask{Name: opts.String("name")})
|
||||
},
|
||||
})
|
||||
|
||||
if !c.ServiceStartup(context.Background(), nil).OK {
|
||||
panic("startup failed")
|
||||
}
|
||||
|
||||
r := c.Cli().Run("cache", "flush", "--name=session-store")
|
||||
fmt.Println(r.Value)
|
||||
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
c := core.New(
|
||||
core.WithOption("name", "agent-workbench"),
|
||||
core.WithService(cache.Register),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.Run()
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation Paths
|
||||
## API Specification
|
||||
|
||||
The full contract is `docs/RFC.md` (21 sections, 1476 lines). An agent should be able to write a service from RFC.md alone.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Path | Covers |
|
||||
|------|--------|
|
||||
| [getting-started.md](getting-started.md) | First runnable CoreGO app |
|
||||
| [primitives.md](primitives.md) | `Options`, `Result`, `Service`, `Message`, `Query`, `Task` |
|
||||
| [services.md](services.md) | Service registry, service locks, runtime helpers |
|
||||
| [commands.md](commands.md) | Path-based commands and CLI execution |
|
||||
| [messaging.md](messaging.md) | `ACTION`, `QUERY`, `QUERYALL`, `PERFORM`, `PerformAsync` |
|
||||
| [lifecycle.md](lifecycle.md) | Startup, shutdown, context, background task draining |
|
||||
| [configuration.md](configuration.md) | Constructor options, config state, feature flags |
|
||||
| [subsystems.md](subsystems.md) | `App`, `Data`, `Drive`, `Fs`, `I18n`, `Cli` |
|
||||
| [errors.md](errors.md) | Structured errors, logging helpers, panic recovery |
|
||||
| [testing.md](testing.md) | Test naming and framework-level testing patterns |
|
||||
| [pkg/core.md](pkg/core.md) | Package-level reference summary |
|
||||
| [pkg/log.md](pkg/log.md) | Logging reference for the root package |
|
||||
| [pkg/PACKAGE_STANDARDS.md](pkg/PACKAGE_STANDARDS.md) | AX package-authoring guidance |
|
||||
|
||||
## Good Reading Order
|
||||
|
||||
1. Start with [getting-started.md](getting-started.md).
|
||||
2. Learn the repeated shapes in [primitives.md](primitives.md).
|
||||
3. Pick the integration path you need next: [services.md](services.md), [commands.md](commands.md), or [messaging.md](messaging.md).
|
||||
4. Use [subsystems.md](subsystems.md), [errors.md](errors.md), and [testing.md](testing.md) as reference pages while building.
|
||||
| [RFC.md](RFC.md) | Authoritative API contract (21 sections) |
|
||||
| [primitives.md](primitives.md) | Option, Result, Action, Task, Registry, Entitlement |
|
||||
| [services.md](services.md) | Service registry, ServiceRuntime, service locks |
|
||||
| [commands.md](commands.md) | Path-based commands, Managed field |
|
||||
| [messaging.md](messaging.md) | ACTION, QUERY, named Actions, PerformAsync |
|
||||
| [lifecycle.md](lifecycle.md) | RunE, ServiceStartup, ServiceShutdown |
|
||||
| [subsystems.md](subsystems.md) | App, Data, Drive, Fs, Config, I18n |
|
||||
| [errors.md](errors.md) | core.E(), structured errors, panic recovery |
|
||||
| [testing.md](testing.md) | AX-7 TestFile_Function_{Good,Bad,Ugly} |
|
||||
| [configuration.md](configuration.md) | WithOption, WithService, WithServiceLock |
|
||||
|
|
|
|||
|
|
@ -1,171 +1,127 @@
|
|||
---
|
||||
title: Messaging
|
||||
description: ACTION, QUERY, QUERYALL, PERFORM, and async task flow.
|
||||
description: ACTION, QUERY, QUERYALL, named Actions, and async dispatch.
|
||||
---
|
||||
|
||||
# Messaging
|
||||
|
||||
CoreGO uses one message bus for broadcasts, lookups, and work dispatch.
|
||||
CoreGO has two messaging layers: anonymous broadcast (ACTION/QUERY) and named Actions.
|
||||
|
||||
## Message Types
|
||||
## Anonymous Broadcast
|
||||
|
||||
```go
|
||||
type Message any
|
||||
type Query any
|
||||
type Task any
|
||||
```
|
||||
### `ACTION`
|
||||
|
||||
Your own structs define the protocol.
|
||||
|
||||
```go
|
||||
type repositoryIndexed struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type repositoryCountQuery struct{}
|
||||
|
||||
type syncRepositoryTask struct {
|
||||
Name string
|
||||
}
|
||||
```
|
||||
|
||||
## `ACTION`
|
||||
|
||||
`ACTION` is a broadcast.
|
||||
Fire-and-forget broadcast to all registered handlers. Each handler is wrapped in panic recovery. Handler return values are ignored — all handlers fire regardless.
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
switch m := msg.(type) {
|
||||
case repositoryIndexed:
|
||||
core.Info("repository indexed", "name", m.Name)
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
if ev, ok := msg.(repositoryIndexed); ok {
|
||||
core.Info("indexed", "name", ev.Name)
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
|
||||
r := c.ACTION(repositoryIndexed{Name: "core-go"})
|
||||
c.ACTION(repositoryIndexed{Name: "core-go"})
|
||||
```
|
||||
|
||||
### Behavior
|
||||
### `QUERY`
|
||||
|
||||
- all registered action handlers are called in their current registration order
|
||||
- if a handler returns `OK:false`, dispatch stops and that `Result` is returned
|
||||
- if no handler fails, `ACTION` returns `Result{OK:true}`
|
||||
|
||||
## `QUERY`
|
||||
|
||||
`QUERY` is first-match request-response.
|
||||
First handler to return `OK:true` wins.
|
||||
|
||||
```go
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
switch q.(type) {
|
||||
case repositoryCountQuery:
|
||||
return core.Result{Value: 42, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
if _, ok := q.(repositoryCountQuery); ok {
|
||||
return core.Result{Value: 42, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
r := c.QUERY(repositoryCountQuery{})
|
||||
```
|
||||
|
||||
### Behavior
|
||||
### `QUERYALL`
|
||||
|
||||
- handlers run until one returns `OK:true`
|
||||
- the first successful result wins
|
||||
- if nothing handles the query, CoreGO returns an empty `Result`
|
||||
|
||||
## `QUERYALL`
|
||||
|
||||
`QUERYALL` collects every successful non-nil response.
|
||||
Collects every successful non-nil response.
|
||||
|
||||
```go
|
||||
r := c.QUERYALL(repositoryCountQuery{})
|
||||
results := r.Value.([]any)
|
||||
```
|
||||
|
||||
### Behavior
|
||||
## Named Actions
|
||||
|
||||
- every query handler is called
|
||||
- only `OK:true` results with non-nil `Value` are collected
|
||||
- the call itself returns `OK:true` even when the result list is empty
|
||||
Named Actions are the typed, inspectable replacement for anonymous dispatch. See Section 18 of `RFC.md`.
|
||||
|
||||
## `PERFORM`
|
||||
|
||||
`PERFORM` dispatches a task to the first handler that accepts it.
|
||||
### Register and Invoke
|
||||
|
||||
```go
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
switch task := t.(type) {
|
||||
case syncRepositoryTask:
|
||||
return core.Result{Value: "synced " + task.Name, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
// Register during OnStartup
|
||||
c.Action("repo.sync", func(ctx context.Context, opts core.Options) core.Result {
|
||||
name := opts.String("name")
|
||||
return core.Result{Value: "synced " + name, OK: true}
|
||||
})
|
||||
|
||||
r := c.PERFORM(syncRepositoryTask{Name: "core-go"})
|
||||
// Invoke by name
|
||||
r := c.Action("repo.sync").Run(ctx, core.NewOptions(
|
||||
core.Option{Key: "name", Value: "core-go"},
|
||||
))
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- handlers run until one returns `OK:true`
|
||||
- the first successful result wins
|
||||
- if nothing handles the task, CoreGO returns an empty `Result`
|
||||
|
||||
## `PerformAsync`
|
||||
|
||||
`PerformAsync` runs a task in a background goroutine and returns a generated task identifier.
|
||||
### Capability Check
|
||||
|
||||
```go
|
||||
r := c.PerformAsync(syncRepositoryTask{Name: "core-go"})
|
||||
taskID := r.Value.(string)
|
||||
if c.Action("process.run").Exists() {
|
||||
// go-process is registered
|
||||
}
|
||||
|
||||
c.Actions() // []string of all registered action names
|
||||
```
|
||||
|
||||
### Generated Events
|
||||
### Permission Gate
|
||||
|
||||
Async execution emits three action messages:
|
||||
Every `Action.Run()` checks `c.Entitled(action.Name)` before executing. See Section 21 of `RFC.md`.
|
||||
|
||||
| Message | When |
|
||||
|---------|------|
|
||||
| `ActionTaskStarted` | just before background execution begins |
|
||||
| `ActionTaskProgress` | whenever `Progress` is called |
|
||||
| `ActionTaskCompleted` | after the task finishes or panics |
|
||||
## Task Composition
|
||||
|
||||
Example listener:
|
||||
A Task is a named sequence of Actions:
|
||||
|
||||
```go
|
||||
c.Task("deploy", core.Task{
|
||||
Steps: []core.Step{
|
||||
{Action: "go.build"},
|
||||
{Action: "go.test"},
|
||||
{Action: "docker.push"},
|
||||
{Action: "notify.slack", Async: true},
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Task("deploy").Run(ctx, c, opts)
|
||||
```
|
||||
|
||||
Sequential steps stop on first failure. `Async: true` steps fire without blocking. `Input: "previous"` pipes output.
|
||||
|
||||
## Background Execution
|
||||
|
||||
```go
|
||||
r := c.PerformAsync("repo.sync", opts)
|
||||
taskID := r.Value.(string)
|
||||
|
||||
c.Progress(taskID, 0.5, "indexing commits", "repo.sync")
|
||||
```
|
||||
|
||||
Broadcasts `ActionTaskStarted`, `ActionTaskProgress`, `ActionTaskCompleted` as ACTION messages.
|
||||
|
||||
### Completion Listener
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
switch m := msg.(type) {
|
||||
case core.ActionTaskCompleted:
|
||||
core.Info("task completed", "task", m.TaskIdentifier, "err", m.Error)
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
if ev, ok := msg.(core.ActionTaskCompleted); ok {
|
||||
core.Info("done", "task", ev.TaskIdentifier, "ok", ev.Result.OK)
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
```
|
||||
|
||||
## Progress Updates
|
||||
## Shutdown
|
||||
|
||||
```go
|
||||
c.Progress(taskID, 0.5, "indexing commits", syncRepositoryTask{Name: "core-go"})
|
||||
```
|
||||
|
||||
That broadcasts `ActionTaskProgress`.
|
||||
|
||||
## `TaskWithIdentifier`
|
||||
|
||||
Tasks that implement `TaskWithIdentifier` receive the generated ID before dispatch.
|
||||
|
||||
```go
|
||||
type trackedTask struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (t *trackedTask) SetTaskIdentifier(id string) { t.ID = id }
|
||||
func (t *trackedTask) GetTaskIdentifier() string { return t.ID }
|
||||
```
|
||||
|
||||
## Shutdown Interaction
|
||||
|
||||
When shutdown has started, `PerformAsync` returns an empty `Result` instead of scheduling more work.
|
||||
|
||||
This is why `ServiceShutdown` can safely drain the outstanding background tasks before stopping services.
|
||||
When shutdown has started, `PerformAsync` returns an empty `Result`. `ServiceShutdown` drains outstanding background work before stopping services.
|
||||
|
|
|
|||
|
|
@ -5,165 +5,172 @@ description: The repeated shapes that make CoreGO easy to navigate.
|
|||
|
||||
# Core Primitives
|
||||
|
||||
CoreGO is easiest to use when you read it as a small vocabulary repeated everywhere. Most of the framework is built from the same handful of types.
|
||||
CoreGO is built from a small vocabulary repeated everywhere.
|
||||
|
||||
## Primitive Map
|
||||
|
||||
| Type | Used For |
|
||||
|------|----------|
|
||||
| `Options` | Input values and lightweight metadata |
|
||||
| `Option` / `Options` | Input values and metadata |
|
||||
| `Result` | Output values and success state |
|
||||
| `Service` | Lifecycle-managed components |
|
||||
| `Message` | Broadcast events |
|
||||
| `Query` | Request-response lookups |
|
||||
| `Task` | Side-effecting work items |
|
||||
| `Action` | Named callable with panic recovery + entitlement |
|
||||
| `Task` | Composed sequence of Actions |
|
||||
| `Registry[T]` | Thread-safe named collection |
|
||||
| `Entitlement` | Permission check result |
|
||||
| `Message` | Broadcast events (ACTION) |
|
||||
| `Query` | Request-response lookups (QUERY) |
|
||||
|
||||
## `Option` and `Options`
|
||||
|
||||
`Option` is one key-value pair. `Options` is an ordered slice of them.
|
||||
|
||||
```go
|
||||
opts := core.Options{
|
||||
{Key: "name", Value: "brain"},
|
||||
{Key: "path", Value: "prompts"},
|
||||
{Key: "debug", Value: true},
|
||||
}
|
||||
```
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "name", Value: "brain"},
|
||||
core.Option{Key: "path", Value: "prompts"},
|
||||
core.Option{Key: "debug", Value: true},
|
||||
)
|
||||
|
||||
Use the helpers to read values:
|
||||
|
||||
```go
|
||||
name := opts.String("name")
|
||||
path := opts.String("path")
|
||||
debug := opts.Bool("debug")
|
||||
hasPath := opts.Has("path")
|
||||
raw := opts.Get("name")
|
||||
raw := opts.Get("name") // Result{Value, OK}
|
||||
opts.Has("path") // true
|
||||
opts.Len() // 3
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- `Get` returns the first matching key.
|
||||
- `String`, `Int`, and `Bool` do not convert between types.
|
||||
- Missing keys return zero values.
|
||||
- CLI flags with values are stored as strings, so `--port=8080` should be read with `opts.String("port")`, not `opts.Int("port")`.
|
||||
|
||||
## `Result`
|
||||
|
||||
`Result` is the universal return shape.
|
||||
Universal return shape. Every Core operation returns Result.
|
||||
|
||||
```go
|
||||
r := core.Result{Value: "ready", OK: true}
|
||||
type Result struct {
|
||||
Value any
|
||||
OK bool
|
||||
}
|
||||
|
||||
r := c.Config().Get("host")
|
||||
if r.OK {
|
||||
fmt.Println(r.Value)
|
||||
host := r.Value.(string)
|
||||
}
|
||||
```
|
||||
|
||||
It has two jobs:
|
||||
|
||||
- carry a value when work succeeds
|
||||
- carry either an error or an empty state when work does not succeed
|
||||
|
||||
### `Result.Result(...)`
|
||||
|
||||
The `Result()` method adapts plain Go values and `(value, error)` pairs into a `core.Result`.
|
||||
The `Result()` method adapts Go `(value, error)` pairs:
|
||||
|
||||
```go
|
||||
r1 := core.Result{}.Result("hello")
|
||||
r2 := core.Result{}.Result(file, err)
|
||||
r := core.Result{}.Result(file, err)
|
||||
```
|
||||
|
||||
This is how several built-in helpers bridge standard-library calls.
|
||||
|
||||
## `Service`
|
||||
|
||||
`Service` is the managed lifecycle DTO stored in the registry.
|
||||
Managed lifecycle component stored in the `ServiceRegistry`.
|
||||
|
||||
```go
|
||||
svc := core.Service{
|
||||
Name: "cache",
|
||||
Options: core.Options{
|
||||
{Key: "backend", Value: "memory"},
|
||||
},
|
||||
OnStart: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnReload: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
core.Service{
|
||||
OnStart: func() core.Result { return core.Result{OK: true} },
|
||||
OnStop: func() core.Result { return core.Result{OK: true} },
|
||||
}
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- `OnStart` and `OnStop` are used by the framework lifecycle.
|
||||
- `OnReload` is stored on the service DTO, but CoreGO does not currently call it automatically.
|
||||
- The registry stores `*core.Service`, not arbitrary typed service instances.
|
||||
|
||||
## `Message`, `Query`, and `Task`
|
||||
|
||||
These are simple aliases to `any`.
|
||||
Or via `Startable`/`Stoppable` interfaces (preferred for named services):
|
||||
|
||||
```go
|
||||
type Message any
|
||||
type Query any
|
||||
type Task any
|
||||
type Startable interface { OnStartup(ctx context.Context) Result }
|
||||
type Stoppable interface { OnShutdown(ctx context.Context) Result }
|
||||
```
|
||||
|
||||
That means your own structs become the protocol:
|
||||
## `Action`
|
||||
|
||||
Named callable — the atomic unit of work. Registered by name, invoked by name.
|
||||
|
||||
```go
|
||||
type deployStarted struct {
|
||||
Environment string
|
||||
}
|
||||
type ActionHandler func(context.Context, Options) Result
|
||||
|
||||
type workspaceCountQuery struct{}
|
||||
|
||||
type syncRepositoryTask struct {
|
||||
Name string
|
||||
type Action struct {
|
||||
Name string
|
||||
Handler ActionHandler
|
||||
Description string
|
||||
Schema Options
|
||||
}
|
||||
```
|
||||
|
||||
## `TaskWithIdentifier`
|
||||
`Action.Run()` includes panic recovery and entitlement checking.
|
||||
|
||||
Long-running tasks can opt into task identifiers.
|
||||
## `Task`
|
||||
|
||||
Composed sequence of Actions:
|
||||
|
||||
```go
|
||||
type indexedTask struct {
|
||||
ID string
|
||||
type Task struct {
|
||||
Name string
|
||||
Description string
|
||||
Steps []Step
|
||||
}
|
||||
|
||||
func (t *indexedTask) SetTaskIdentifier(id string) { t.ID = id }
|
||||
func (t *indexedTask) GetTaskIdentifier() string { return t.ID }
|
||||
type Step struct {
|
||||
Action string
|
||||
With Options
|
||||
Async bool
|
||||
Input string // "previous" = output of last step
|
||||
}
|
||||
```
|
||||
|
||||
If a task implements `TaskWithIdentifier`, `PerformAsync` injects the generated `task-N` identifier before dispatch.
|
||||
## `Registry[T]`
|
||||
|
||||
Thread-safe named collection with insertion order and 3 lock modes:
|
||||
|
||||
```go
|
||||
r := core.NewRegistry[*MyService]()
|
||||
r.Set("brain", svc)
|
||||
r.Get("brain") // Result
|
||||
r.Has("brain") // bool
|
||||
r.Names() // []string (insertion order)
|
||||
r.Each(func(name string, svc *MyService) { ... })
|
||||
r.Lock() // fully frozen
|
||||
r.Seal() // no new keys, updates OK
|
||||
```
|
||||
|
||||
## `Entitlement`
|
||||
|
||||
Permission check result:
|
||||
|
||||
```go
|
||||
type Entitlement struct {
|
||||
Allowed bool
|
||||
Unlimited bool
|
||||
Limit int
|
||||
Used int
|
||||
Remaining int
|
||||
Reason string
|
||||
}
|
||||
|
||||
e := c.Entitled("social.accounts", 3)
|
||||
e.NearLimit(0.8) // true if > 80% used
|
||||
e.UsagePercent() // 75.0
|
||||
```
|
||||
|
||||
## `Message` and `Query`
|
||||
|
||||
IPC type aliases for the anonymous broadcast system:
|
||||
|
||||
```go
|
||||
type Message any // broadcast via ACTION
|
||||
type Query any // request/response via QUERY
|
||||
```
|
||||
|
||||
For typed, named dispatch use `c.Action("name").Run(ctx, opts)`.
|
||||
|
||||
## `ServiceRuntime[T]`
|
||||
|
||||
`ServiceRuntime[T]` is the small helper for packages that want to keep a Core reference and a typed options struct together.
|
||||
Composition helper for services that need Core access and typed options:
|
||||
|
||||
```go
|
||||
type agentServiceOptions struct {
|
||||
WorkspacePath string
|
||||
type MyService struct {
|
||||
*core.ServiceRuntime[MyOptions]
|
||||
}
|
||||
|
||||
type agentService struct {
|
||||
*core.ServiceRuntime[agentServiceOptions]
|
||||
}
|
||||
|
||||
runtime := core.NewServiceRuntime(c, agentServiceOptions{
|
||||
WorkspacePath: "/srv/agent-workspaces",
|
||||
})
|
||||
runtime := core.NewServiceRuntime(c, MyOptions{BufferSize: 1024})
|
||||
runtime.Core() // *Core
|
||||
runtime.Options() // MyOptions
|
||||
runtime.Config() // shortcut to Core().Config()
|
||||
```
|
||||
|
||||
It exposes:
|
||||
|
||||
- `Core()`
|
||||
- `Options()`
|
||||
- `Config()`
|
||||
|
||||
This helper does not register anything by itself. It is a composition aid for package authors.
|
||||
|
|
|
|||
|
|
@ -78,14 +78,12 @@ assert.Equal(t, "pong", c.QUERY("ping").Value)
|
|||
```
|
||||
|
||||
```go
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
if t == "compute" {
|
||||
return core.Result{Value: 42, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
c.Action("compute", func(_ context.Context, _ core.Options) core.Result {
|
||||
return core.Result{Value: 42, OK: true}
|
||||
})
|
||||
|
||||
assert.Equal(t, 42, c.PERFORM("compute").Value)
|
||||
r := c.Action("compute").Run(context.Background(), core.NewOptions())
|
||||
assert.Equal(t, 42, r.Value)
|
||||
```
|
||||
|
||||
## Test Async Work
|
||||
|
|
|
|||
95
drive.go
95
drive.go
|
|
@ -6,28 +6,24 @@
|
|||
//
|
||||
// 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:
|
||||
//
|
||||
// api := c.Drive().Get("api")
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DriveHandle holds a named transport resource.
|
||||
type DriveHandle struct {
|
||||
Name string
|
||||
|
|
@ -35,78 +31,29 @@ type DriveHandle struct {
|
|||
Options Options
|
||||
}
|
||||
|
||||
// Drive manages named transport handles.
|
||||
// Drive manages named transport handles. Embeds Registry[*DriveHandle].
|
||||
type Drive struct {
|
||||
handles map[string]*DriveHandle
|
||||
mu sync.RWMutex
|
||||
*Registry[*DriveHandle]
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
transport := opts.String("transport")
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.handles == nil {
|
||||
d.handles = make(map[string]*DriveHandle)
|
||||
}
|
||||
|
||||
cp := make(Options, len(opts))
|
||||
copy(cp, opts)
|
||||
handle := &DriveHandle{
|
||||
Name: name,
|
||||
Transport: transport,
|
||||
Options: cp,
|
||||
Transport: opts.String("transport"),
|
||||
Options: opts,
|
||||
}
|
||||
|
||||
d.handles[name] = handle
|
||||
d.Set(name, handle)
|
||||
return Result{handle, true}
|
||||
}
|
||||
|
||||
// Get returns a handle by name.
|
||||
//
|
||||
// r := c.Drive().Get("api")
|
||||
// if r.OK { handle := r.Value.(*DriveHandle) }
|
||||
func (d *Drive) Get(name string) Result {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if d.handles == nil {
|
||||
return Result{}
|
||||
}
|
||||
h, ok := d.handles[name]
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
return Result{h, true}
|
||||
}
|
||||
|
||||
// Has returns true if a handle is registered.
|
||||
//
|
||||
// if c.Drive().Has("ssh") { ... }
|
||||
func (d *Drive) Has(name string) bool {
|
||||
return d.Get(name).OK
|
||||
}
|
||||
|
||||
// Names returns all registered handle names.
|
||||
//
|
||||
// names := c.Drive().Names()
|
||||
func (d *Drive) Names() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
var names []string
|
||||
for k := range d.handles {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
|
|
|||
35
drive_example_test.go
Normal file
35
drive_example_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleDrive_New() {
|
||||
c := New()
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "forge"},
|
||||
Option{Key: "transport", Value: "https://forge.lthn.ai"},
|
||||
))
|
||||
|
||||
Println(c.Drive().Has("forge"))
|
||||
Println(c.Drive().Names())
|
||||
// Output:
|
||||
// true
|
||||
// [forge]
|
||||
}
|
||||
|
||||
func ExampleDrive_Get() {
|
||||
c := New()
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "charon"},
|
||||
Option{Key: "transport", Value: "http://10.69.69.165:9101"},
|
||||
))
|
||||
|
||||
r := c.Drive().Get("charon")
|
||||
if r.OK {
|
||||
h := r.Value.(*DriveHandle)
|
||||
Println(h.Transport)
|
||||
}
|
||||
// Output: http://10.69.69.165:9101
|
||||
}
|
||||
|
|
@ -10,31 +10,31 @@ import (
|
|||
// --- Drive (Transport Handles) ---
|
||||
|
||||
func TestDrive_New_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
r := c.Drive().New(Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
})
|
||||
c := New()
|
||||
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)
|
||||
}
|
||||
|
||||
func TestDrive_New_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
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().Value.(*Core)
|
||||
c.Drive().New(Options{
|
||||
{Key: "name", Value: "ssh"},
|
||||
{Key: "transport", Value: "ssh://claude@10.69.69.165"},
|
||||
})
|
||||
c := New()
|
||||
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)
|
||||
|
|
@ -42,23 +42,23 @@ func TestDrive_Get_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDrive_Get_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Drive().Get("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestDrive_Has_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Drive().New(Options{{Key: "name", Value: "mcp"}, {Key: "transport", Value: "mcp://mcp.lthn.sh"}})
|
||||
c := New()
|
||||
c.Drive().New(NewOptions(Option{Key: "name", Value: "mcp"}, Option{Key: "transport", Value: "mcp://mcp.lthn.sh"}))
|
||||
assert.True(t, c.Drive().Has("mcp"))
|
||||
assert.False(t, c.Drive().Has("missing"))
|
||||
}
|
||||
|
||||
func TestDrive_Names_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Drive().New(Options{{Key: "name", Value: "api"}, {Key: "transport", Value: "https://api.lthn.ai"}})
|
||||
c.Drive().New(Options{{Key: "name", Value: "ssh"}, {Key: "transport", Value: "ssh://claude@10.69.69.165"}})
|
||||
c.Drive().New(Options{{Key: "name", Value: "mcp"}, {Key: "transport", Value: "mcp://mcp.lthn.sh"}})
|
||||
c := New()
|
||||
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")
|
||||
|
|
@ -67,12 +67,12 @@ func TestDrive_Names_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDrive_OptionsPreserved_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Drive().New(Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
{Key: "timeout", Value: 30},
|
||||
})
|
||||
c := New()
|
||||
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.
|
||||
|
|
|
|||
119
embed_test.go
119
embed_test.go
|
|
@ -4,7 +4,6 @@ import (
|
|||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -13,12 +12,20 @@ import (
|
|||
|
||||
// --- Mount ---
|
||||
|
||||
func TestMount_Good(t *testing.T) {
|
||||
func mustMountTestFS(t *testing.T, basedir string) *Embed {
|
||||
t.Helper()
|
||||
|
||||
r := Mount(testFS, basedir)
|
||||
assert.True(t, r.OK)
|
||||
return r.Value.(*Embed)
|
||||
}
|
||||
|
||||
func TestEmbed_Mount_Good(t *testing.T) {
|
||||
r := Mount(testFS, "testdata")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestMount_Bad(t *testing.T) {
|
||||
func TestEmbed_Mount_Bad(t *testing.T) {
|
||||
r := Mount(testFS, "nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
|
@ -26,34 +33,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 +69,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)
|
||||
|
|
@ -80,45 +87,45 @@ func TestEmbed_EmbedFS_Good(t *testing.T) {
|
|||
|
||||
// --- Extract ---
|
||||
|
||||
func TestExtract_Good(t *testing.T) {
|
||||
func TestEmbed_Extract_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := Extract(testFS, dir, nil)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
content, err := os.ReadFile(dir + "/testdata/test.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello from testdata\n", string(content))
|
||||
cr := (&Fs{}).New("/").Read(Path(dir, "testdata/test.txt"))
|
||||
assert.True(t, cr.OK)
|
||||
assert.Equal(t, "hello from testdata\n", cr.Value)
|
||||
}
|
||||
|
||||
// --- Asset Pack ---
|
||||
|
||||
func TestAddGetAsset_Good(t *testing.T) {
|
||||
func TestEmbed_AddGetAsset_Good(t *testing.T) {
|
||||
AddAsset("test-group", "greeting", mustCompress("hello world"))
|
||||
r := GetAsset("test-group", "greeting")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello world", r.Value.(string))
|
||||
}
|
||||
|
||||
func TestGetAsset_Bad(t *testing.T) {
|
||||
func TestEmbed_GetAsset_Bad(t *testing.T) {
|
||||
r := GetAsset("missing-group", "missing")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestGetAssetBytes_Good(t *testing.T) {
|
||||
func TestEmbed_GetAssetBytes_Good(t *testing.T) {
|
||||
AddAsset("bytes-group", "file", mustCompress("binary content"))
|
||||
r := GetAssetBytes("bytes-group", "file")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, []byte("binary content"), r.Value.([]byte))
|
||||
}
|
||||
|
||||
func TestMountEmbed_Good(t *testing.T) {
|
||||
func TestEmbed_MountEmbed_Good(t *testing.T) {
|
||||
r := MountEmbed(testFS, "testdata")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
// --- ScanAssets ---
|
||||
|
||||
func TestScanAssets_Good(t *testing.T) {
|
||||
func TestEmbed_ScanAssets_Good(t *testing.T) {
|
||||
r := ScanAssets([]string{"testdata/scantest/sample.go"})
|
||||
assert.True(t, r.OK)
|
||||
pkgs := r.Value.([]ScannedPackage)
|
||||
|
|
@ -126,27 +133,27 @@ func TestScanAssets_Good(t *testing.T) {
|
|||
assert.Equal(t, "scantest", pkgs[0].PackageName)
|
||||
}
|
||||
|
||||
func TestScanAssets_Bad(t *testing.T) {
|
||||
func TestEmbed_ScanAssets_Bad(t *testing.T) {
|
||||
r := ScanAssets([]string{"nonexistent.go"})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestGeneratePack_Empty_Good(t *testing.T) {
|
||||
func TestEmbed_GeneratePack_Empty_Good(t *testing.T) {
|
||||
pkg := ScannedPackage{PackageName: "empty"}
|
||||
r := GeneratePack(pkg)
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "package empty")
|
||||
}
|
||||
|
||||
func TestGeneratePack_WithFiles_Good(t *testing.T) {
|
||||
func TestEmbed_GeneratePack_WithFiles_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assetDir := dir + "/mygroup"
|
||||
os.MkdirAll(assetDir, 0755)
|
||||
os.WriteFile(assetDir+"/hello.txt", []byte("hello world"), 0644)
|
||||
assetDir := Path(dir, "mygroup")
|
||||
(&Fs{}).New("/").EnsureDir(assetDir)
|
||||
(&Fs{}).New("/").Write(Path(assetDir, "hello.txt"), "hello world")
|
||||
|
||||
source := "package test\nimport \"dappco.re/go/core\"\nfunc example() {\n\t_, _ = core.GetAsset(\"mygroup\", \"hello.txt\")\n}\n"
|
||||
goFile := dir + "/test.go"
|
||||
os.WriteFile(goFile, []byte(source), 0644)
|
||||
goFile := Path(dir, "test.go")
|
||||
(&Fs{}).New("/").Write(goFile, source)
|
||||
|
||||
sr := ScanAssets([]string{goFile})
|
||||
assert.True(t, sr.OK)
|
||||
|
|
@ -159,58 +166,60 @@ func TestGeneratePack_WithFiles_Good(t *testing.T) {
|
|||
|
||||
// --- Extract (template + nested) ---
|
||||
|
||||
func TestExtract_WithTemplate_Good(t *testing.T) {
|
||||
func TestEmbed_Extract_WithTemplate_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create an in-memory FS with a template file and a plain file
|
||||
tmplDir := os.DirFS(t.TempDir())
|
||||
tmplDir := DirFS(t.TempDir())
|
||||
|
||||
// Use a real temp dir with files
|
||||
srcDir := t.TempDir()
|
||||
os.WriteFile(srcDir+"/plain.txt", []byte("static content"), 0644)
|
||||
os.WriteFile(srcDir+"/greeting.tmpl", []byte("Hello {{.Name}}!"), 0644)
|
||||
os.MkdirAll(srcDir+"/sub", 0755)
|
||||
os.WriteFile(srcDir+"/sub/nested.txt", []byte("nested"), 0644)
|
||||
(&Fs{}).New("/").Write(Path(srcDir, "plain.txt"), "static content")
|
||||
(&Fs{}).New("/").Write(Path(srcDir, "greeting.tmpl"), "Hello {{.Name}}!")
|
||||
(&Fs{}).New("/").EnsureDir(Path(srcDir, "sub"))
|
||||
(&Fs{}).New("/").Write(Path(srcDir, "sub/nested.txt"), "nested")
|
||||
|
||||
_ = tmplDir
|
||||
fsys := os.DirFS(srcDir)
|
||||
fsys := DirFS(srcDir)
|
||||
data := map[string]string{"Name": "World"}
|
||||
|
||||
r := Extract(fsys, dir, data)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
f := (&Fs{}).New("/")
|
||||
|
||||
// Plain file copied
|
||||
content, err := os.ReadFile(dir + "/plain.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "static content", string(content))
|
||||
cr := f.Read(Path(dir, "plain.txt"))
|
||||
assert.True(t, cr.OK)
|
||||
assert.Equal(t, "static content", cr.Value)
|
||||
|
||||
// Template processed and .tmpl stripped
|
||||
greeting, err := os.ReadFile(dir + "/greeting")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hello World!", string(greeting))
|
||||
gr := f.Read(Path(dir, "greeting"))
|
||||
assert.True(t, gr.OK)
|
||||
assert.Equal(t, "Hello World!", gr.Value)
|
||||
|
||||
// Nested directory preserved
|
||||
nested, err := os.ReadFile(dir + "/sub/nested.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "nested", string(nested))
|
||||
nr := f.Read(Path(dir, "sub/nested.txt"))
|
||||
assert.True(t, nr.OK)
|
||||
assert.Equal(t, "nested", nr.Value)
|
||||
}
|
||||
|
||||
func TestExtract_BadTargetDir_Ugly(t *testing.T) {
|
||||
func TestEmbed_Extract_BadTargetDir_Ugly(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
os.WriteFile(srcDir+"/f.txt", []byte("x"), 0644)
|
||||
r := Extract(os.DirFS(srcDir), "/nonexistent/deeply/nested/impossible", nil)
|
||||
(&Fs{}).New("/").Write(Path(srcDir, "f.txt"), "x")
|
||||
r := Extract(DirFS(srcDir), "/nonexistent/deeply/nested/impossible", nil)
|
||||
// Should fail gracefully, not panic
|
||||
_ = r
|
||||
}
|
||||
|
||||
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,30 +227,30 @@ 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)
|
||||
}
|
||||
|
||||
func TestExtract_NilData_Good(t *testing.T) {
|
||||
func TestEmbed_Extract_NilData_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
srcDir := t.TempDir()
|
||||
os.WriteFile(srcDir+"/file.txt", []byte("no template"), 0644)
|
||||
(&Fs{}).New("/").Write(Path(srcDir, "file.txt"), "no template")
|
||||
|
||||
r := Extract(os.DirFS(srcDir), dir, nil)
|
||||
r := Extract(DirFS(srcDir), dir, nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
|
|
|
|||
130
entitlement.go
Normal file
130
entitlement.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Permission primitive for the Core framework.
|
||||
// Entitlement answers "can [subject] do [action] with [quantity]?"
|
||||
// Default: everything permitted (trusted conclave).
|
||||
// With go-entitlements: checks workspace packages, features, usage, boosts.
|
||||
// With commerce-matrix: checks entity hierarchy, lock cascade.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// e := c.Entitled("process.run") // boolean gate
|
||||
// e := c.Entitled("social.accounts", 3) // quantity check
|
||||
// if e.Allowed { proceed() }
|
||||
// if e.NearLimit(0.8) { showUpgradePrompt() }
|
||||
//
|
||||
// Registration:
|
||||
//
|
||||
// c.SetEntitlementChecker(myChecker)
|
||||
// c.SetUsageRecorder(myRecorder)
|
||||
package core
|
||||
|
||||
import "context"
|
||||
|
||||
// Entitlement is the result of a permission check.
|
||||
// Carries context for both boolean gates (Allowed) and usage limits (Limit/Used/Remaining).
|
||||
//
|
||||
// e := c.Entitled("social.accounts", 3)
|
||||
// e.Allowed // true
|
||||
// e.Limit // 5
|
||||
// e.Used // 2
|
||||
// e.Remaining // 3
|
||||
// e.NearLimit(0.8) // false
|
||||
type Entitlement struct {
|
||||
Allowed bool // permission granted
|
||||
Unlimited bool // no cap (agency tier, admin, trusted conclave)
|
||||
Limit int // total allowed (0 = boolean gate)
|
||||
Used int // current consumption
|
||||
Remaining int // Limit - Used
|
||||
Reason string // denial reason — for UI and audit logging
|
||||
}
|
||||
|
||||
// NearLimit returns true if usage exceeds the threshold percentage.
|
||||
//
|
||||
// if e.NearLimit(0.8) { showUpgradePrompt() }
|
||||
func (e Entitlement) NearLimit(threshold float64) bool {
|
||||
if e.Unlimited || e.Limit == 0 {
|
||||
return false
|
||||
}
|
||||
return float64(e.Used)/float64(e.Limit) >= threshold
|
||||
}
|
||||
|
||||
// UsagePercent returns current usage as a percentage of the limit.
|
||||
//
|
||||
// pct := e.UsagePercent() // 75.0
|
||||
func (e Entitlement) UsagePercent() float64 {
|
||||
if e.Limit == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(e.Used) / float64(e.Limit) * 100
|
||||
}
|
||||
|
||||
// EntitlementChecker answers "can [subject] do [action] with [quantity]?"
|
||||
// Subject comes from context (workspace, entity, user — consumer's concern).
|
||||
type EntitlementChecker func(action string, quantity int, ctx context.Context) Entitlement
|
||||
|
||||
// UsageRecorder records consumption after a gated action succeeds.
|
||||
// Consumer packages provide the implementation (database, cache, etc).
|
||||
type UsageRecorder func(action string, quantity int, ctx context.Context)
|
||||
|
||||
// defaultChecker — trusted conclave, everything permitted.
|
||||
func defaultChecker(_ string, _ int, _ context.Context) Entitlement {
|
||||
return Entitlement{Allowed: true, Unlimited: true}
|
||||
}
|
||||
|
||||
// Entitled checks if an action is permitted in the current context.
|
||||
// Default: always returns Allowed=true, Unlimited=true.
|
||||
// Denials are logged via core.Security().
|
||||
//
|
||||
// e := c.Entitled("process.run")
|
||||
// e := c.Entitled("social.accounts", 3)
|
||||
func (c *Core) Entitled(action string, quantity ...int) Entitlement {
|
||||
qty := 1
|
||||
if len(quantity) > 0 {
|
||||
qty = quantity[0]
|
||||
}
|
||||
|
||||
e := c.entitlementChecker(action, qty, c.Context())
|
||||
|
||||
if !e.Allowed {
|
||||
Security("entitlement.denied", "action", action, "quantity", qty, "reason", e.Reason)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// SetEntitlementChecker replaces the default (permissive) checker.
|
||||
// Called by go-entitlements or commerce-matrix during OnStartup.
|
||||
//
|
||||
// func (s *EntitlementService) OnStartup(ctx context.Context) core.Result {
|
||||
// s.Core().SetEntitlementChecker(s.check)
|
||||
// return core.Result{OK: true}
|
||||
// }
|
||||
func (c *Core) SetEntitlementChecker(checker EntitlementChecker) {
|
||||
c.entitlementChecker = checker
|
||||
}
|
||||
|
||||
// RecordUsage records consumption after a gated action succeeds.
|
||||
// Delegates to the registered UsageRecorder. No-op if none registered.
|
||||
//
|
||||
// e := c.Entitled("ai.credits", 10)
|
||||
// if e.Allowed {
|
||||
// doWork()
|
||||
// c.RecordUsage("ai.credits", 10)
|
||||
// }
|
||||
func (c *Core) RecordUsage(action string, quantity ...int) {
|
||||
if c.usageRecorder == nil {
|
||||
return
|
||||
}
|
||||
qty := 1
|
||||
if len(quantity) > 0 {
|
||||
qty = quantity[0]
|
||||
}
|
||||
c.usageRecorder(action, qty, c.Context())
|
||||
}
|
||||
|
||||
// SetUsageRecorder registers a usage tracking function.
|
||||
// Called by go-entitlements during OnStartup.
|
||||
func (c *Core) SetUsageRecorder(recorder UsageRecorder) {
|
||||
c.usageRecorder = recorder
|
||||
}
|
||||
52
entitlement_example_test.go
Normal file
52
entitlement_example_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleEntitlement_UsagePercent() {
|
||||
e := Entitlement{Limit: 100, Used: 75}
|
||||
Println(e.UsagePercent())
|
||||
// Output: 75
|
||||
}
|
||||
|
||||
func ExampleCore_SetEntitlementChecker() {
|
||||
c := New()
|
||||
c.SetEntitlementChecker(func(action string, qty int, _ context.Context) Entitlement {
|
||||
limits := map[string]int{"social.accounts": 5, "ai.credits": 100}
|
||||
usage := map[string]int{"social.accounts": 3, "ai.credits": 95}
|
||||
|
||||
limit, ok := limits[action]
|
||||
if !ok {
|
||||
return Entitlement{Allowed: false, Reason: "not in package"}
|
||||
}
|
||||
used := usage[action]
|
||||
remaining := limit - used
|
||||
if qty > remaining {
|
||||
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
|
||||
}
|
||||
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
|
||||
})
|
||||
|
||||
Println(c.Entitled("social.accounts", 2).Allowed)
|
||||
Println(c.Entitled("social.accounts", 5).Allowed)
|
||||
Println(c.Entitled("ai.credits").NearLimit(0.9))
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
// true
|
||||
}
|
||||
|
||||
func ExampleCore_RecordUsage() {
|
||||
c := New()
|
||||
var recorded string
|
||||
c.SetUsageRecorder(func(action string, qty int, _ context.Context) {
|
||||
recorded = Concat(action, ":", Sprint(qty))
|
||||
})
|
||||
|
||||
c.RecordUsage("ai.credits", 10)
|
||||
Println(recorded)
|
||||
// Output: ai.credits:10
|
||||
}
|
||||
235
entitlement_test.go
Normal file
235
entitlement_test.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Entitled ---
|
||||
|
||||
func TestEntitlement_Entitled_Good_DefaultPermissive(t *testing.T) {
|
||||
c := New()
|
||||
e := c.Entitled("anything")
|
||||
assert.True(t, e.Allowed, "default checker permits everything")
|
||||
assert.True(t, e.Unlimited)
|
||||
}
|
||||
|
||||
func TestEntitlement_Entitled_Good_BooleanGate(t *testing.T) {
|
||||
c := New()
|
||||
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||||
if action == "premium.feature" {
|
||||
return Entitlement{Allowed: true}
|
||||
}
|
||||
return Entitlement{Allowed: false, Reason: "not in package"}
|
||||
})
|
||||
|
||||
assert.True(t, c.Entitled("premium.feature").Allowed)
|
||||
assert.False(t, c.Entitled("other.feature").Allowed)
|
||||
assert.Equal(t, "not in package", c.Entitled("other.feature").Reason)
|
||||
}
|
||||
|
||||
func TestEntitlement_Entitled_Good_QuantityCheck(t *testing.T) {
|
||||
c := New()
|
||||
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||||
if action == "social.accounts" {
|
||||
limit := 5
|
||||
used := 3
|
||||
remaining := limit - used
|
||||
if qty > remaining {
|
||||
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
|
||||
}
|
||||
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
|
||||
}
|
||||
return Entitlement{Allowed: true, Unlimited: true}
|
||||
})
|
||||
|
||||
// Can create 2 more (3 used of 5)
|
||||
e := c.Entitled("social.accounts", 2)
|
||||
assert.True(t, e.Allowed)
|
||||
assert.Equal(t, 5, e.Limit)
|
||||
assert.Equal(t, 3, e.Used)
|
||||
assert.Equal(t, 2, e.Remaining)
|
||||
|
||||
// Can't create 3 more
|
||||
e = c.Entitled("social.accounts", 3)
|
||||
assert.False(t, e.Allowed)
|
||||
assert.Equal(t, "limit exceeded", e.Reason)
|
||||
}
|
||||
|
||||
func TestEntitlement_Entitled_Bad_Denied(t *testing.T) {
|
||||
c := New()
|
||||
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||||
return Entitlement{Allowed: false, Reason: "locked by M1"}
|
||||
})
|
||||
|
||||
e := c.Entitled("product.create")
|
||||
assert.False(t, e.Allowed)
|
||||
assert.Equal(t, "locked by M1", e.Reason)
|
||||
}
|
||||
|
||||
func TestEntitlement_Entitled_Ugly_DefaultQuantityIsOne(t *testing.T) {
|
||||
c := New()
|
||||
var receivedQty int
|
||||
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||||
receivedQty = qty
|
||||
return Entitlement{Allowed: true}
|
||||
})
|
||||
|
||||
c.Entitled("test")
|
||||
assert.Equal(t, 1, receivedQty, "default quantity should be 1")
|
||||
}
|
||||
|
||||
// --- Action.Run Entitlement Enforcement ---
|
||||
|
||||
func TestEntitlement_ActionRun_Good_Permitted(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("work", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "done", OK: true}
|
||||
})
|
||||
|
||||
r := c.Action("work").Run(context.Background(), NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "done", r.Value)
|
||||
}
|
||||
|
||||
func TestEntitlement_ActionRun_Bad_Denied(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("restricted", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "should not reach", OK: true}
|
||||
})
|
||||
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||||
if action == "restricted" {
|
||||
return Entitlement{Allowed: false, Reason: "tier too low"}
|
||||
}
|
||||
return Entitlement{Allowed: true, Unlimited: true}
|
||||
})
|
||||
|
||||
r := c.Action("restricted").Run(context.Background(), NewOptions())
|
||||
assert.False(t, r.OK, "denied action must not execute")
|
||||
err, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, err.Error(), "not entitled")
|
||||
assert.Contains(t, err.Error(), "tier too low")
|
||||
}
|
||||
|
||||
func TestEntitlement_ActionRun_Good_OtherActionsStillWork(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("allowed", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "ok", OK: true}
|
||||
})
|
||||
c.Action("blocked", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "nope", OK: true}
|
||||
})
|
||||
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||||
if action == "blocked" {
|
||||
return Entitlement{Allowed: false, Reason: "nope"}
|
||||
}
|
||||
return Entitlement{Allowed: true, Unlimited: true}
|
||||
})
|
||||
|
||||
assert.True(t, c.Action("allowed").Run(context.Background(), NewOptions()).OK)
|
||||
assert.False(t, c.Action("blocked").Run(context.Background(), NewOptions()).OK)
|
||||
}
|
||||
|
||||
// --- NearLimit ---
|
||||
|
||||
func TestEntitlement_NearLimit_Good(t *testing.T) {
|
||||
e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15}
|
||||
assert.True(t, e.NearLimit(0.8))
|
||||
assert.False(t, e.NearLimit(0.9))
|
||||
}
|
||||
|
||||
func TestEntitlement_NearLimit_Bad_Unlimited(t *testing.T) {
|
||||
e := Entitlement{Allowed: true, Unlimited: true}
|
||||
assert.False(t, e.NearLimit(0.8), "unlimited should never be near limit")
|
||||
}
|
||||
|
||||
func TestEntitlement_NearLimit_Ugly_ZeroLimit(t *testing.T) {
|
||||
e := Entitlement{Allowed: true, Limit: 0}
|
||||
assert.False(t, e.NearLimit(0.8), "boolean gate (limit=0) should not report near limit")
|
||||
}
|
||||
|
||||
// --- UsagePercent ---
|
||||
|
||||
func TestEntitlement_UsagePercent_Good(t *testing.T) {
|
||||
e := Entitlement{Limit: 100, Used: 75}
|
||||
assert.Equal(t, 75.0, e.UsagePercent())
|
||||
}
|
||||
|
||||
func TestEntitlement_UsagePercent_Ugly_ZeroLimit(t *testing.T) {
|
||||
e := Entitlement{Limit: 0, Used: 5}
|
||||
assert.Equal(t, 0.0, e.UsagePercent(), "zero limit = boolean gate, no percentage")
|
||||
}
|
||||
|
||||
// --- RecordUsage ---
|
||||
|
||||
func TestEntitlement_RecordUsage_Good(t *testing.T) {
|
||||
c := New()
|
||||
var recorded string
|
||||
var recordedQty int
|
||||
|
||||
c.SetUsageRecorder(func(action string, qty int, ctx context.Context) {
|
||||
recorded = action
|
||||
recordedQty = qty
|
||||
})
|
||||
|
||||
c.RecordUsage("ai.credits", 10)
|
||||
assert.Equal(t, "ai.credits", recorded)
|
||||
assert.Equal(t, 10, recordedQty)
|
||||
}
|
||||
|
||||
func TestEntitlement_RecordUsage_Good_NoRecorder(t *testing.T) {
|
||||
c := New()
|
||||
// No recorder set — should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
c.RecordUsage("anything", 5)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Permission Model Integration ---
|
||||
|
||||
func TestEntitlement_Ugly_SaaSGatingPattern(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
// Simulate RFC-004 entitlement service
|
||||
packages := map[string]int{
|
||||
"social.accounts": 5,
|
||||
"social.posts.scheduled": 100,
|
||||
"ai.credits": 50,
|
||||
}
|
||||
usage := map[string]int{
|
||||
"social.accounts": 3,
|
||||
"social.posts.scheduled": 45,
|
||||
"ai.credits": 48,
|
||||
}
|
||||
|
||||
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||||
limit, hasFeature := packages[action]
|
||||
if !hasFeature {
|
||||
return Entitlement{Allowed: false, Reason: "feature not in package"}
|
||||
}
|
||||
used := usage[action]
|
||||
remaining := limit - used
|
||||
if qty > remaining {
|
||||
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
|
||||
}
|
||||
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
|
||||
})
|
||||
|
||||
// Can create 2 social accounts
|
||||
e := c.Entitled("social.accounts", 2)
|
||||
assert.True(t, e.Allowed)
|
||||
|
||||
// AI credits near limit
|
||||
e = c.Entitled("ai.credits", 1)
|
||||
assert.True(t, e.Allowed)
|
||||
assert.True(t, e.NearLimit(0.8))
|
||||
assert.Equal(t, 96.0, e.UsagePercent())
|
||||
|
||||
// Feature not in package
|
||||
e = c.Entitled("premium.feature")
|
||||
assert.False(t, e.Allowed)
|
||||
}
|
||||
33
error_example_test.go
Normal file
33
error_example_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleE() {
|
||||
err := E("cache.Get", "key not found", nil)
|
||||
Println(Operation(err))
|
||||
Println(ErrorMessage(err))
|
||||
// Output:
|
||||
// cache.Get
|
||||
// key not found
|
||||
}
|
||||
|
||||
func ExampleWrap() {
|
||||
cause := NewError("connection refused")
|
||||
err := Wrap(cause, "database.Connect", "failed to reach host")
|
||||
Println(Operation(err))
|
||||
Println(Is(err, cause))
|
||||
// Output:
|
||||
// database.Connect
|
||||
// true
|
||||
}
|
||||
|
||||
func ExampleRoot() {
|
||||
cause := NewError("original")
|
||||
wrapped := Wrap(cause, "op1", "first wrap")
|
||||
double := Wrap(wrapped, "op2", "second wrap")
|
||||
Println(Root(double))
|
||||
// Output: original
|
||||
}
|
||||
129
error_test.go
129
error_test.go
|
|
@ -1,7 +1,6 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -10,39 +9,39 @@ import (
|
|||
|
||||
// --- Error Creation ---
|
||||
|
||||
func TestE_Good(t *testing.T) {
|
||||
func TestError_E_Good(t *testing.T) {
|
||||
err := E("user.Save", "failed to save", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user.Save")
|
||||
assert.Contains(t, err.Error(), "failed to save")
|
||||
}
|
||||
|
||||
func TestE_WithCause_Good(t *testing.T) {
|
||||
cause := errors.New("connection refused")
|
||||
func TestError_E_WithCause_Good(t *testing.T) {
|
||||
cause := NewError("connection refused")
|
||||
err := E("db.Connect", "database unavailable", cause)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestWrap_Good(t *testing.T) {
|
||||
cause := errors.New("timeout")
|
||||
func TestError_Wrap_Good(t *testing.T) {
|
||||
cause := NewError("timeout")
|
||||
err := Wrap(cause, "api.Call", "request failed")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestWrap_Nil_Good(t *testing.T) {
|
||||
func TestError_Wrap_Nil_Good(t *testing.T) {
|
||||
err := Wrap(nil, "api.Call", "request failed")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestWrapCode_Good(t *testing.T) {
|
||||
cause := errors.New("invalid email")
|
||||
func TestError_WrapCode_Good(t *testing.T) {
|
||||
cause := NewError("invalid email")
|
||||
err := WrapCode(cause, "VALIDATION_ERROR", "user.Validate", "bad input")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "VALIDATION_ERROR", ErrorCode(err))
|
||||
}
|
||||
|
||||
func TestNewCode_Good(t *testing.T) {
|
||||
func TestError_NewCode_Good(t *testing.T) {
|
||||
err := NewCode("NOT_FOUND", "resource not found")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "NOT_FOUND", ErrorCode(err))
|
||||
|
|
@ -50,42 +49,42 @@ func TestNewCode_Good(t *testing.T) {
|
|||
|
||||
// --- Error Introspection ---
|
||||
|
||||
func TestOperation_Good(t *testing.T) {
|
||||
func TestError_Operation_Good(t *testing.T) {
|
||||
err := E("brain.Recall", "search failed", nil)
|
||||
assert.Equal(t, "brain.Recall", Operation(err))
|
||||
}
|
||||
|
||||
func TestOperation_Bad(t *testing.T) {
|
||||
err := errors.New("plain error")
|
||||
func TestError_Operation_Bad(t *testing.T) {
|
||||
err := NewError("plain error")
|
||||
assert.Equal(t, "", Operation(err))
|
||||
}
|
||||
|
||||
func TestErrorMessage_Good(t *testing.T) {
|
||||
func TestError_ErrorMessage_Good(t *testing.T) {
|
||||
err := E("op", "the message", nil)
|
||||
assert.Equal(t, "the message", ErrorMessage(err))
|
||||
}
|
||||
|
||||
func TestErrorMessage_Plain(t *testing.T) {
|
||||
err := errors.New("plain")
|
||||
func TestError_ErrorMessage_Plain(t *testing.T) {
|
||||
err := NewError("plain")
|
||||
assert.Equal(t, "plain", ErrorMessage(err))
|
||||
}
|
||||
|
||||
func TestErrorMessage_Nil(t *testing.T) {
|
||||
func TestError_ErrorMessage_Nil(t *testing.T) {
|
||||
assert.Equal(t, "", ErrorMessage(nil))
|
||||
}
|
||||
|
||||
func TestRoot_Good(t *testing.T) {
|
||||
root := errors.New("root cause")
|
||||
func TestError_Root_Good(t *testing.T) {
|
||||
root := NewError("root cause")
|
||||
wrapped := Wrap(root, "layer1", "first wrap")
|
||||
double := Wrap(wrapped, "layer2", "second wrap")
|
||||
assert.Equal(t, root, Root(double))
|
||||
}
|
||||
|
||||
func TestRoot_Nil(t *testing.T) {
|
||||
func TestError_Root_Nil(t *testing.T) {
|
||||
assert.Nil(t, Root(nil))
|
||||
}
|
||||
|
||||
func TestStackTrace_Good(t *testing.T) {
|
||||
func TestError_StackTrace_Good(t *testing.T) {
|
||||
err := Wrap(E("inner", "cause", nil), "outer", "wrapper")
|
||||
stack := StackTrace(err)
|
||||
assert.Len(t, stack, 2)
|
||||
|
|
@ -93,7 +92,7 @@ func TestStackTrace_Good(t *testing.T) {
|
|||
assert.Equal(t, "inner", stack[1])
|
||||
}
|
||||
|
||||
func TestFormatStackTrace_Good(t *testing.T) {
|
||||
func TestError_FormatStackTrace_Good(t *testing.T) {
|
||||
err := Wrap(E("a", "x", nil), "b", "y")
|
||||
formatted := FormatStackTrace(err)
|
||||
assert.Equal(t, "b -> a", formatted)
|
||||
|
|
@ -101,36 +100,36 @@ func TestFormatStackTrace_Good(t *testing.T) {
|
|||
|
||||
// --- ErrorLog ---
|
||||
|
||||
func TestErrorLog_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
cause := errors.New("boom")
|
||||
func TestError_ErrorLog_Good(t *testing.T) {
|
||||
c := New()
|
||||
cause := NewError("boom")
|
||||
r := c.Log().Error(cause, "test.Operation", "something broke")
|
||||
assert.False(t, r.OK)
|
||||
assert.ErrorIs(t, r.Value.(error), cause)
|
||||
}
|
||||
|
||||
func TestErrorLog_Nil_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorLog_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Log().Error(nil, "test.Operation", "no error")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestErrorLog_Warn_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
cause := errors.New("warning")
|
||||
func TestError_ErrorLog_Warn_Good(t *testing.T) {
|
||||
c := New()
|
||||
cause := NewError("warning")
|
||||
r := c.Log().Warn(cause, "test.Operation", "heads up")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestErrorLog_Must_Ugly(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorLog_Must_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
assert.Panics(t, func() {
|
||||
c.Log().Must(errors.New("fatal"), "test.Operation", "must fail")
|
||||
c.Log().Must(NewError("fatal"), "test.Operation", "must fail")
|
||||
})
|
||||
}
|
||||
|
||||
func TestErrorLog_Must_Nil_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorLog_Must_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotPanics(t, func() {
|
||||
c.Log().Must(nil, "test.Operation", "no error")
|
||||
})
|
||||
|
|
@ -138,8 +137,8 @@ func TestErrorLog_Must_Nil_Good(t *testing.T) {
|
|||
|
||||
// --- ErrorPanic ---
|
||||
|
||||
func TestErrorPanic_Recover_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorPanic_Recover_Good(t *testing.T) {
|
||||
c := New()
|
||||
// Should not panic — Recover catches it
|
||||
assert.NotPanics(t, func() {
|
||||
defer c.Error().Recover()
|
||||
|
|
@ -147,8 +146,8 @@ func TestErrorPanic_Recover_Good(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestErrorPanic_SafeGo_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorPanic_SafeGo_Good(t *testing.T) {
|
||||
c := New()
|
||||
done := make(chan bool, 1)
|
||||
c.Error().SafeGo(func() {
|
||||
done <- true
|
||||
|
|
@ -156,8 +155,8 @@ func TestErrorPanic_SafeGo_Good(t *testing.T) {
|
|||
assert.True(t, <-done)
|
||||
}
|
||||
|
||||
func TestErrorPanic_SafeGo_Panic_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorPanic_SafeGo_Panic_Good(t *testing.T) {
|
||||
c := New()
|
||||
done := make(chan bool, 1)
|
||||
c.Error().SafeGo(func() {
|
||||
defer func() { done <- true }()
|
||||
|
|
@ -169,27 +168,27 @@ func TestErrorPanic_SafeGo_Panic_Good(t *testing.T) {
|
|||
|
||||
// --- Standard Library Wrappers ---
|
||||
|
||||
func TestIs_Good(t *testing.T) {
|
||||
target := errors.New("target")
|
||||
func TestError_Is_Good(t *testing.T) {
|
||||
target := NewError("target")
|
||||
wrapped := Wrap(target, "op", "msg")
|
||||
assert.True(t, Is(wrapped, target))
|
||||
}
|
||||
|
||||
func TestAs_Good(t *testing.T) {
|
||||
func TestError_As_Good(t *testing.T) {
|
||||
err := E("op", "msg", nil)
|
||||
var e *Err
|
||||
assert.True(t, As(err, &e))
|
||||
assert.Equal(t, "op", e.Operation)
|
||||
}
|
||||
|
||||
func TestNewError_Good(t *testing.T) {
|
||||
func TestError_NewError_Good(t *testing.T) {
|
||||
err := NewError("simple error")
|
||||
assert.Equal(t, "simple error", err.Error())
|
||||
}
|
||||
|
||||
func TestErrorJoin_Good(t *testing.T) {
|
||||
e1 := errors.New("first")
|
||||
e2 := errors.New("second")
|
||||
func TestError_ErrorJoin_Good(t *testing.T) {
|
||||
e1 := NewError("first")
|
||||
e2 := NewError("second")
|
||||
joined := ErrorJoin(e1, e2)
|
||||
assert.ErrorIs(t, joined, e1)
|
||||
assert.ErrorIs(t, joined, e2)
|
||||
|
|
@ -197,12 +196,12 @@ func TestErrorJoin_Good(t *testing.T) {
|
|||
|
||||
// --- ErrorPanic Crash Reports ---
|
||||
|
||||
func TestErrorPanic_Reports_Good(t *testing.T) {
|
||||
func TestError_ErrorPanic_Reports_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/crashes.json"
|
||||
path := Path(dir, "crashes.json")
|
||||
|
||||
// Create ErrorPanic with file output
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
// Access internals via a crash that writes to file
|
||||
// Since ErrorPanic fields are unexported, we test via Recover
|
||||
_ = c
|
||||
|
|
@ -212,16 +211,16 @@ func TestErrorPanic_Reports_Good(t *testing.T) {
|
|||
|
||||
// --- ErrorPanic Crash File ---
|
||||
|
||||
func TestErrorPanic_CrashFile_Good(t *testing.T) {
|
||||
func TestError_ErrorPanic_CrashFile_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/crashes.json"
|
||||
path := Path(dir, "crashes.json")
|
||||
|
||||
// Create Core, trigger a panic through SafeGo, check crash file
|
||||
// ErrorPanic.filePath is unexported — but we can test via the package-level
|
||||
// error handling that writes crash reports
|
||||
|
||||
// For now, test that Reports handles missing file gracefully
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Error().Reports(5)
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
|
|
@ -230,43 +229,43 @@ func TestErrorPanic_CrashFile_Good(t *testing.T) {
|
|||
|
||||
// --- Error formatting branches ---
|
||||
|
||||
func TestErr_Error_WithCode_Good(t *testing.T) {
|
||||
err := WrapCode(errors.New("bad"), "INVALID", "validate", "input failed")
|
||||
func TestError_Err_Error_WithCode_Good(t *testing.T) {
|
||||
err := WrapCode(NewError("bad"), "INVALID", "validate", "input failed")
|
||||
assert.Contains(t, err.Error(), "[INVALID]")
|
||||
assert.Contains(t, err.Error(), "validate")
|
||||
assert.Contains(t, err.Error(), "bad")
|
||||
}
|
||||
|
||||
func TestErr_Error_CodeNoCause_Good(t *testing.T) {
|
||||
func TestError_Err_Error_CodeNoCause_Good(t *testing.T) {
|
||||
err := NewCode("NOT_FOUND", "resource missing")
|
||||
assert.Contains(t, err.Error(), "[NOT_FOUND]")
|
||||
assert.Contains(t, err.Error(), "resource missing")
|
||||
}
|
||||
|
||||
func TestErr_Error_NoOp_Good(t *testing.T) {
|
||||
func TestError_Err_Error_NoOp_Good(t *testing.T) {
|
||||
err := &Err{Message: "bare error"}
|
||||
assert.Equal(t, "bare error", err.Error())
|
||||
}
|
||||
|
||||
func TestWrapCode_NilErr_EmptyCode_Good(t *testing.T) {
|
||||
func TestError_WrapCode_NilErr_EmptyCode_Good(t *testing.T) {
|
||||
err := WrapCode(nil, "", "op", "msg")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestWrap_PreservesCode_Good(t *testing.T) {
|
||||
inner := WrapCode(errors.New("root"), "AUTH_FAIL", "auth", "denied")
|
||||
func TestError_Wrap_PreservesCode_Good(t *testing.T) {
|
||||
inner := WrapCode(NewError("root"), "AUTH_FAIL", "auth", "denied")
|
||||
outer := Wrap(inner, "handler", "request failed")
|
||||
assert.Equal(t, "AUTH_FAIL", ErrorCode(outer))
|
||||
}
|
||||
|
||||
func TestErrorLog_Warn_Nil_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorLog_Warn_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.LogWarn(nil, "op", "msg")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestErrorLog_Error_Nil_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestError_ErrorLog_Error_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.LogError(nil, "op", "msg")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
|
|
|||
314
example_test.go
Normal file
314
example_test.go
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// --- Core Creation ---
|
||||
|
||||
func ExampleNew() {
|
||||
c := New(
|
||||
WithOption("name", "my-app"),
|
||||
WithServiceLock(),
|
||||
)
|
||||
Println(c.App().Name)
|
||||
// Output: my-app
|
||||
}
|
||||
|
||||
func ExampleNew_withService() {
|
||||
c := New(
|
||||
WithOption("name", "example"),
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("greeter", Service{
|
||||
OnStart: func() Result {
|
||||
Info("greeter started", "app", c.App().Name)
|
||||
return Result{OK: true}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
Println(c.Services())
|
||||
c.ServiceShutdown(context.Background())
|
||||
// Output is non-deterministic (map order), so no Output comment
|
||||
}
|
||||
|
||||
// --- Options ---
|
||||
|
||||
func ExampleNewOptions() {
|
||||
opts := NewOptions(
|
||||
Option{Key: "name", Value: "brain"},
|
||||
Option{Key: "port", Value: 8080},
|
||||
Option{Key: "debug", Value: true},
|
||||
)
|
||||
Println(opts.String("name"))
|
||||
Println(opts.Int("port"))
|
||||
Println(opts.Bool("debug"))
|
||||
// Output:
|
||||
// brain
|
||||
// 8080
|
||||
// true
|
||||
}
|
||||
|
||||
// --- Result ---
|
||||
|
||||
func ExampleResult() {
|
||||
r := Result{Value: "hello", OK: true}
|
||||
if r.OK {
|
||||
Println(r.Value)
|
||||
}
|
||||
// Output: hello
|
||||
}
|
||||
|
||||
// --- Action ---
|
||||
|
||||
func ExampleCore_Action_register() {
|
||||
c := New()
|
||||
c.Action("greet", func(_ context.Context, opts Options) Result {
|
||||
name := opts.String("name")
|
||||
return Result{Value: Concat("hello ", name), OK: true}
|
||||
})
|
||||
Println(c.Action("greet").Exists())
|
||||
// Output: true
|
||||
}
|
||||
|
||||
func ExampleCore_Action_invoke() {
|
||||
c := New()
|
||||
c.Action("add", func(_ context.Context, opts Options) Result {
|
||||
a := opts.Int("a")
|
||||
b := opts.Int("b")
|
||||
return Result{Value: a + b, OK: true}
|
||||
})
|
||||
|
||||
r := c.Action("add").Run(context.Background(), NewOptions(
|
||||
Option{Key: "a", Value: 3},
|
||||
Option{Key: "b", Value: 4},
|
||||
))
|
||||
Println(r.Value)
|
||||
// Output: 7
|
||||
}
|
||||
|
||||
func ExampleCore_Actions() {
|
||||
c := New()
|
||||
c.Action("process.run", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
c.Action("brain.recall", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
|
||||
Println(c.Actions())
|
||||
// Output: [process.run brain.recall]
|
||||
}
|
||||
|
||||
// --- Task ---
|
||||
|
||||
func ExampleCore_Task() {
|
||||
c := New()
|
||||
order := ""
|
||||
|
||||
c.Action("step.a", func(_ context.Context, _ Options) Result {
|
||||
order += "a"
|
||||
return Result{Value: "from-a", OK: true}
|
||||
})
|
||||
c.Action("step.b", func(_ context.Context, opts Options) Result {
|
||||
order += "b"
|
||||
return Result{OK: true}
|
||||
})
|
||||
|
||||
c.Task("pipeline", Task{
|
||||
Steps: []Step{
|
||||
{Action: "step.a"},
|
||||
{Action: "step.b", Input: "previous"},
|
||||
},
|
||||
})
|
||||
|
||||
c.Task("pipeline").Run(context.Background(), c, NewOptions())
|
||||
Println(order)
|
||||
// Output: ab
|
||||
}
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
func ExampleNewRegistry() {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Set("bravo", "second")
|
||||
|
||||
Println(r.Has("alpha"))
|
||||
Println(r.Names())
|
||||
Println(r.Len())
|
||||
// Output:
|
||||
// true
|
||||
// [alpha bravo]
|
||||
// 2
|
||||
}
|
||||
|
||||
func ExampleRegistry_Lock() {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Lock()
|
||||
|
||||
result := r.Set("beta", "second")
|
||||
Println(result.OK)
|
||||
// Output: false
|
||||
}
|
||||
|
||||
func ExampleRegistry_Seal() {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Seal()
|
||||
|
||||
// Can update existing
|
||||
Println(r.Set("alpha", "updated").OK)
|
||||
// Can't add new
|
||||
Println(r.Set("beta", "new").OK)
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
}
|
||||
|
||||
// --- Entitlement ---
|
||||
|
||||
func ExampleCore_Entitled_default() {
|
||||
c := New()
|
||||
e := c.Entitled("anything")
|
||||
Println(e.Allowed)
|
||||
Println(e.Unlimited)
|
||||
// Output:
|
||||
// true
|
||||
// true
|
||||
}
|
||||
|
||||
func ExampleCore_Entitled_custom() {
|
||||
c := New()
|
||||
c.SetEntitlementChecker(func(action string, qty int, _ context.Context) Entitlement {
|
||||
if action == "premium" {
|
||||
return Entitlement{Allowed: false, Reason: "upgrade required"}
|
||||
}
|
||||
return Entitlement{Allowed: true, Unlimited: true}
|
||||
})
|
||||
|
||||
Println(c.Entitled("basic").Allowed)
|
||||
Println(c.Entitled("premium").Allowed)
|
||||
Println(c.Entitled("premium").Reason)
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
// upgrade required
|
||||
}
|
||||
|
||||
func ExampleEntitlement_NearLimit() {
|
||||
e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15}
|
||||
Println(e.NearLimit(0.8))
|
||||
Println(e.UsagePercent())
|
||||
// Output:
|
||||
// true
|
||||
// 85
|
||||
}
|
||||
|
||||
// --- Process ---
|
||||
|
||||
func ExampleCore_Process() {
|
||||
c := New()
|
||||
// No go-process registered — permission by registration
|
||||
Println(c.Process().Exists())
|
||||
|
||||
// Register a mock process handler
|
||||
c.Action("process.run", func(_ context.Context, opts Options) Result {
|
||||
return Result{Value: Concat("output of ", opts.String("command")), OK: true}
|
||||
})
|
||||
Println(c.Process().Exists())
|
||||
|
||||
r := c.Process().Run(context.Background(), "echo", "hello")
|
||||
Println(r.Value)
|
||||
// Output:
|
||||
// false
|
||||
// true
|
||||
// output of echo
|
||||
}
|
||||
|
||||
// --- JSON ---
|
||||
|
||||
func ExampleJSONMarshal() {
|
||||
type config struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
r := JSONMarshal(config{Host: "localhost", Port: 8080})
|
||||
Println(string(r.Value.([]byte)))
|
||||
// Output: {"host":"localhost","port":8080}
|
||||
}
|
||||
|
||||
func ExampleJSONUnmarshalString() {
|
||||
type config struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
var cfg config
|
||||
JSONUnmarshalString(`{"host":"localhost","port":8080}`, &cfg)
|
||||
Println(cfg.Host, cfg.Port)
|
||||
// Output: localhost 8080
|
||||
}
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
func ExampleID() {
|
||||
id := ID()
|
||||
Println(HasPrefix(id, "id-"))
|
||||
// Output: true
|
||||
}
|
||||
|
||||
func ExampleValidateName() {
|
||||
Println(ValidateName("brain").OK)
|
||||
Println(ValidateName("").OK)
|
||||
Println(ValidateName("..").OK)
|
||||
Println(ValidateName("path/traversal").OK)
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
// false
|
||||
// false
|
||||
}
|
||||
|
||||
func ExampleSanitisePath() {
|
||||
Println(SanitisePath("../../etc/passwd"))
|
||||
Println(SanitisePath(""))
|
||||
Println(SanitisePath("/some/path/file.txt"))
|
||||
// Output:
|
||||
// passwd
|
||||
// invalid
|
||||
// file.txt
|
||||
}
|
||||
|
||||
// --- Command ---
|
||||
|
||||
func ExampleCore_Command() {
|
||||
c := New()
|
||||
c.Command("deploy/to/homelab", Command{
|
||||
Action: func(opts Options) Result {
|
||||
return Result{Value: Concat("deployed to ", opts.String("_arg")), OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Cli().Run("deploy", "to", "homelab")
|
||||
Println(r.OK)
|
||||
// Output: true
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
|
||||
func ExampleConfig() {
|
||||
c := New()
|
||||
c.Config().Set("database.host", "localhost")
|
||||
c.Config().Set("database.port", 5432)
|
||||
c.Config().Enable("dark-mode")
|
||||
|
||||
Println(c.Config().String("database.host"))
|
||||
Println(c.Config().Int("database.port"))
|
||||
Println(c.Config().Enabled("dark-mode"))
|
||||
// Output:
|
||||
// localhost
|
||||
// 5432
|
||||
// true
|
||||
}
|
||||
|
||||
// Error examples in error_example_test.go
|
||||
137
fs.go
137
fs.go
|
|
@ -2,6 +2,8 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
|
|
@ -13,6 +15,37 @@ 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
|
||||
}
|
||||
|
||||
// NewUnrestricted returns a new Fs with root "/", granting full filesystem access.
|
||||
// Use this instead of unsafe.Pointer to bypass the sandbox.
|
||||
//
|
||||
// fs := c.Fs().NewUnrestricted()
|
||||
// fs.Read("/etc/hostname") // works — no sandbox
|
||||
func (m *Fs) NewUnrestricted() *Fs {
|
||||
return (&Fs{}).New("/")
|
||||
}
|
||||
|
||||
// Root returns the sandbox root path.
|
||||
//
|
||||
// root := c.Fs().Root() // e.g. "/home/agent/.core"
|
||||
func (m *Fs) Root() string {
|
||||
if m.root == "" {
|
||||
return "/"
|
||||
}
|
||||
return m.root
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -136,6 +169,52 @@ func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result {
|
|||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// TempDir creates a temporary directory and returns its path.
|
||||
// The caller is responsible for cleanup via fs.DeleteAll().
|
||||
//
|
||||
// dir := fs.TempDir("agent-workspace")
|
||||
// defer fs.DeleteAll(dir)
|
||||
func (m *Fs) TempDir(prefix string) string {
|
||||
dir, err := os.MkdirTemp("", prefix)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// DirFS returns an fs.FS rooted at the given directory path.
|
||||
//
|
||||
// fsys := core.DirFS("/path/to/templates")
|
||||
func DirFS(dir string) fs.FS {
|
||||
return os.DirFS(dir)
|
||||
}
|
||||
|
||||
// WriteAtomic writes content by writing to a temp file then renaming.
|
||||
// Rename is atomic on POSIX — concurrent readers never see a partial file.
|
||||
// Use this for status files, config, or any file read from multiple goroutines.
|
||||
//
|
||||
// r := fs.WriteAtomic("/status.json", jsonData)
|
||||
func (m *Fs) WriteAtomic(p, content string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
tmp := full + ".tmp." + shortRand()
|
||||
if err := os.WriteFile(tmp, []byte(content), 0644); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := os.Rename(tmp, full); err != nil {
|
||||
os.Remove(tmp)
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// EnsureDir creates directory if it doesn't exist.
|
||||
func (m *Fs) EnsureDir(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
|
|
@ -190,7 +269,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 +278,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 +287,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 +300,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 +313,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.
|
||||
|
|
@ -247,6 +326,54 @@ func (m *Fs) WriteStream(path string) Result {
|
|||
return m.Create(path)
|
||||
}
|
||||
|
||||
// ReadAll reads all bytes from a ReadCloser and closes it.
|
||||
// Wraps io.ReadAll so consumers don't import "io".
|
||||
//
|
||||
// r := fs.ReadStream(path)
|
||||
// data := core.ReadAll(r.Value)
|
||||
func ReadAll(reader any) Result {
|
||||
rc, ok := reader.(io.Reader)
|
||||
if !ok {
|
||||
return Result{E("core.ReadAll", "not a reader", nil), false}
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
if closer, ok := reader.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{string(data), true}
|
||||
}
|
||||
|
||||
// WriteAll writes content to a writer and closes it if it implements Closer.
|
||||
//
|
||||
// r := fs.WriteStream(path)
|
||||
// core.WriteAll(r.Value, "content")
|
||||
func WriteAll(writer any, content string) Result {
|
||||
wc, ok := writer.(io.Writer)
|
||||
if !ok {
|
||||
return Result{E("core.WriteAll", "not a writer", nil), false}
|
||||
}
|
||||
_, err := wc.Write([]byte(content))
|
||||
if closer, ok := writer.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// CloseStream closes any value that implements io.Closer.
|
||||
//
|
||||
// core.CloseStream(r.Value)
|
||||
func CloseStream(v any) {
|
||||
if closer, ok := v.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes a file or empty directory.
|
||||
func (m *Fs) Delete(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
|
|
|
|||
42
fs_example_test.go
Normal file
42
fs_example_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleFs_WriteAtomic() {
|
||||
f := (&Fs{}).New("/")
|
||||
dir := f.TempDir("example")
|
||||
defer f.DeleteAll(dir)
|
||||
|
||||
path := Path(dir, "status.json")
|
||||
f.WriteAtomic(path, `{"status":"completed"}`)
|
||||
|
||||
r := f.Read(path)
|
||||
Println(r.Value)
|
||||
// Output: {"status":"completed"}
|
||||
}
|
||||
|
||||
func ExampleFs_NewUnrestricted() {
|
||||
f := (&Fs{}).New("/")
|
||||
dir := f.TempDir("example")
|
||||
defer f.DeleteAll(dir)
|
||||
|
||||
// Write outside sandbox using Core's Fs
|
||||
outside := Path(dir, "outside.txt")
|
||||
f.Write(outside, "hello")
|
||||
|
||||
sandbox := (&Fs{}).New(Path(dir, "sandbox"))
|
||||
unrestricted := sandbox.NewUnrestricted()
|
||||
|
||||
r := unrestricted.Read(outside)
|
||||
Println(r.Value)
|
||||
// Output: hello
|
||||
}
|
||||
|
||||
func ExampleFs_Root() {
|
||||
f := (&Fs{}).New("/srv/workspaces")
|
||||
Println(f.Root())
|
||||
// Output: /srv/workspaces
|
||||
}
|
||||
220
fs_test.go
220
fs_test.go
|
|
@ -1,10 +1,7 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -15,9 +12,9 @@ import (
|
|||
|
||||
func TestFs_WriteRead_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
path := Path(dir, "test.txt")
|
||||
assert.True(t, c.Fs().Write(path, "hello core").OK)
|
||||
|
||||
r := c.Fs().Read(path)
|
||||
|
|
@ -26,31 +23,31 @@ func TestFs_WriteRead_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFs_Read_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Fs().Read("/nonexistent/path/to/file.txt")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_EnsureDir_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "sub", "dir")
|
||||
c := New()
|
||||
path := Path(dir, "sub", "dir")
|
||||
assert.True(t, c.Fs().EnsureDir(path).OK)
|
||||
assert.True(t, c.Fs().IsDir(path))
|
||||
}
|
||||
|
||||
func TestFs_IsDir_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
dir := t.TempDir()
|
||||
assert.True(t, c.Fs().IsDir(dir))
|
||||
assert.False(t, c.Fs().IsDir(filepath.Join(dir, "nonexistent")))
|
||||
assert.False(t, c.Fs().IsDir(Path(dir, "nonexistent")))
|
||||
assert.False(t, c.Fs().IsDir(""))
|
||||
}
|
||||
|
||||
func TestFs_IsFile_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
c := New()
|
||||
path := Path(dir, "test.txt")
|
||||
c.Fs().Write(path, "data")
|
||||
assert.True(t, c.Fs().IsFile(path))
|
||||
assert.False(t, c.Fs().IsFile(dir))
|
||||
|
|
@ -59,19 +56,19 @@ func TestFs_IsFile_Good(t *testing.T) {
|
|||
|
||||
func TestFs_Exists_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "exists.txt")
|
||||
c := New()
|
||||
path := Path(dir, "exists.txt")
|
||||
c.Fs().Write(path, "yes")
|
||||
assert.True(t, c.Fs().Exists(path))
|
||||
assert.True(t, c.Fs().Exists(dir))
|
||||
assert.False(t, c.Fs().Exists(filepath.Join(dir, "nope")))
|
||||
assert.False(t, c.Fs().Exists(Path(dir, "nope")))
|
||||
}
|
||||
|
||||
func TestFs_List_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
c.Fs().Write(filepath.Join(dir, "a.txt"), "a")
|
||||
c.Fs().Write(filepath.Join(dir, "b.txt"), "b")
|
||||
c := New()
|
||||
c.Fs().Write(Path(dir, "a.txt"), "a")
|
||||
c.Fs().Write(Path(dir, "b.txt"), "b")
|
||||
r := c.Fs().List(dir)
|
||||
assert.True(t, r.OK)
|
||||
assert.Len(t, r.Value.([]fs.DirEntry), 2)
|
||||
|
|
@ -79,76 +76,70 @@ func TestFs_List_Good(t *testing.T) {
|
|||
|
||||
func TestFs_Stat_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "stat.txt")
|
||||
c := New()
|
||||
path := Path(dir, "stat.txt")
|
||||
c.Fs().Write(path, "data")
|
||||
r := c.Fs().Stat(path)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "stat.txt", r.Value.(os.FileInfo).Name())
|
||||
assert.Equal(t, "stat.txt", r.Value.(fs.FileInfo).Name())
|
||||
}
|
||||
|
||||
func TestFs_Open_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "open.txt")
|
||||
c := New()
|
||||
path := Path(dir, "open.txt")
|
||||
c.Fs().Write(path, "content")
|
||||
r := c.Fs().Open(path)
|
||||
assert.True(t, r.OK)
|
||||
r.Value.(io.Closer).Close()
|
||||
CloseStream(r.Value)
|
||||
}
|
||||
|
||||
func TestFs_Create_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "sub", "created.txt")
|
||||
c := New()
|
||||
path := Path(dir, "sub", "created.txt")
|
||||
r := c.Fs().Create(path)
|
||||
assert.True(t, r.OK)
|
||||
w := r.Value.(io.WriteCloser)
|
||||
w.Write([]byte("hello"))
|
||||
w.Close()
|
||||
WriteAll(r.Value, "hello")
|
||||
rr := c.Fs().Read(path)
|
||||
assert.Equal(t, "hello", rr.Value.(string))
|
||||
}
|
||||
|
||||
func TestFs_Append_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "append.txt")
|
||||
c := New()
|
||||
path := Path(dir, "append.txt")
|
||||
c.Fs().Write(path, "first")
|
||||
r := c.Fs().Append(path)
|
||||
assert.True(t, r.OK)
|
||||
w := r.Value.(io.WriteCloser)
|
||||
w.Write([]byte(" second"))
|
||||
w.Close()
|
||||
WriteAll(r.Value, " second")
|
||||
rr := c.Fs().Read(path)
|
||||
assert.Equal(t, "first second", rr.Value.(string))
|
||||
}
|
||||
|
||||
func TestFs_ReadStream_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "stream.txt")
|
||||
c := New()
|
||||
path := Path(dir, "stream.txt")
|
||||
c.Fs().Write(path, "streamed")
|
||||
r := c.Fs().ReadStream(path)
|
||||
assert.True(t, r.OK)
|
||||
r.Value.(io.Closer).Close()
|
||||
CloseStream(r.Value)
|
||||
}
|
||||
|
||||
func TestFs_WriteStream_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "sub", "ws.txt")
|
||||
c := New()
|
||||
path := Path(dir, "sub", "ws.txt")
|
||||
r := c.Fs().WriteStream(path)
|
||||
assert.True(t, r.OK)
|
||||
w := r.Value.(io.WriteCloser)
|
||||
w.Write([]byte("stream"))
|
||||
w.Close()
|
||||
WriteAll(r.Value, "stream")
|
||||
}
|
||||
|
||||
func TestFs_Delete_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "delete.txt")
|
||||
c := New()
|
||||
path := Path(dir, "delete.txt")
|
||||
c.Fs().Write(path, "gone")
|
||||
assert.True(t, c.Fs().Delete(path).OK)
|
||||
assert.False(t, c.Fs().Exists(path))
|
||||
|
|
@ -156,19 +147,19 @@ func TestFs_Delete_Good(t *testing.T) {
|
|||
|
||||
func TestFs_DeleteAll_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
sub := filepath.Join(dir, "deep", "nested")
|
||||
c := New()
|
||||
sub := Path(dir, "deep", "nested")
|
||||
c.Fs().EnsureDir(sub)
|
||||
c.Fs().Write(filepath.Join(sub, "file.txt"), "data")
|
||||
assert.True(t, c.Fs().DeleteAll(filepath.Join(dir, "deep")).OK)
|
||||
assert.False(t, c.Fs().Exists(filepath.Join(dir, "deep")))
|
||||
c.Fs().Write(Path(sub, "file.txt"), "data")
|
||||
assert.True(t, c.Fs().DeleteAll(Path(dir, "deep")).OK)
|
||||
assert.False(t, c.Fs().Exists(Path(dir, "deep")))
|
||||
}
|
||||
|
||||
func TestFs_Rename_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
old := filepath.Join(dir, "old.txt")
|
||||
nw := filepath.Join(dir, "new.txt")
|
||||
c := New()
|
||||
old := Path(dir, "old.txt")
|
||||
nw := Path(dir, "new.txt")
|
||||
c.Fs().Write(old, "data")
|
||||
assert.True(t, c.Fs().Rename(old, nw).OK)
|
||||
assert.False(t, c.Fs().Exists(old))
|
||||
|
|
@ -177,12 +168,12 @@ func TestFs_Rename_Good(t *testing.T) {
|
|||
|
||||
func TestFs_WriteMode_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "secret.txt")
|
||||
c := New()
|
||||
path := Path(dir, "secret.txt")
|
||||
assert.True(t, c.Fs().WriteMode(path, "secret", 0600).OK)
|
||||
r := c.Fs().Stat(path)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "secret.txt", r.Value.(os.FileInfo).Name())
|
||||
assert.Equal(t, "secret.txt", r.Value.(fs.FileInfo).Name())
|
||||
}
|
||||
|
||||
// --- Zero Value ---
|
||||
|
|
@ -191,7 +182,7 @@ func TestFs_ZeroValue_Good(t *testing.T) {
|
|||
dir := t.TempDir()
|
||||
zeroFs := &Fs{}
|
||||
|
||||
path := filepath.Join(dir, "zero.txt")
|
||||
path := Path(dir, "zero.txt")
|
||||
assert.True(t, zeroFs.Write(path, "zero value works").OK)
|
||||
r := zeroFs.Read(path)
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -205,7 +196,7 @@ func TestFs_ZeroValue_List_Good(t *testing.T) {
|
|||
dir := t.TempDir()
|
||||
zeroFs := &Fs{}
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644)
|
||||
(&Fs{}).New("/").Write(Path(dir, "a.txt"), "a")
|
||||
r := zeroFs.List(dir)
|
||||
assert.True(t, r.OK)
|
||||
entries := r.Value.([]fs.DirEntry)
|
||||
|
|
@ -213,40 +204,40 @@ func TestFs_ZeroValue_List_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFs_Exists_NotFound_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.False(t, c.Fs().Exists("/nonexistent/path/xyz"))
|
||||
}
|
||||
|
||||
// --- Fs path/validatePath edge cases ---
|
||||
|
||||
func TestFs_Read_EmptyPath_Ugly(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Fs().Read("")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_Write_EmptyPath_Ugly(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Fs().Write("", "data")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_Delete_Protected_Ugly(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Fs().Delete("/")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_DeleteAll_Protected_Ugly(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Fs().DeleteAll("/")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_ReadStream_WriteStream_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New().Value.(*Core)
|
||||
path := filepath.Join(dir, "stream.txt")
|
||||
c := New()
|
||||
path := Path(dir, "stream.txt")
|
||||
c.Fs().Write(path, "streamed")
|
||||
|
||||
r := c.Fs().ReadStream(path)
|
||||
|
|
@ -255,3 +246,104 @@ func TestFs_ReadStream_WriteStream_Good(t *testing.T) {
|
|||
w := c.Fs().WriteStream(path)
|
||||
assert.True(t, w.OK)
|
||||
}
|
||||
|
||||
// --- WriteAtomic ---
|
||||
|
||||
func TestFs_WriteAtomic_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "status.json")
|
||||
r := c.Fs().WriteAtomic(path, `{"status":"completed"}`)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
read := c.Fs().Read(path)
|
||||
assert.True(t, read.OK)
|
||||
assert.Equal(t, `{"status":"completed"}`, read.Value)
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Good_Overwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "data.txt")
|
||||
c.Fs().WriteAtomic(path, "first")
|
||||
c.Fs().WriteAtomic(path, "second")
|
||||
|
||||
read := c.Fs().Read(path)
|
||||
assert.Equal(t, "second", read.Value)
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Bad_ReadOnlyDir(t *testing.T) {
|
||||
// Write to a non-existent root that can't be created
|
||||
m := (&Fs{}).New("/proc/nonexistent")
|
||||
r := m.WriteAtomic("file.txt", "data")
|
||||
assert.False(t, r.OK, "WriteAtomic must fail when parent dir cannot be created")
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Ugly_NoTempFileLeftOver(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "clean.txt")
|
||||
c.Fs().WriteAtomic(path, "content")
|
||||
|
||||
// Check no .tmp files remain
|
||||
lr := c.Fs().List(dir)
|
||||
entries, _ := lr.Value.([]fs.DirEntry)
|
||||
for _, e := range entries {
|
||||
assert.False(t, Contains(e.Name(), ".tmp."), "temp file should not remain after successful atomic write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFs_WriteAtomic_Good_CreatesParentDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "sub", "dir", "file.txt")
|
||||
r := c.Fs().WriteAtomic(path, "nested")
|
||||
assert.True(t, r.OK)
|
||||
|
||||
read := c.Fs().Read(path)
|
||||
assert.Equal(t, "nested", read.Value)
|
||||
}
|
||||
|
||||
// --- NewUnrestricted ---
|
||||
|
||||
func TestFs_NewUnrestricted_Good(t *testing.T) {
|
||||
sandboxed := (&Fs{}).New(t.TempDir())
|
||||
unrestricted := sandboxed.NewUnrestricted()
|
||||
assert.Equal(t, "/", unrestricted.Root())
|
||||
}
|
||||
|
||||
func TestFs_NewUnrestricted_Good_CanReadOutsideSandbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := Path(dir, "outside.txt")
|
||||
(&Fs{}).New("/").Write(outside, "hello")
|
||||
|
||||
sandboxed := (&Fs{}).New(Path(dir, "sandbox"))
|
||||
unrestricted := sandboxed.NewUnrestricted()
|
||||
|
||||
r := unrestricted.Read(outside)
|
||||
assert.True(t, r.OK, "unrestricted Fs must read paths outside the original sandbox")
|
||||
assert.Equal(t, "hello", r.Value)
|
||||
}
|
||||
|
||||
func TestFs_NewUnrestricted_Ugly_OriginalStaysSandboxed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sandbox := Path(dir, "sandbox")
|
||||
(&Fs{}).New("/").EnsureDir(sandbox)
|
||||
|
||||
sandboxed := (&Fs{}).New(sandbox)
|
||||
_ = sandboxed.NewUnrestricted() // getting unrestricted doesn't affect original
|
||||
|
||||
assert.Equal(t, sandbox, sandboxed.Root(), "original Fs must remain sandboxed")
|
||||
}
|
||||
|
||||
// --- Root ---
|
||||
|
||||
func TestFs_Root_Good(t *testing.T) {
|
||||
m := (&Fs{}).New("/home/agent")
|
||||
assert.Equal(t, "/home/agent", m.Root())
|
||||
}
|
||||
|
||||
func TestFs_Root_Good_Default(t *testing.T) {
|
||||
m := (&Fs{}).New("")
|
||||
assert.Equal(t, "/", m.Root())
|
||||
}
|
||||
|
|
|
|||
30
i18n_test.go
30
i18n_test.go
|
|
@ -10,17 +10,17 @@ import (
|
|||
// --- I18n ---
|
||||
|
||||
func TestI18n_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.NotNil(t, c.I18n())
|
||||
}
|
||||
|
||||
func TestI18n_AddLocales_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "lang"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
c := New()
|
||||
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))
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ func TestI18n_AddLocales_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestI18n_Locales_Empty_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.I18n().Locales()
|
||||
assert.True(t, r.OK)
|
||||
assert.Empty(t, r.Value.([]*Embed))
|
||||
|
|
@ -39,7 +39,7 @@ func TestI18n_Locales_Empty_Good(t *testing.T) {
|
|||
// --- Translator (no translator registered) ---
|
||||
|
||||
func TestI18n_Translate_NoTranslator_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
// Without a translator, Translate returns the key as-is
|
||||
r := c.I18n().Translate("greeting.hello")
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -47,24 +47,24 @@ func TestI18n_Translate_NoTranslator_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestI18n_SetLanguage_NoTranslator_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.I18n().SetLanguage("de")
|
||||
assert.True(t, r.OK) // no-op without translator
|
||||
}
|
||||
|
||||
func TestI18n_Language_NoTranslator_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.Equal(t, "en", c.I18n().Language())
|
||||
}
|
||||
|
||||
func TestI18n_AvailableLanguages_NoTranslator_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
langs := c.I18n().AvailableLanguages()
|
||||
assert.Equal(t, []string{"en"}, langs)
|
||||
}
|
||||
|
||||
func TestI18n_Translator_Nil_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.False(t, c.I18n().Translator().OK)
|
||||
}
|
||||
|
||||
|
|
@ -75,14 +75,14 @@ type mockTranslator struct {
|
|||
}
|
||||
|
||||
func (m *mockTranslator) Translate(id string, args ...any) Result {
|
||||
return Result{"translated:" + id, true}
|
||||
return Result{Concat("translated:", id), true}
|
||||
}
|
||||
func (m *mockTranslator) SetLanguage(lang string) error { m.lang = lang; return nil }
|
||||
func (m *mockTranslator) Language() string { return m.lang }
|
||||
func (m *mockTranslator) AvailableLanguages() []string { return []string{"en", "de", "fr"} }
|
||||
|
||||
func TestI18n_WithTranslator_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
tr := &mockTranslator{lang: "en"}
|
||||
c.I18n().SetTranslator(tr)
|
||||
|
||||
|
|
|
|||
17
info_example_test.go
Normal file
17
info_example_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleEnv() {
|
||||
Println(Env("OS")) // e.g. "darwin"
|
||||
Println(Env("ARCH")) // e.g. "arm64"
|
||||
}
|
||||
|
||||
func ExampleEnvKeys() {
|
||||
keys := EnvKeys()
|
||||
Println(len(keys) > 0)
|
||||
// Output: true
|
||||
}
|
||||
78
info_test.go
78
info_test.go
|
|
@ -3,8 +3,6 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -13,88 +11,84 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnv_OS(t *testing.T) {
|
||||
assert.Equal(t, runtime.GOOS, core.Env("OS"))
|
||||
func TestInfo_Env_OS_Good(t *testing.T) {
|
||||
v := core.Env("OS")
|
||||
assert.NotEmpty(t, v)
|
||||
assert.Contains(t, []string{"darwin", "linux", "windows"}, v)
|
||||
}
|
||||
|
||||
func TestEnv_ARCH(t *testing.T) {
|
||||
assert.Equal(t, runtime.GOARCH, core.Env("ARCH"))
|
||||
func TestInfo_Env_ARCH_Good(t *testing.T) {
|
||||
v := core.Env("ARCH")
|
||||
assert.NotEmpty(t, v)
|
||||
assert.Contains(t, []string{"amd64", "arm64", "386"}, v)
|
||||
}
|
||||
|
||||
func TestEnv_GO(t *testing.T) {
|
||||
assert.Equal(t, runtime.Version(), core.Env("GO"))
|
||||
func TestInfo_Env_GO_Good(t *testing.T) {
|
||||
assert.True(t, core.HasPrefix(core.Env("GO"), "go"))
|
||||
}
|
||||
|
||||
func TestEnv_DS(t *testing.T) {
|
||||
assert.Equal(t, string(os.PathSeparator), core.Env("DS"))
|
||||
func TestInfo_Env_DS_Good(t *testing.T) {
|
||||
ds := core.Env("DS")
|
||||
assert.Contains(t, []string{"/", "\\"}, ds)
|
||||
}
|
||||
|
||||
func TestEnv_PS(t *testing.T) {
|
||||
assert.Equal(t, string(os.PathListSeparator), core.Env("PS"))
|
||||
func TestInfo_Env_PS_Good(t *testing.T) {
|
||||
ps := core.Env("PS")
|
||||
assert.Contains(t, []string{":", ";"}, ps)
|
||||
}
|
||||
|
||||
func TestEnv_DIR_HOME(t *testing.T) {
|
||||
if ch := os.Getenv("CORE_HOME"); ch != "" {
|
||||
assert.Equal(t, ch, core.Env("DIR_HOME"))
|
||||
return
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, home, core.Env("DIR_HOME"))
|
||||
func TestInfo_Env_DIR_HOME_Good(t *testing.T) {
|
||||
home := core.Env("DIR_HOME")
|
||||
assert.NotEmpty(t, home)
|
||||
assert.True(t, core.PathIsAbs(home), "DIR_HOME should be absolute")
|
||||
}
|
||||
|
||||
func TestEnv_DIR_TMP(t *testing.T) {
|
||||
assert.Equal(t, os.TempDir(), core.Env("DIR_TMP"))
|
||||
func TestInfo_Env_DIR_TMP_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("DIR_TMP"))
|
||||
}
|
||||
|
||||
func TestEnv_DIR_CONFIG(t *testing.T) {
|
||||
cfg, err := os.UserConfigDir()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cfg, core.Env("DIR_CONFIG"))
|
||||
func TestInfo_Env_DIR_CONFIG_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("DIR_CONFIG"))
|
||||
}
|
||||
|
||||
func TestEnv_DIR_CACHE(t *testing.T) {
|
||||
cache, err := os.UserCacheDir()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cache, core.Env("DIR_CACHE"))
|
||||
func TestInfo_Env_DIR_CACHE_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("DIR_CACHE"))
|
||||
}
|
||||
|
||||
func TestEnv_HOSTNAME(t *testing.T) {
|
||||
hostname, err := os.Hostname()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, hostname, core.Env("HOSTNAME"))
|
||||
func TestInfo_Env_HOSTNAME_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("HOSTNAME"))
|
||||
}
|
||||
|
||||
func TestEnv_USER(t *testing.T) {
|
||||
func TestInfo_Env_USER_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("USER"))
|
||||
}
|
||||
|
||||
func TestEnv_PID(t *testing.T) {
|
||||
func TestInfo_Env_PID_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("PID"))
|
||||
}
|
||||
|
||||
func TestEnv_NUM_CPU(t *testing.T) {
|
||||
func TestInfo_Env_NUM_CPU_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("NUM_CPU"))
|
||||
}
|
||||
|
||||
func TestEnv_CORE_START(t *testing.T) {
|
||||
func TestInfo_Env_CORE_START_Good(t *testing.T) {
|
||||
ts := core.Env("CORE_START")
|
||||
require.NotEmpty(t, ts)
|
||||
_, err := time.Parse(time.RFC3339, ts)
|
||||
assert.NoError(t, err, "CORE_START should be valid RFC3339")
|
||||
}
|
||||
|
||||
func TestEnv_Unknown(t *testing.T) {
|
||||
func TestInfo_Env_Bad_Unknown(t *testing.T) {
|
||||
assert.Equal(t, "", core.Env("NOPE"))
|
||||
}
|
||||
|
||||
func TestEnv_CoreInstance(t *testing.T) {
|
||||
c := core.New().Value.(*core.Core)
|
||||
func TestInfo_Env_Good_CoreInstance(t *testing.T) {
|
||||
c := core.New()
|
||||
assert.Equal(t, core.Env("OS"), c.Env("OS"))
|
||||
assert.Equal(t, core.Env("DIR_HOME"), c.Env("DIR_HOME"))
|
||||
}
|
||||
|
||||
func TestEnvKeys(t *testing.T) {
|
||||
func TestInfo_EnvKeys_Good(t *testing.T) {
|
||||
keys := core.EnvKeys()
|
||||
assert.NotEmpty(t, keys)
|
||||
assert.Contains(t, keys, "OS")
|
||||
|
|
|
|||
55
ipc.go
55
ipc.go
|
|
@ -11,7 +11,9 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// Ipc holds IPC dispatch data.
|
||||
// Ipc holds IPC dispatch data and the named action registry.
|
||||
//
|
||||
// ipc := (&core.Ipc{}).New()
|
||||
type Ipc struct {
|
||||
ipcMu sync.RWMutex
|
||||
ipcHandlers []func(*Core, Message) Result
|
||||
|
|
@ -19,23 +21,33 @@ type Ipc struct {
|
|||
queryMu sync.RWMutex
|
||||
queryHandlers []QueryHandler
|
||||
|
||||
taskMu sync.RWMutex
|
||||
taskHandlers []TaskHandler
|
||||
actions *Registry[*Action] // named action registry
|
||||
tasks *Registry[*Task] // named task registry
|
||||
}
|
||||
|
||||
func (c *Core) Action(msg Message) Result {
|
||||
// broadcast dispatches a message to all registered IPC handlers.
|
||||
// Each handler is wrapped in panic recovery. All handlers fire regardless of individual results.
|
||||
func (c *Core) broadcast(msg Message) Result {
|
||||
c.ipc.ipcMu.RLock()
|
||||
handlers := slices.Clone(c.ipc.ipcHandlers)
|
||||
c.ipc.ipcMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
if r := h(c, msg); !r.OK {
|
||||
return r
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
Error("ACTION handler panicked", "panic", r)
|
||||
}
|
||||
}()
|
||||
h(c, msg)
|
||||
}()
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Query dispatches a request — first handler to return OK wins.
|
||||
//
|
||||
// r := c.Query(MyQuery{})
|
||||
func (c *Core) Query(q Query) Result {
|
||||
c.ipc.queryMu.RLock()
|
||||
handlers := slices.Clone(c.ipc.queryHandlers)
|
||||
|
|
@ -50,6 +62,10 @@ func (c *Core) Query(q Query) Result {
|
|||
return Result{}
|
||||
}
|
||||
|
||||
// QueryAll dispatches a request — collects all OK responses.
|
||||
//
|
||||
// r := c.QueryAll(countQuery{})
|
||||
// results := r.Value.([]any)
|
||||
func (c *Core) QueryAll(q Query) Result {
|
||||
c.ipc.queryMu.RLock()
|
||||
handlers := slices.Clone(c.ipc.queryHandlers)
|
||||
|
|
@ -65,8 +81,33 @@ func (c *Core) QueryAll(q Query) Result {
|
|||
return Result{results, true}
|
||||
}
|
||||
|
||||
// RegisterQuery registers a handler for QUERY dispatch.
|
||||
//
|
||||
// c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { ... })
|
||||
func (c *Core) RegisterQuery(handler QueryHandler) {
|
||||
c.ipc.queryMu.Lock()
|
||||
c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler)
|
||||
c.ipc.queryMu.Unlock()
|
||||
}
|
||||
|
||||
// --- IPC Registration (handlers) ---
|
||||
|
||||
// RegisterAction registers a broadcast handler for ACTION messages.
|
||||
//
|
||||
// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
|
||||
// if ev, ok := msg.(AgentCompleted); ok { ... }
|
||||
// return core.Result{OK: true}
|
||||
// })
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterActions registers multiple broadcast handlers.
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
|
|
|
|||
83
ipc_test.go
83
ipc_test.go
|
|
@ -1,6 +1,7 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -12,7 +13,7 @@ import (
|
|||
type testMessage struct{ payload string }
|
||||
|
||||
func TestAction_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
var received Message
|
||||
c.RegisterAction(func(_ *Core, msg Message) Result {
|
||||
received = msg
|
||||
|
|
@ -24,7 +25,7 @@ func TestAction_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAction_Multiple_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
count := 0
|
||||
handler := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
|
||||
c.RegisterActions(handler, handler, handler)
|
||||
|
|
@ -33,16 +34,65 @@ func TestAction_Multiple_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAction_None_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
// No handlers registered — should succeed
|
||||
r := c.ACTION(nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestAction_Bad_HandlerFails(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
return Result{Value: NewError("intentional"), OK: false}
|
||||
})
|
||||
// ACTION is broadcast — even with a failing handler, dispatch succeeds
|
||||
r := c.ACTION(testMessage{payload: "test"})
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestAction_Ugly_HandlerFailsChainContinues(t *testing.T) {
|
||||
c := New()
|
||||
var order []int
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 1)
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 2)
|
||||
return Result{Value: NewError("handler 2 fails"), OK: false}
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 3)
|
||||
return Result{OK: true}
|
||||
})
|
||||
r := c.ACTION(testMessage{payload: "test"})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, []int{1, 2, 3}, order, "all 3 handlers must fire even when handler 2 returns !OK")
|
||||
}
|
||||
|
||||
func TestAction_Ugly_HandlerPanicsChainContinues(t *testing.T) {
|
||||
c := New()
|
||||
var order []int
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 1)
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
panic("handler 2 explodes")
|
||||
})
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
order = append(order, 3)
|
||||
return Result{OK: true}
|
||||
})
|
||||
r := c.ACTION(testMessage{payload: "test"})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, []int{1, 3}, order, "handlers 1 and 3 must fire even when handler 2 panics")
|
||||
}
|
||||
|
||||
// --- IPC: Queries ---
|
||||
|
||||
func TestQuery_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestIpc_Query_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
if q == "ping" {
|
||||
return Result{Value: "pong", OK: true}
|
||||
|
|
@ -54,8 +104,8 @@ func TestQuery_Good(t *testing.T) {
|
|||
assert.Equal(t, "pong", r.Value)
|
||||
}
|
||||
|
||||
func TestQuery_Unhandled_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestIpc_Query_Unhandled_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
return Result{}
|
||||
})
|
||||
|
|
@ -63,8 +113,8 @@ func TestQuery_Unhandled_Good(t *testing.T) {
|
|||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestQueryAll_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestIpc_QueryAll_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, _ Query) Result {
|
||||
return Result{Value: "a", OK: true}
|
||||
})
|
||||
|
|
@ -79,17 +129,14 @@ func TestQueryAll_Good(t *testing.T) {
|
|||
assert.Contains(t, results, "b")
|
||||
}
|
||||
|
||||
// --- IPC: Tasks ---
|
||||
// --- IPC: Named Action Invocation ---
|
||||
|
||||
func TestPerform_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.RegisterTask(func(_ *Core, t Task) Result {
|
||||
if t == "compute" {
|
||||
return Result{Value: 42, OK: true}
|
||||
}
|
||||
return Result{}
|
||||
func TestIpc_ActionInvoke_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("compute", func(_ context.Context, opts Options) Result {
|
||||
return Result{Value: 42, OK: true}
|
||||
})
|
||||
r := c.PERFORM("compute")
|
||||
r := c.Action("compute").Run(context.Background(), NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 42, r.Value)
|
||||
}
|
||||
|
|
|
|||
58
json.go
Normal file
58
json.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// JSON helpers for the Core framework.
|
||||
// Wraps encoding/json so consumers don't import it directly.
|
||||
// Same guardrail pattern as string.go wraps strings.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// data := core.JSONMarshal(myStruct)
|
||||
// if data.OK { json := data.Value.([]byte) }
|
||||
//
|
||||
// r := core.JSONUnmarshal(jsonBytes, &target)
|
||||
// if !r.OK { /* handle error */ }
|
||||
package core
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// JSONMarshal serialises a value to JSON bytes.
|
||||
//
|
||||
// r := core.JSONMarshal(myStruct)
|
||||
// if r.OK { data := r.Value.([]byte) }
|
||||
func JSONMarshal(v any) Result {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{data, true}
|
||||
}
|
||||
|
||||
// JSONMarshalString serialises a value to a JSON string.
|
||||
//
|
||||
// s := core.JSONMarshalString(myStruct)
|
||||
func JSONMarshalString(v any) string {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// JSONUnmarshal deserialises JSON bytes into a target.
|
||||
//
|
||||
// var cfg Config
|
||||
// r := core.JSONUnmarshal(data, &cfg)
|
||||
func JSONUnmarshal(data []byte, target any) Result {
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// JSONUnmarshalString deserialises a JSON string into a target.
|
||||
//
|
||||
// var cfg Config
|
||||
// r := core.JSONUnmarshalString(`{"port":8080}`, &cfg)
|
||||
func JSONUnmarshalString(s string, target any) Result {
|
||||
return JSONUnmarshal([]byte(s), target)
|
||||
}
|
||||
63
json_test.go
Normal file
63
json_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testJSON struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// --- JSONMarshal ---
|
||||
|
||||
func TestJson_JSONMarshal_Good(t *testing.T) {
|
||||
r := JSONMarshal(testJSON{Name: "brain", Port: 8080})
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, string(r.Value.([]byte)), `"name":"brain"`)
|
||||
}
|
||||
|
||||
func TestJson_JSONMarshal_Bad_Unmarshalable(t *testing.T) {
|
||||
r := JSONMarshal(make(chan int))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- JSONMarshalString ---
|
||||
|
||||
func TestJson_JSONMarshalString_Good(t *testing.T) {
|
||||
s := JSONMarshalString(testJSON{Name: "x", Port: 1})
|
||||
assert.Contains(t, s, `"name":"x"`)
|
||||
}
|
||||
|
||||
func TestJson_JSONMarshalString_Ugly_Fallback(t *testing.T) {
|
||||
s := JSONMarshalString(make(chan int))
|
||||
assert.Equal(t, "{}", s)
|
||||
}
|
||||
|
||||
// --- JSONUnmarshal ---
|
||||
|
||||
func TestJson_JSONUnmarshal_Good(t *testing.T) {
|
||||
var target testJSON
|
||||
r := JSONUnmarshal([]byte(`{"name":"brain","port":8080}`), &target)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "brain", target.Name)
|
||||
assert.Equal(t, 8080, target.Port)
|
||||
}
|
||||
|
||||
func TestJson_JSONUnmarshal_Bad_Invalid(t *testing.T) {
|
||||
var target testJSON
|
||||
r := JSONUnmarshal([]byte(`not json`), &target)
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- JSONUnmarshalString ---
|
||||
|
||||
func TestJson_JSONUnmarshalString_Good(t *testing.T) {
|
||||
var target testJSON
|
||||
r := JSONUnmarshalString(`{"name":"x","port":1}`, &target)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "x", target.Name)
|
||||
}
|
||||
46
llm.txt
Normal file
46
llm.txt
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# core/go — CoreGO Framework
|
||||
|
||||
> dappco.re/go/core — Dependency injection, service lifecycle, permission,
|
||||
> and message-passing framework for Go. Foundation layer for the Lethean ecosystem.
|
||||
|
||||
## Entry Points
|
||||
|
||||
- CLAUDE.md — Agent instructions, build commands, subsystem table
|
||||
- docs/RFC.md — API contract specification (21 sections, the authoritative spec)
|
||||
|
||||
## Package Layout
|
||||
|
||||
All source files at module root. No pkg/ nesting. Tests are *_test.go alongside source.
|
||||
|
||||
## Key Types
|
||||
|
||||
- Core — Central application container (core.New() returns *Core)
|
||||
- Option — Single key-value pair {Key: string, Value: any}
|
||||
- Options — Collection of Option with typed accessors
|
||||
- Result — Universal return type {Value: any, OK: bool}
|
||||
- Service — Managed component with lifecycle (Startable/Stoppable return Result)
|
||||
- Action — Named callable with panic recovery and entitlement enforcement
|
||||
- Task — Composed sequence of Actions (Steps, Async, Input piping)
|
||||
- Registry[T] — Thread-safe named collection (universal brick)
|
||||
- Process — Managed execution (sugar over Actions)
|
||||
- API — Remote streams (protocol handlers, Drive integration)
|
||||
- Entitlement — Permission check result (Allowed, Limit, Used, Remaining)
|
||||
- Message — IPC broadcast type for ACTION
|
||||
- Query — IPC request/response type for QUERY
|
||||
|
||||
## Service Pattern
|
||||
|
||||
core.New(
|
||||
core.WithService(mypackage.Register),
|
||||
)
|
||||
|
||||
func Register(c *core.Core) core.Result {
|
||||
svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, opts)}
|
||||
return core.Result{Value: svc, OK: true}
|
||||
}
|
||||
|
||||
## Conventions
|
||||
|
||||
Follows RFC-025 Agent Experience (AX) principles.
|
||||
Tests: TestFile_Function_{Good,Bad,Ugly} — 100% AX-7 naming.
|
||||
See: https://core.help/specs/RFC-025-AGENT-EXPERIENCE/
|
||||
49
lock.go
49
lock.go
|
|
@ -8,82 +8,61 @@ 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
|
||||
locks *Registry[*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]
|
||||
if !ok {
|
||||
m = &sync.RWMutex{}
|
||||
lockMap[name] = m
|
||||
r := c.lock.locks.Get(name)
|
||||
if r.OK {
|
||||
return &Lock{Name: name, Mutex: r.Value.(*sync.RWMutex)}
|
||||
}
|
||||
lockMu.Unlock()
|
||||
m := &sync.RWMutex{}
|
||||
c.lock.locks.Set(name, m)
|
||||
return &Lock{Name: name, Mutex: m}
|
||||
}
|
||||
|
||||
// LockEnable marks that the service lock should be applied after initialisation.
|
||||
func (c *Core) LockEnable(name ...string) {
|
||||
n := "srv"
|
||||
if len(name) > 0 {
|
||||
n = name[0]
|
||||
}
|
||||
c.Lock(n).Mutex.Lock()
|
||||
defer c.Lock(n).Mutex.Unlock()
|
||||
c.services.lockEnabled = true
|
||||
}
|
||||
|
||||
// LockApply activates the service lock if it was enabled.
|
||||
func (c *Core) LockApply(name ...string) {
|
||||
n := "srv"
|
||||
if len(name) > 0 {
|
||||
n = name[0]
|
||||
}
|
||||
c.Lock(n).Mutex.Lock()
|
||||
defer c.Lock(n).Mutex.Unlock()
|
||||
if c.services.lockEnabled {
|
||||
c.services.locked = true
|
||||
c.services.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
// Startables returns services that have an OnStart function.
|
||||
// Startables returns services that have an OnStart function, in registration order.
|
||||
func (c *Core) Startables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var out []*Service
|
||||
for _, svc := range c.services.services {
|
||||
c.services.Each(func(_ string, svc *Service) {
|
||||
if svc.OnStart != nil {
|
||||
out = append(out, svc)
|
||||
}
|
||||
}
|
||||
})
|
||||
return Result{out, true}
|
||||
}
|
||||
|
||||
// Stoppables returns services that have an OnStop function.
|
||||
// Stoppables returns services that have an OnStop function, in registration order.
|
||||
func (c *Core) Stoppables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var out []*Service
|
||||
for _, svc := range c.services.services {
|
||||
c.services.Each(func(_ string, svc *Service) {
|
||||
if svc.OnStop != nil {
|
||||
out = append(out, svc)
|
||||
}
|
||||
}
|
||||
})
|
||||
return Result{out, true}
|
||||
}
|
||||
|
|
|
|||
18
lock_example_test.go
Normal file
18
lock_example_test.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleCore_Lock() {
|
||||
c := New()
|
||||
lock := c.Lock("drain")
|
||||
lock.Mutex.Lock()
|
||||
Println("locked")
|
||||
lock.Mutex.Unlock()
|
||||
Println("unlocked")
|
||||
// Output:
|
||||
// locked
|
||||
// unlocked
|
||||
}
|
||||
18
lock_test.go
18
lock_test.go
|
|
@ -8,28 +8,28 @@ import (
|
|||
)
|
||||
|
||||
func TestLock_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
lock := c.Lock("test")
|
||||
assert.NotNil(t, lock)
|
||||
assert.NotNil(t, lock.Mutex)
|
||||
}
|
||||
|
||||
func TestLock_SameName_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
l1 := c.Lock("shared")
|
||||
l2 := c.Lock("shared")
|
||||
assert.Equal(t, l1, l2)
|
||||
}
|
||||
|
||||
func TestLock_DifferentName_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
l1 := c.Lock("a")
|
||||
l2 := c.Lock("b")
|
||||
assert.NotEqual(t, l1, l2)
|
||||
}
|
||||
|
||||
func TestLockEnable_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestLock_LockEnable_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("early", Service{})
|
||||
c.LockEnable()
|
||||
c.LockApply()
|
||||
|
|
@ -38,16 +38,16 @@ func TestLockEnable_Good(t *testing.T) {
|
|||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestStartables_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestLock_Startables_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }})
|
||||
r := c.Startables()
|
||||
assert.True(t, r.OK)
|
||||
assert.Len(t, r.Value.([]*Service), 1)
|
||||
}
|
||||
|
||||
func TestStoppables_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestLock_Stoppables_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }})
|
||||
r := c.Stoppables()
|
||||
assert.True(t, r.OK)
|
||||
|
|
|
|||
15
log_example_test.go
Normal file
15
log_example_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package core_test
|
||||
|
||||
import . "dappco.re/go/core"
|
||||
|
||||
func ExampleInfo() {
|
||||
Info("server started", "port", 8080)
|
||||
}
|
||||
|
||||
func ExampleWarn() {
|
||||
Warn("deprecated", "feature", "old-api")
|
||||
}
|
||||
|
||||
func ExampleSecurity() {
|
||||
Security("access denied", "user", "unknown", "action", "admin.nuke")
|
||||
}
|
||||
13
log_test.go
13
log_test.go
|
|
@ -1,7 +1,6 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -54,7 +53,7 @@ func TestLog_LevelString_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLog_CoreLog_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
assert.NotNil(t, c.Log())
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ func TestLog_Username_Good(t *testing.T) {
|
|||
|
||||
// --- LogErr ---
|
||||
|
||||
func TestLogErr_Good(t *testing.T) {
|
||||
func TestLog_LogErr_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
le := NewLogErr(l)
|
||||
assert.NotNil(t, le)
|
||||
|
|
@ -114,7 +113,7 @@ func TestLogErr_Good(t *testing.T) {
|
|||
le.Log(err)
|
||||
}
|
||||
|
||||
func TestLogErr_Nil_Good(t *testing.T) {
|
||||
func TestLog_LogErr_Nil_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
le := NewLogErr(l)
|
||||
le.Log(nil) // should not panic
|
||||
|
|
@ -122,13 +121,13 @@ func TestLogErr_Nil_Good(t *testing.T) {
|
|||
|
||||
// --- LogPanic ---
|
||||
|
||||
func TestLogPanic_Good(t *testing.T) {
|
||||
func TestLog_LogPanic_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
lp := NewLogPanic(l)
|
||||
assert.NotNil(t, lp)
|
||||
}
|
||||
|
||||
func TestLogPanic_Recover_Good(t *testing.T) {
|
||||
func TestLog_LogPanic_Recover_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
lp := NewLogPanic(l)
|
||||
assert.NotPanics(t, func() {
|
||||
|
|
@ -141,7 +140,7 @@ func TestLogPanic_Recover_Good(t *testing.T) {
|
|||
|
||||
func TestLog_SetOutput_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
l.SetOutput(os.Stderr)
|
||||
l.SetOutput(NewBuilder())
|
||||
l.Info("redirected")
|
||||
}
|
||||
|
||||
|
|
|
|||
141
options.go
141
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,49 @@ 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}
|
||||
// New adapts Go (value, error) pairs into a Result.
|
||||
//
|
||||
// r := core.Result{}.New(file, err)
|
||||
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
|
||||
}
|
||||
|
||||
// Get returns the Result if OK, empty Result otherwise.
|
||||
//
|
||||
// r := core.Result{Value: "hello", OK: true}.Get()
|
||||
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 +89,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 +183,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 TestOptions_NewOptions_Good(t *testing.T) {
|
||||
opts := NewOptions(
|
||||
Option{Key: "name", Value: "brain"},
|
||||
Option{Key: "port", Value: 8080},
|
||||
)
|
||||
assert.Equal(t, 2, opts.Len())
|
||||
}
|
||||
|
||||
func TestOptions_NewOptions_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 TestOptions_Result_New_Good(t *testing.T) {
|
||||
r := Result{}.New("value")
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestOptions_Result_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 TestOptions_Result_Result_Good(t *testing.T) {
|
||||
r := Result{Value: "hello", OK: true}
|
||||
assert.Equal(t, r, r.Result())
|
||||
}
|
||||
|
||||
func TestOptions_Result_Result_WithArgs_Good(t *testing.T) {
|
||||
r := Result{}.Result("value")
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestOptions_Result_Get_Good(t *testing.T) {
|
||||
r := Result{Value: "hello", OK: true}
|
||||
assert.True(t, r.Get().OK)
|
||||
}
|
||||
|
||||
func TestOptions_Result_Get_Bad(t *testing.T) {
|
||||
r := Result{Value: "err", OK: false}
|
||||
assert.False(t, r.Get().OK)
|
||||
}
|
||||
|
||||
// --- WithOption ---
|
||||
|
||||
func TestOptions_WithOption_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"))
|
||||
}
|
||||
|
|
|
|||
37
path_example_test.go
Normal file
37
path_example_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleJoinPath() {
|
||||
Println(JoinPath("deploy", "to", "homelab"))
|
||||
// Output: deploy/to/homelab
|
||||
}
|
||||
|
||||
func ExamplePathBase() {
|
||||
Println(PathBase("/srv/workspaces/alpha"))
|
||||
// Output: alpha
|
||||
}
|
||||
|
||||
func ExamplePathDir() {
|
||||
Println(PathDir("/srv/workspaces/alpha"))
|
||||
// Output: /srv/workspaces
|
||||
}
|
||||
|
||||
func ExamplePathExt() {
|
||||
Println(PathExt("report.pdf"))
|
||||
// Output: .pdf
|
||||
}
|
||||
|
||||
func ExampleCleanPath() {
|
||||
Println(CleanPath("/tmp//file", "/"))
|
||||
Println(CleanPath("a/b/../c", "/"))
|
||||
Println(CleanPath("deploy/to/homelab", "/"))
|
||||
// Output:
|
||||
// /tmp/file
|
||||
// a/c
|
||||
// deploy/to/homelab
|
||||
}
|
||||
|
||||
49
path_test.go
49
path_test.go
|
|
@ -3,18 +3,15 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPath_Relative(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
home := core.Env("DIR_HOME")
|
||||
|
||||
ds := core.Env("DS")
|
||||
assert.Equal(t, home+ds+"Code"+ds+".core", core.Path("Code", ".core"))
|
||||
}
|
||||
|
|
@ -25,14 +22,14 @@ func TestPath_Absolute(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPath_Empty(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
home := core.Env("DIR_HOME")
|
||||
|
||||
assert.Equal(t, home, core.Path())
|
||||
}
|
||||
|
||||
func TestPath_Cleans(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
home := core.Env("DIR_HOME")
|
||||
|
||||
assert.Equal(t, home+core.Env("DS")+"Code", core.Path("Code", "sub", ".."))
|
||||
}
|
||||
|
||||
|
|
@ -41,32 +38,32 @@ func TestPath_CleanDoubleSlash(t *testing.T) {
|
|||
assert.Equal(t, ds+"tmp"+ds+"file", core.Path("/tmp//file"))
|
||||
}
|
||||
|
||||
func TestPathBase(t *testing.T) {
|
||||
func TestPath_PathBase(t *testing.T) {
|
||||
assert.Equal(t, "core", core.PathBase("/Users/snider/Code/core"))
|
||||
assert.Equal(t, "homelab", core.PathBase("deploy/to/homelab"))
|
||||
}
|
||||
|
||||
func TestPathBase_Root(t *testing.T) {
|
||||
func TestPath_PathBase_Root(t *testing.T) {
|
||||
assert.Equal(t, "/", core.PathBase("/"))
|
||||
}
|
||||
|
||||
func TestPathBase_Empty(t *testing.T) {
|
||||
func TestPath_PathBase_Empty(t *testing.T) {
|
||||
assert.Equal(t, ".", core.PathBase(""))
|
||||
}
|
||||
|
||||
func TestPathDir(t *testing.T) {
|
||||
func TestPath_PathDir(t *testing.T) {
|
||||
assert.Equal(t, "/Users/snider/Code", core.PathDir("/Users/snider/Code/core"))
|
||||
}
|
||||
|
||||
func TestPathDir_Root(t *testing.T) {
|
||||
func TestPath_PathDir_Root(t *testing.T) {
|
||||
assert.Equal(t, "/", core.PathDir("/file"))
|
||||
}
|
||||
|
||||
func TestPathDir_NoDir(t *testing.T) {
|
||||
func TestPath_PathDir_NoDir(t *testing.T) {
|
||||
assert.Equal(t, ".", core.PathDir("file.go"))
|
||||
}
|
||||
|
||||
func TestPathExt(t *testing.T) {
|
||||
func TestPath_PathExt(t *testing.T) {
|
||||
assert.Equal(t, ".go", core.PathExt("main.go"))
|
||||
assert.Equal(t, "", core.PathExt("Makefile"))
|
||||
assert.Equal(t, ".gz", core.PathExt("archive.tar.gz"))
|
||||
|
|
@ -76,36 +73,38 @@ func TestPath_EnvConsistency(t *testing.T) {
|
|||
assert.Equal(t, core.Env("DIR_HOME"), core.Path())
|
||||
}
|
||||
|
||||
func TestPathGlob_Good(t *testing.T) {
|
||||
func TestPath_PathGlob_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "c.log"), []byte("c"), 0644)
|
||||
f := (&core.Fs{}).New("/")
|
||||
f.Write(core.Path(dir, "a.txt"), "a")
|
||||
f.Write(core.Path(dir, "b.txt"), "b")
|
||||
f.Write(core.Path(dir, "c.log"), "c")
|
||||
|
||||
matches := core.PathGlob(filepath.Join(dir, "*.txt"))
|
||||
matches := core.PathGlob(core.Path(dir, "*.txt"))
|
||||
assert.Len(t, matches, 2)
|
||||
}
|
||||
|
||||
func TestPathGlob_NoMatch(t *testing.T) {
|
||||
func TestPath_PathGlob_NoMatch(t *testing.T) {
|
||||
matches := core.PathGlob("/nonexistent/pattern-*.xyz")
|
||||
assert.Empty(t, matches)
|
||||
}
|
||||
|
||||
func TestPathIsAbs_Good(t *testing.T) {
|
||||
func TestPath_PathIsAbs_Good(t *testing.T) {
|
||||
assert.True(t, core.PathIsAbs("/tmp"))
|
||||
assert.True(t, core.PathIsAbs("/"))
|
||||
assert.False(t, core.PathIsAbs("relative"))
|
||||
assert.False(t, core.PathIsAbs(""))
|
||||
}
|
||||
|
||||
func TestCleanPath_Good(t *testing.T) {
|
||||
func TestPath_CleanPath_Good(t *testing.T) {
|
||||
assert.Equal(t, "/a/b", core.CleanPath("/a//b", "/"))
|
||||
assert.Equal(t, "/a/c", core.CleanPath("/a/b/../c", "/"))
|
||||
assert.Equal(t, "/", core.CleanPath("/", "/"))
|
||||
assert.Equal(t, ".", core.CleanPath("", "/"))
|
||||
}
|
||||
|
||||
func TestPathDir_TrailingSlash(t *testing.T) {
|
||||
func TestPath_PathDir_TrailingSlash(t *testing.T) {
|
||||
result := core.PathDir("/Users/snider/Code/")
|
||||
assert.Equal(t, "/Users/snider/Code", result)
|
||||
}
|
||||
|
||||
|
|
|
|||
96
process.go
Normal file
96
process.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Process is the Core primitive for managed execution.
|
||||
// Methods emit via named Actions — actual execution is handled by
|
||||
// whichever service registers the "process.*" actions (typically go-process).
|
||||
//
|
||||
// If go-process is NOT registered, all methods return Result{OK: false}.
|
||||
// This is permission-by-registration: no handler = no capability.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r := c.Process().Run(ctx, "git", "log", "--oneline")
|
||||
// if r.OK { output := r.Value.(string) }
|
||||
//
|
||||
// r := c.Process().RunIn(ctx, "/path/to/repo", "go", "test", "./...")
|
||||
//
|
||||
// Permission model:
|
||||
//
|
||||
// // Full Core — process registered:
|
||||
// c := core.New(core.WithService(process.Register))
|
||||
// c.Process().Run(ctx, "git", "log") // works
|
||||
//
|
||||
// // Sandboxed Core — no process:
|
||||
// c := core.New()
|
||||
// c.Process().Run(ctx, "git", "log") // Result{OK: false}
|
||||
package core
|
||||
|
||||
import "context"
|
||||
|
||||
// Process is the Core primitive for process management.
|
||||
// Zero dependencies — delegates to named Actions.
|
||||
type Process struct {
|
||||
core *Core
|
||||
}
|
||||
|
||||
// Process returns the process management primitive.
|
||||
//
|
||||
// c.Process().Run(ctx, "git", "log")
|
||||
func (c *Core) Process() *Process {
|
||||
return &Process{core: c}
|
||||
}
|
||||
|
||||
// Run executes a command synchronously and returns the output.
|
||||
//
|
||||
// r := c.Process().Run(ctx, "git", "log", "--oneline")
|
||||
// if r.OK { output := r.Value.(string) }
|
||||
func (p *Process) Run(ctx context.Context, command string, args ...string) Result {
|
||||
return p.core.Action("process.run").Run(ctx, NewOptions(
|
||||
Option{Key: "command", Value: command},
|
||||
Option{Key: "args", Value: args},
|
||||
))
|
||||
}
|
||||
|
||||
// RunIn executes a command in a specific directory.
|
||||
//
|
||||
// r := c.Process().RunIn(ctx, "/repo", "go", "test", "./...")
|
||||
func (p *Process) RunIn(ctx context.Context, dir string, command string, args ...string) Result {
|
||||
return p.core.Action("process.run").Run(ctx, NewOptions(
|
||||
Option{Key: "command", Value: command},
|
||||
Option{Key: "args", Value: args},
|
||||
Option{Key: "dir", Value: dir},
|
||||
))
|
||||
}
|
||||
|
||||
// RunWithEnv executes with additional environment variables.
|
||||
//
|
||||
// r := c.Process().RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test")
|
||||
func (p *Process) RunWithEnv(ctx context.Context, dir string, env []string, command string, args ...string) Result {
|
||||
return p.core.Action("process.run").Run(ctx, NewOptions(
|
||||
Option{Key: "command", Value: command},
|
||||
Option{Key: "args", Value: args},
|
||||
Option{Key: "dir", Value: dir},
|
||||
Option{Key: "env", Value: env},
|
||||
))
|
||||
}
|
||||
|
||||
// Start spawns a detached/background process.
|
||||
//
|
||||
// r := c.Process().Start(ctx, ProcessStartOptions{Command: "docker", Args: []string{"run", "..."}})
|
||||
func (p *Process) Start(ctx context.Context, opts Options) Result {
|
||||
return p.core.Action("process.start").Run(ctx, opts)
|
||||
}
|
||||
|
||||
// Kill terminates a managed process by ID or PID.
|
||||
//
|
||||
// c.Process().Kill(ctx, core.NewOptions(core.Option{Key: "id", Value: processID}))
|
||||
func (p *Process) Kill(ctx context.Context, opts Options) Result {
|
||||
return p.core.Action("process.kill").Run(ctx, opts)
|
||||
}
|
||||
|
||||
// Exists returns true if any process execution capability is registered.
|
||||
//
|
||||
// if c.Process().Exists() { /* can run commands */ }
|
||||
func (p *Process) Exists() bool {
|
||||
return p.core.Action("process.run").Exists()
|
||||
}
|
||||
144
process_test.go
Normal file
144
process_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Process.Run ---
|
||||
|
||||
func TestProcess_Run_Good(t *testing.T) {
|
||||
c := New()
|
||||
// Register a mock process handler
|
||||
c.Action("process.run", func(_ context.Context, opts Options) Result {
|
||||
cmd := opts.String("command")
|
||||
return Result{Value: Concat("output of ", cmd), OK: true}
|
||||
})
|
||||
|
||||
r := c.Process().Run(context.Background(), "git", "log")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "output of git", r.Value)
|
||||
}
|
||||
|
||||
func TestProcess_Run_Bad_NotRegistered(t *testing.T) {
|
||||
c := New()
|
||||
// No process service registered — sandboxed Core
|
||||
r := c.Process().Run(context.Background(), "git", "log")
|
||||
assert.False(t, r.OK, "sandboxed Core must not execute commands")
|
||||
}
|
||||
|
||||
func TestProcess_Run_Ugly_HandlerPanics(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.run", func(_ context.Context, _ Options) Result {
|
||||
panic("segfault")
|
||||
})
|
||||
r := c.Process().Run(context.Background(), "test")
|
||||
assert.False(t, r.OK, "panicking handler must not crash")
|
||||
}
|
||||
|
||||
// --- Process.RunIn ---
|
||||
|
||||
func TestProcess_RunIn_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.run", func(_ context.Context, opts Options) Result {
|
||||
dir := opts.String("dir")
|
||||
cmd := opts.String("command")
|
||||
return Result{Value: Concat(cmd, " in ", dir), OK: true}
|
||||
})
|
||||
|
||||
r := c.Process().RunIn(context.Background(), "/repo", "go", "test")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "go in /repo", r.Value)
|
||||
}
|
||||
|
||||
// --- Process.RunWithEnv ---
|
||||
|
||||
func TestProcess_RunWithEnv_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.run", func(_ context.Context, opts Options) Result {
|
||||
r := opts.Get("env")
|
||||
if !r.OK {
|
||||
return Result{Value: "no env", OK: true}
|
||||
}
|
||||
env := r.Value.([]string)
|
||||
return Result{Value: env[0], OK: true}
|
||||
})
|
||||
|
||||
r := c.Process().RunWithEnv(context.Background(), "/repo", []string{"GOWORK=off"}, "go", "test")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "GOWORK=off", r.Value)
|
||||
}
|
||||
|
||||
// --- Process.Start ---
|
||||
|
||||
func TestProcess_Start_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.start", func(_ context.Context, opts Options) Result {
|
||||
return Result{Value: "proc-1", OK: true}
|
||||
})
|
||||
|
||||
r := c.Process().Start(context.Background(), NewOptions(
|
||||
Option{Key: "command", Value: "docker"},
|
||||
Option{Key: "args", Value: []string{"run", "nginx"}},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "proc-1", r.Value)
|
||||
}
|
||||
|
||||
func TestProcess_Start_Bad_NotRegistered(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Process().Start(context.Background(), NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Process.Kill ---
|
||||
|
||||
func TestProcess_Kill_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("process.kill", func(_ context.Context, opts Options) Result {
|
||||
return Result{OK: true}
|
||||
})
|
||||
|
||||
r := c.Process().Kill(context.Background(), NewOptions(
|
||||
Option{Key: "id", Value: "proc-1"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Process.Exists ---
|
||||
|
||||
func TestProcess_Exists_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.False(t, c.Process().Exists(), "no process service = no capability")
|
||||
|
||||
c.Action("process.run", func(_ context.Context, _ Options) Result {
|
||||
return Result{OK: true}
|
||||
})
|
||||
assert.True(t, c.Process().Exists(), "process.run registered = capability exists")
|
||||
}
|
||||
|
||||
// --- Permission model ---
|
||||
|
||||
func TestProcess_Ugly_PermissionByRegistration(t *testing.T) {
|
||||
// Full Core
|
||||
full := New()
|
||||
full.Action("process.run", func(_ context.Context, opts Options) Result {
|
||||
return Result{Value: Concat("executed ", opts.String("command")), OK: true}
|
||||
})
|
||||
|
||||
// Sandboxed Core
|
||||
sandboxed := New()
|
||||
|
||||
// Full can execute
|
||||
assert.True(t, full.Process().Exists())
|
||||
r := full.Process().Run(context.Background(), "whoami")
|
||||
assert.True(t, r.OK)
|
||||
|
||||
// Sandboxed cannot
|
||||
assert.False(t, sandboxed.Process().Exists())
|
||||
r = sandboxed.Process().Run(context.Background(), "whoami")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
271
registry.go
Normal file
271
registry.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Thread-safe named collection primitive for the Core framework.
|
||||
// Registry[T] is the universal brick — all named registries (services,
|
||||
// commands, actions, drives, data) embed this type.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r := core.NewRegistry[*MyService]()
|
||||
// r.Set("brain", brainSvc)
|
||||
// r.Get("brain") // Result{brainSvc, true}
|
||||
// r.Has("brain") // true
|
||||
// r.Names() // []string{"brain"} (insertion order)
|
||||
// r.Each(func(name string, svc *MyService) { ... })
|
||||
// r.Lock() // fully frozen — no more writes
|
||||
// r.Seal() // no new keys, updates to existing OK
|
||||
//
|
||||
// Three lock modes:
|
||||
//
|
||||
// Open (default) — anything goes
|
||||
// Sealed — no new keys, existing keys CAN be updated
|
||||
// Locked — fully frozen, no writes at all
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// registryMode controls write behaviour.
|
||||
type registryMode int
|
||||
|
||||
const (
|
||||
registryOpen registryMode = iota // anything goes
|
||||
registrySealed // update existing, no new keys
|
||||
registryLocked // fully frozen
|
||||
)
|
||||
|
||||
// Registry is a thread-safe named collection. The universal brick
|
||||
// for all named registries in Core.
|
||||
//
|
||||
// r := core.NewRegistry[*Service]()
|
||||
// r.Set("brain", svc)
|
||||
// if r.Has("brain") { ... }
|
||||
type Registry[T any] struct {
|
||||
items map[string]T
|
||||
disabled map[string]bool
|
||||
order []string // insertion order
|
||||
mu sync.RWMutex
|
||||
mode registryMode
|
||||
}
|
||||
|
||||
// NewRegistry creates an empty registry in Open mode.
|
||||
//
|
||||
// r := core.NewRegistry[*Service]()
|
||||
func NewRegistry[T any]() *Registry[T] {
|
||||
return &Registry[T]{
|
||||
items: make(map[string]T),
|
||||
disabled: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Set registers an item by name. Returns Result{OK: false} if the
|
||||
// registry is locked, or if sealed and the key doesn't already exist.
|
||||
//
|
||||
// r.Set("brain", brainSvc)
|
||||
func (r *Registry[T]) Set(name string, item T) Result {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
switch r.mode {
|
||||
case registryLocked:
|
||||
return Result{E("registry.Set", Concat("registry is locked, cannot set: ", name), nil), false}
|
||||
case registrySealed:
|
||||
if _, exists := r.items[name]; !exists {
|
||||
return Result{E("registry.Set", Concat("registry is sealed, cannot add new key: ", name), nil), false}
|
||||
}
|
||||
}
|
||||
|
||||
if _, exists := r.items[name]; !exists {
|
||||
r.order = append(r.order, name)
|
||||
}
|
||||
r.items[name] = item
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Get retrieves an item by name.
|
||||
//
|
||||
// res := r.Get("brain")
|
||||
// if res.OK { svc := res.Value.(*Service) }
|
||||
func (r *Registry[T]) Get(name string) Result {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
item, ok := r.items[name]
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
return Result{item, true}
|
||||
}
|
||||
|
||||
// Has returns true if the name exists in the registry.
|
||||
//
|
||||
// if r.Has("brain") { ... }
|
||||
func (r *Registry[T]) Has(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
_, ok := r.items[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Names returns all registered names in insertion order.
|
||||
//
|
||||
// names := r.Names() // ["brain", "monitor", "process"]
|
||||
func (r *Registry[T]) Names() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
out := make([]string, len(r.order))
|
||||
copy(out, r.order)
|
||||
return out
|
||||
}
|
||||
|
||||
// List returns items whose names match the glob pattern.
|
||||
// Uses filepath.Match semantics: "*" matches any sequence, "?" matches one char.
|
||||
//
|
||||
// services := r.List("process.*")
|
||||
func (r *Registry[T]) List(pattern string) []T {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []T
|
||||
for _, name := range r.order {
|
||||
if matched, _ := filepath.Match(pattern, name); matched {
|
||||
if !r.disabled[name] {
|
||||
result = append(result, r.items[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Each iterates over all items in insertion order, calling fn for each.
|
||||
// Disabled items are skipped.
|
||||
//
|
||||
// r.Each(func(name string, svc *Service) {
|
||||
// fmt.Println(name, svc)
|
||||
// })
|
||||
func (r *Registry[T]) Each(fn func(string, T)) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, name := range r.order {
|
||||
if !r.disabled[name] {
|
||||
fn(name, r.items[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of registered items (including disabled).
|
||||
//
|
||||
// count := r.Len()
|
||||
func (r *Registry[T]) Len() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.items)
|
||||
}
|
||||
|
||||
// Delete removes an item. Returns Result{OK: false} if locked or not found.
|
||||
//
|
||||
// r.Delete("old-service")
|
||||
func (r *Registry[T]) Delete(name string) Result {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.mode == registryLocked {
|
||||
return Result{E("registry.Delete", Concat("registry is locked, cannot delete: ", name), nil), false}
|
||||
}
|
||||
if _, exists := r.items[name]; !exists {
|
||||
return Result{E("registry.Delete", Concat("not found: ", name), nil), false}
|
||||
}
|
||||
|
||||
delete(r.items, name)
|
||||
delete(r.disabled, name)
|
||||
// Remove from order slice
|
||||
for i, n := range r.order {
|
||||
if n == name {
|
||||
r.order = append(r.order[:i], r.order[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Disable soft-disables an item. It still exists but Each/List skip it.
|
||||
// Returns Result{OK: false} if not found.
|
||||
//
|
||||
// r.Disable("broken-handler")
|
||||
func (r *Registry[T]) Disable(name string) Result {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.items[name]; !exists {
|
||||
return Result{E("registry.Disable", Concat("not found: ", name), nil), false}
|
||||
}
|
||||
r.disabled[name] = true
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Enable re-enables a disabled item.
|
||||
//
|
||||
// r.Enable("fixed-handler")
|
||||
func (r *Registry[T]) Enable(name string) Result {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.items[name]; !exists {
|
||||
return Result{E("registry.Enable", Concat("not found: ", name), nil), false}
|
||||
}
|
||||
delete(r.disabled, name)
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Disabled returns true if the item is soft-disabled.
|
||||
func (r *Registry[T]) Disabled(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.disabled[name]
|
||||
}
|
||||
|
||||
// Lock fully freezes the registry. No Set, no Delete.
|
||||
//
|
||||
// r.Lock() // after startup, prevent late registration
|
||||
func (r *Registry[T]) Lock() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.mode = registryLocked
|
||||
}
|
||||
|
||||
// Locked returns true if the registry is fully frozen.
|
||||
func (r *Registry[T]) Locked() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.mode == registryLocked
|
||||
}
|
||||
|
||||
// Seal prevents new keys but allows updates to existing keys.
|
||||
// Use for hot-reload: shape is fixed, implementations can change.
|
||||
//
|
||||
// r.Seal() // no new capabilities, but handlers can be swapped
|
||||
func (r *Registry[T]) Seal() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.mode = registrySealed
|
||||
}
|
||||
|
||||
// Sealed returns true if the registry is sealed (no new keys).
|
||||
func (r *Registry[T]) Sealed() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.mode == registrySealed
|
||||
}
|
||||
|
||||
// Open resets the registry to open mode (default).
|
||||
//
|
||||
// r.Open() // re-enable writes for testing
|
||||
func (r *Registry[T]) Open() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.mode = registryOpen
|
||||
}
|
||||
70
registry_example_test.go
Normal file
70
registry_example_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleRegistry_Set() {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Set("bravo", "second")
|
||||
Println(r.Get("alpha").Value)
|
||||
// Output: first
|
||||
}
|
||||
|
||||
func ExampleRegistry_Names() {
|
||||
r := NewRegistry[int]()
|
||||
r.Set("charlie", 3)
|
||||
r.Set("alpha", 1)
|
||||
r.Set("bravo", 2)
|
||||
Println(r.Names())
|
||||
// Output: [charlie alpha bravo]
|
||||
}
|
||||
|
||||
func ExampleRegistry_List() {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("process.run", "run")
|
||||
r.Set("process.kill", "kill")
|
||||
r.Set("brain.recall", "recall")
|
||||
|
||||
items := r.List("process.*")
|
||||
Println(len(items))
|
||||
// Output: 2
|
||||
}
|
||||
|
||||
func ExampleRegistry_Each() {
|
||||
r := NewRegistry[int]()
|
||||
r.Set("a", 1)
|
||||
r.Set("b", 2)
|
||||
r.Set("c", 3)
|
||||
|
||||
sum := 0
|
||||
r.Each(func(_ string, v int) { sum += v })
|
||||
Println(sum)
|
||||
// Output: 6
|
||||
}
|
||||
|
||||
func ExampleRegistry_Disable() {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Set("bravo", "second")
|
||||
r.Disable("alpha")
|
||||
|
||||
var names []string
|
||||
r.Each(func(name string, _ string) { names = append(names, name) })
|
||||
Println(names)
|
||||
// Output: [bravo]
|
||||
}
|
||||
|
||||
func ExampleRegistry_Delete() {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("temp", "value")
|
||||
Println(r.Has("temp"))
|
||||
|
||||
r.Delete("temp")
|
||||
Println(r.Has("temp"))
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
}
|
||||
387
registry_test.go
Normal file
387
registry_test.go
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Set ---
|
||||
|
||||
func TestRegistry_Set_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
res := r.Set("alpha", "first")
|
||||
assert.True(t, res.OK)
|
||||
assert.True(t, r.Has("alpha"))
|
||||
}
|
||||
|
||||
func TestRegistry_Set_Good_Update(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Set("alpha", "second")
|
||||
res := r.Get("alpha")
|
||||
assert.Equal(t, "second", res.Value)
|
||||
assert.Equal(t, 1, r.Len(), "update should not increase count")
|
||||
}
|
||||
|
||||
func TestRegistry_Set_Bad_Locked(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Lock()
|
||||
res := r.Set("beta", "second")
|
||||
assert.False(t, res.OK)
|
||||
}
|
||||
|
||||
func TestRegistry_Set_Bad_SealedNewKey(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Seal()
|
||||
res := r.Set("beta", "new")
|
||||
assert.False(t, res.OK, "sealed registry must reject new keys")
|
||||
}
|
||||
|
||||
func TestRegistry_Set_Good_SealedExistingKey(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Seal()
|
||||
res := r.Set("alpha", "updated")
|
||||
assert.True(t, res.OK, "sealed registry must allow updates to existing keys")
|
||||
assert.Equal(t, "updated", r.Get("alpha").Value)
|
||||
}
|
||||
|
||||
func TestRegistry_Set_Ugly_ConcurrentWrites(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
r.Set(Sprintf("key-%d", n), n)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, 100, r.Len())
|
||||
}
|
||||
|
||||
// --- Get ---
|
||||
|
||||
func TestRegistry_Get_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
res := r.Get("alpha")
|
||||
assert.True(t, res.OK)
|
||||
assert.Equal(t, "value", res.Value)
|
||||
}
|
||||
|
||||
func TestRegistry_Get_Bad_NotFound(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
res := r.Get("missing")
|
||||
assert.False(t, res.OK)
|
||||
}
|
||||
|
||||
func TestRegistry_Get_Ugly_EmptyKey(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("", "empty-key")
|
||||
res := r.Get("")
|
||||
assert.True(t, res.OK, "empty string is a valid key")
|
||||
assert.Equal(t, "empty-key", res.Value)
|
||||
}
|
||||
|
||||
// --- Has ---
|
||||
|
||||
func TestRegistry_Has_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
assert.True(t, r.Has("alpha"))
|
||||
}
|
||||
|
||||
func TestRegistry_Has_Bad_NotFound(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
assert.False(t, r.Has("missing"))
|
||||
}
|
||||
|
||||
func TestRegistry_Has_Ugly_AfterDelete(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
r.Delete("alpha")
|
||||
assert.False(t, r.Has("alpha"))
|
||||
}
|
||||
|
||||
// --- Names ---
|
||||
|
||||
func TestRegistry_Names_Good(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
r.Set("charlie", 3)
|
||||
r.Set("alpha", 1)
|
||||
r.Set("bravo", 2)
|
||||
assert.Equal(t, []string{"charlie", "alpha", "bravo"}, r.Names(), "must preserve insertion order")
|
||||
}
|
||||
|
||||
func TestRegistry_Names_Bad_Empty(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
assert.Empty(t, r.Names())
|
||||
}
|
||||
|
||||
func TestRegistry_Names_Ugly_AfterDeleteAndReinsert(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
r.Set("a", 1)
|
||||
r.Set("b", 2)
|
||||
r.Set("c", 3)
|
||||
r.Delete("b")
|
||||
r.Set("d", 4)
|
||||
assert.Equal(t, []string{"a", "c", "d"}, r.Names())
|
||||
}
|
||||
|
||||
// --- Each ---
|
||||
|
||||
func TestRegistry_Each_Good(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
r.Set("a", 1)
|
||||
r.Set("b", 2)
|
||||
r.Set("c", 3)
|
||||
var names []string
|
||||
var sum int
|
||||
r.Each(func(name string, val int) {
|
||||
names = append(names, name)
|
||||
sum += val
|
||||
})
|
||||
assert.Equal(t, []string{"a", "b", "c"}, names)
|
||||
assert.Equal(t, 6, sum)
|
||||
}
|
||||
|
||||
func TestRegistry_Each_Bad_Empty(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
called := false
|
||||
r.Each(func(_ string, _ int) { called = true })
|
||||
assert.False(t, called)
|
||||
}
|
||||
|
||||
func TestRegistry_Each_Ugly_SkipsDisabled(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
r.Set("a", 1)
|
||||
r.Set("b", 2)
|
||||
r.Set("c", 3)
|
||||
r.Disable("b")
|
||||
var names []string
|
||||
r.Each(func(name string, _ int) { names = append(names, name) })
|
||||
assert.Equal(t, []string{"a", "c"}, names)
|
||||
}
|
||||
|
||||
// --- Len ---
|
||||
|
||||
func TestRegistry_Len_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
assert.Equal(t, 0, r.Len())
|
||||
r.Set("a", "1")
|
||||
assert.Equal(t, 1, r.Len())
|
||||
r.Set("b", "2")
|
||||
assert.Equal(t, 2, r.Len())
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
func TestRegistry_List_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("process.run", "run")
|
||||
r.Set("process.start", "start")
|
||||
r.Set("agentic.dispatch", "dispatch")
|
||||
items := r.List("process.*")
|
||||
assert.Len(t, items, 2)
|
||||
assert.Contains(t, items, "run")
|
||||
assert.Contains(t, items, "start")
|
||||
}
|
||||
|
||||
func TestRegistry_List_Bad_NoMatch(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "1")
|
||||
items := r.List("beta.*")
|
||||
assert.Empty(t, items)
|
||||
}
|
||||
|
||||
func TestRegistry_List_Ugly_SkipsDisabled(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("process.run", "run")
|
||||
r.Set("process.kill", "kill")
|
||||
r.Disable("process.kill")
|
||||
items := r.List("process.*")
|
||||
assert.Len(t, items, 1)
|
||||
assert.Equal(t, "run", items[0])
|
||||
}
|
||||
|
||||
func TestRegistry_List_Good_WildcardAll(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("a", "1")
|
||||
r.Set("b", "2")
|
||||
items := r.List("*")
|
||||
assert.Len(t, items, 2)
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
func TestRegistry_Delete_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
res := r.Delete("alpha")
|
||||
assert.True(t, res.OK)
|
||||
assert.False(t, r.Has("alpha"))
|
||||
assert.Equal(t, 0, r.Len())
|
||||
}
|
||||
|
||||
func TestRegistry_Delete_Bad_NotFound(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
res := r.Delete("missing")
|
||||
assert.False(t, res.OK)
|
||||
}
|
||||
|
||||
func TestRegistry_Delete_Ugly_Locked(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
r.Lock()
|
||||
res := r.Delete("alpha")
|
||||
assert.False(t, res.OK, "locked registry must reject delete")
|
||||
assert.True(t, r.Has("alpha"), "item must survive failed delete")
|
||||
}
|
||||
|
||||
// --- Disable / Enable ---
|
||||
|
||||
func TestRegistry_Disable_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
res := r.Disable("alpha")
|
||||
assert.True(t, res.OK)
|
||||
assert.True(t, r.Disabled("alpha"))
|
||||
// Still exists via Get/Has
|
||||
assert.True(t, r.Has("alpha"))
|
||||
assert.True(t, r.Get("alpha").OK)
|
||||
}
|
||||
|
||||
func TestRegistry_Disable_Bad_NotFound(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
res := r.Disable("missing")
|
||||
assert.False(t, res.OK)
|
||||
}
|
||||
|
||||
func TestRegistry_Disable_Ugly_EnableRoundtrip(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
r.Disable("alpha")
|
||||
assert.True(t, r.Disabled("alpha"))
|
||||
|
||||
res := r.Enable("alpha")
|
||||
assert.True(t, res.OK)
|
||||
assert.False(t, r.Disabled("alpha"))
|
||||
|
||||
// Verify Each sees it again
|
||||
var seen []string
|
||||
r.Each(func(name string, _ string) { seen = append(seen, name) })
|
||||
assert.Equal(t, []string{"alpha"}, seen)
|
||||
}
|
||||
|
||||
func TestRegistry_Enable_Bad_NotFound(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
res := r.Enable("missing")
|
||||
assert.False(t, res.OK)
|
||||
}
|
||||
|
||||
// --- Lock ---
|
||||
|
||||
func TestRegistry_Lock_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
r.Lock()
|
||||
assert.True(t, r.Locked())
|
||||
// Reads still work
|
||||
assert.True(t, r.Get("alpha").OK)
|
||||
assert.True(t, r.Has("alpha"))
|
||||
}
|
||||
|
||||
func TestRegistry_Lock_Bad_SetAfterLock(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Lock()
|
||||
res := r.Set("new", "value")
|
||||
assert.False(t, res.OK)
|
||||
}
|
||||
|
||||
func TestRegistry_Lock_Ugly_UpdateAfterLock(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Lock()
|
||||
res := r.Set("alpha", "second")
|
||||
assert.False(t, res.OK, "locked registry must reject even updates")
|
||||
assert.Equal(t, "first", r.Get("alpha").Value, "value must not change")
|
||||
}
|
||||
|
||||
// --- Seal ---
|
||||
|
||||
func TestRegistry_Seal_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "first")
|
||||
r.Seal()
|
||||
assert.True(t, r.Sealed())
|
||||
// Update existing OK
|
||||
res := r.Set("alpha", "second")
|
||||
assert.True(t, res.OK)
|
||||
assert.Equal(t, "second", r.Get("alpha").Value)
|
||||
}
|
||||
|
||||
func TestRegistry_Seal_Bad_NewKey(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Seal()
|
||||
res := r.Set("new", "value")
|
||||
assert.False(t, res.OK)
|
||||
}
|
||||
|
||||
func TestRegistry_Seal_Ugly_DeleteWhileSealed(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Set("alpha", "value")
|
||||
r.Seal()
|
||||
// Delete is NOT locked by seal — only Set for new keys
|
||||
res := r.Delete("alpha")
|
||||
assert.True(t, res.OK, "seal blocks new keys, not deletes")
|
||||
}
|
||||
|
||||
// --- Open ---
|
||||
|
||||
func TestRegistry_Open_Good(t *testing.T) {
|
||||
r := NewRegistry[string]()
|
||||
r.Lock()
|
||||
assert.True(t, r.Locked())
|
||||
r.Open()
|
||||
assert.False(t, r.Locked())
|
||||
// Can write again
|
||||
res := r.Set("new", "value")
|
||||
assert.True(t, res.OK)
|
||||
}
|
||||
|
||||
// --- Concurrency ---
|
||||
|
||||
func TestRegistry_Ugly_ConcurrentReadWrite(t *testing.T) {
|
||||
r := NewRegistry[int]()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent writers
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
r.Set(Sprintf("w-%d", n), n)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent readers
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
r.Has(Sprintf("w-%d", n))
|
||||
r.Get(Sprintf("w-%d", n))
|
||||
r.Names()
|
||||
r.Len()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
assert.Equal(t, 50, r.Len())
|
||||
}
|
||||
25
runtime.go
25
runtime.go
|
|
@ -25,8 +25,19 @@ func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
|
|||
return &ServiceRuntime[T]{core: c, opts: opts}
|
||||
}
|
||||
|
||||
func (r *ServiceRuntime[T]) Core() *Core { return r.core }
|
||||
func (r *ServiceRuntime[T]) Options() T { return r.opts }
|
||||
// Core returns the Core instance this service is registered with.
|
||||
//
|
||||
// c := s.Core()
|
||||
func (r *ServiceRuntime[T]) Core() *Core { return r.core }
|
||||
|
||||
// Options returns the typed options this service was created with.
|
||||
//
|
||||
// opts := s.Options() // MyOptions{BufferSize: 1024, ...}
|
||||
func (r *ServiceRuntime[T]) Options() T { return r.opts }
|
||||
|
||||
// Config is a shortcut to s.Core().Config().
|
||||
//
|
||||
// host := s.Config().String("database.host")
|
||||
func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() }
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
|
@ -106,11 +117,7 @@ type ServiceFactory func() Result
|
|||
|
||||
// NewWithFactories creates a Runtime with the provided service factories.
|
||||
func NewWithFactories(app any, factories map[string]ServiceFactory) Result {
|
||||
r := New(WithOptions(Options{{Key: "name", Value: "core"}}))
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
c := r.Value.(*Core)
|
||||
c := New(WithOptions(NewOptions(Option{Key: "name", Value: "core"})))
|
||||
c.app.Runtime = app
|
||||
|
||||
names := slices.Sorted(maps.Keys(factories))
|
||||
|
|
@ -141,10 +148,14 @@ func NewRuntime(app any) Result {
|
|||
return NewWithFactories(app, map[string]ServiceFactory{})
|
||||
}
|
||||
|
||||
// ServiceName returns "Core" — the Runtime's service identity.
|
||||
func (r *Runtime) ServiceName() string { return "Core" }
|
||||
|
||||
// ServiceStartup starts all services via the embedded Core.
|
||||
func (r *Runtime) ServiceStartup(ctx context.Context, options any) Result {
|
||||
return r.Core.ServiceStartup(ctx, options)
|
||||
}
|
||||
// ServiceShutdown stops all services via the embedded Core.
|
||||
func (r *Runtime) ServiceShutdown(ctx context.Context) Result {
|
||||
if r.Core != nil {
|
||||
return r.Core.ServiceShutdown(ctx)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ type testOpts struct {
|
|||
Timeout int
|
||||
}
|
||||
|
||||
func TestServiceRuntime_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestRuntime_ServiceRuntime_Good(t *testing.T) {
|
||||
c := New()
|
||||
opts := testOpts{URL: "https://api.lthn.ai", Timeout: 30}
|
||||
rt := NewServiceRuntime(c, opts)
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ func TestServiceRuntime_Good(t *testing.T) {
|
|||
|
||||
// --- NewWithFactories ---
|
||||
|
||||
func TestNewWithFactories_Good(t *testing.T) {
|
||||
func TestRuntime_NewWithFactories_Good(t *testing.T) {
|
||||
r := NewWithFactories(nil, map[string]ServiceFactory{
|
||||
"svc1": func() Result { return Result{Value: Service{}, OK: true} },
|
||||
"svc2": func() Result { return Result{Value: Service{}, OK: true} },
|
||||
|
|
@ -38,14 +38,14 @@ func TestNewWithFactories_Good(t *testing.T) {
|
|||
assert.NotNil(t, rt.Core)
|
||||
}
|
||||
|
||||
func TestNewWithFactories_NilFactory_Good(t *testing.T) {
|
||||
func TestRuntime_NewWithFactories_NilFactory_Good(t *testing.T) {
|
||||
r := NewWithFactories(nil, map[string]ServiceFactory{
|
||||
"bad": nil,
|
||||
})
|
||||
assert.True(t, r.OK) // nil factories skipped
|
||||
}
|
||||
|
||||
func TestNewRuntime_Good(t *testing.T) {
|
||||
func TestRuntime_NewRuntime_Good(t *testing.T) {
|
||||
r := NewRuntime(nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ func TestRuntime_ServiceShutdown_NilCore_Good(t *testing.T) {
|
|||
|
||||
func TestCore_ServiceShutdown_Good(t *testing.T) {
|
||||
stopped := false
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Service("test", Service{
|
||||
OnStart: func() Result { return Result{OK: true} },
|
||||
OnStop: func() Result { stopped = true; return Result{OK: true} },
|
||||
|
|
@ -114,7 +114,7 @@ func TestCore_ServiceShutdown_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCore_Context_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
assert.NotNil(t, c.Context())
|
||||
c.ServiceShutdown(context.Background())
|
||||
|
|
|
|||
120
service.go
120
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,22 +17,23 @@
|
|||
|
||||
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
|
||||
OnReload func() Result
|
||||
}
|
||||
|
||||
// serviceRegistry holds registered services.
|
||||
type serviceRegistry struct {
|
||||
services map[string]*Service
|
||||
// ServiceRegistry holds registered services. Embeds Registry[*Service]
|
||||
// for thread-safe named storage with insertion order.
|
||||
type ServiceRegistry struct {
|
||||
*Registry[*Service]
|
||||
lockEnabled bool
|
||||
locked bool
|
||||
}
|
||||
|
||||
// --- Core service methods ---
|
||||
|
|
@ -39,45 +44,110 @@ type serviceRegistry struct {
|
|||
// r := c.Service("auth")
|
||||
func (c *Core) Service(name string, service ...Service) Result {
|
||||
if len(service) == 0 {
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
v, ok := c.services.services[name]
|
||||
c.Lock("srv").Mutex.RUnlock()
|
||||
return Result{v, ok}
|
||||
r := c.services.Get(name)
|
||||
if !r.OK {
|
||||
return Result{}
|
||||
}
|
||||
svc := r.Value.(*Service)
|
||||
// Return the instance if available, otherwise the Service DTO
|
||||
if svc.Instance != nil {
|
||||
return Result{svc.Instance, true}
|
||||
}
|
||||
return Result{svc, true}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return Result{E("core.Service", "service name cannot be empty", nil), false}
|
||||
}
|
||||
|
||||
c.Lock("srv").Mutex.Lock()
|
||||
defer c.Lock("srv").Mutex.Unlock()
|
||||
|
||||
if c.services.locked {
|
||||
if c.services.Locked() {
|
||||
return Result{E("core.Service", Concat("service \"", name, "\" not permitted — registry locked"), nil), false}
|
||||
}
|
||||
if _, exists := c.services.services[name]; exists {
|
||||
if c.services.Has(name) {
|
||||
return Result{E("core.Service", Join(" ", "service", name, "already registered"), nil), false}
|
||||
}
|
||||
|
||||
srv := &service[0]
|
||||
srv.Name = name
|
||||
c.services.services[name] = srv
|
||||
return c.services.Set(name, srv)
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
if c.services.Locked() {
|
||||
return Result{E("core.RegisterService", Concat("service \"", name, "\" not permitted — registry locked"), nil), false}
|
||||
}
|
||||
if c.services.Has(name) {
|
||||
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 {
|
||||
return s.OnStartup(c.context)
|
||||
}
|
||||
}
|
||||
if s, ok := instance.(Stoppable); ok {
|
||||
srv.OnStop = func() Result {
|
||||
return s.OnShutdown(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
c.services.Set(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}
|
||||
}
|
||||
|
||||
// Services returns all registered service names.
|
||||
// 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 in registration order.
|
||||
//
|
||||
// names := c.Services()
|
||||
func (c *Core) Services() []string {
|
||||
if c.services == nil {
|
||||
return nil
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var names []string
|
||||
for k := range c.services.services {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
return c.services.Names()
|
||||
}
|
||||
|
|
|
|||
50
service_example_test.go
Normal file
50
service_example_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleServiceFor() {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("cache", Service{
|
||||
OnStart: func() Result { return Result{OK: true} },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
svc := c.Service("cache")
|
||||
Println(svc.OK)
|
||||
// Output: true
|
||||
}
|
||||
|
||||
func ExampleWithService() {
|
||||
started := false
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("worker", Service{
|
||||
OnStart: func() Result { started = true; return Result{OK: true} },
|
||||
})
|
||||
}),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
Println(started)
|
||||
c.ServiceShutdown(context.Background())
|
||||
// Output: true
|
||||
}
|
||||
|
||||
func ExampleWithServiceLock() {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("allowed", Service{})
|
||||
}),
|
||||
WithServiceLock(),
|
||||
)
|
||||
|
||||
// Can't register after lock
|
||||
r := c.Service("blocked", Service{})
|
||||
Println(r.OK)
|
||||
// Output: false
|
||||
}
|
||||
127
service_test.go
127
service_test.go
|
|
@ -1,6 +1,7 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -10,26 +11,26 @@ import (
|
|||
// --- Service Registration ---
|
||||
|
||||
func TestService_Register_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Service("auth", Service{})
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Register_Duplicate_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Service("auth", Service{})
|
||||
r := c.Service("auth", Service{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Register_Empty_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Service("", Service{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Get_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
c.Service("brain", Service{OnStart: func() Result { return Result{OK: true} }})
|
||||
r := c.Service("brain")
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -37,25 +38,25 @@ func TestService_Get_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_Get_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
r := c.Service("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Names_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
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 ---
|
||||
|
||||
func TestService_Lifecycle_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c := New()
|
||||
started := false
|
||||
stopped := false
|
||||
c.Service("lifecycle", Service{
|
||||
|
|
@ -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) Result {
|
||||
s.started = true
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
func (s *autoLifecycleService) OnShutdown(_ context.Context) Result {
|
||||
s.stopped = true
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
35
string_example_test.go
Normal file
35
string_example_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleContains() {
|
||||
Println(Contains("hello world", "world"))
|
||||
Println(Contains("hello world", "mars"))
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
}
|
||||
|
||||
func ExampleSplit() {
|
||||
parts := Split("deploy/to/homelab", "/")
|
||||
Println(parts)
|
||||
// Output: [deploy to homelab]
|
||||
}
|
||||
|
||||
func ExampleJoin() {
|
||||
Println(Join("/", "deploy", "to", "homelab"))
|
||||
// Output: deploy/to/homelab
|
||||
}
|
||||
|
||||
func ExampleConcat() {
|
||||
Println(Concat("hello", " ", "world"))
|
||||
// Output: hello world
|
||||
}
|
||||
|
||||
func ExampleTrim() {
|
||||
Println(Trim(" spaced "))
|
||||
// Output: spaced
|
||||
}
|
||||
|
|
@ -9,61 +9,61 @@ import (
|
|||
|
||||
// --- String Operations ---
|
||||
|
||||
func TestHasPrefix_Good(t *testing.T) {
|
||||
func TestString_HasPrefix_Good(t *testing.T) {
|
||||
assert.True(t, HasPrefix("--verbose", "--"))
|
||||
assert.True(t, HasPrefix("-v", "-"))
|
||||
assert.False(t, HasPrefix("hello", "-"))
|
||||
}
|
||||
|
||||
func TestHasSuffix_Good(t *testing.T) {
|
||||
func TestString_HasSuffix_Good(t *testing.T) {
|
||||
assert.True(t, HasSuffix("test.go", ".go"))
|
||||
assert.False(t, HasSuffix("test.go", ".py"))
|
||||
}
|
||||
|
||||
func TestTrimPrefix_Good(t *testing.T) {
|
||||
func TestString_TrimPrefix_Good(t *testing.T) {
|
||||
assert.Equal(t, "verbose", TrimPrefix("--verbose", "--"))
|
||||
assert.Equal(t, "hello", TrimPrefix("hello", "--"))
|
||||
}
|
||||
|
||||
func TestTrimSuffix_Good(t *testing.T) {
|
||||
func TestString_TrimSuffix_Good(t *testing.T) {
|
||||
assert.Equal(t, "test", TrimSuffix("test.go", ".go"))
|
||||
assert.Equal(t, "test.go", TrimSuffix("test.go", ".py"))
|
||||
}
|
||||
|
||||
func TestContains_Good(t *testing.T) {
|
||||
func TestString_Contains_Good(t *testing.T) {
|
||||
assert.True(t, Contains("hello world", "world"))
|
||||
assert.False(t, Contains("hello world", "mars"))
|
||||
}
|
||||
|
||||
func TestSplit_Good(t *testing.T) {
|
||||
func TestString_Split_Good(t *testing.T) {
|
||||
assert.Equal(t, []string{"a", "b", "c"}, Split("a/b/c", "/"))
|
||||
}
|
||||
|
||||
func TestSplitN_Good(t *testing.T) {
|
||||
func TestString_SplitN_Good(t *testing.T) {
|
||||
assert.Equal(t, []string{"key", "value=extra"}, SplitN("key=value=extra", "=", 2))
|
||||
}
|
||||
|
||||
func TestJoin_Good(t *testing.T) {
|
||||
func TestString_Join_Good(t *testing.T) {
|
||||
assert.Equal(t, "a/b/c", Join("/", "a", "b", "c"))
|
||||
}
|
||||
|
||||
func TestReplace_Good(t *testing.T) {
|
||||
func TestString_Replace_Good(t *testing.T) {
|
||||
assert.Equal(t, "deploy.to.homelab", Replace("deploy/to/homelab", "/", "."))
|
||||
}
|
||||
|
||||
func TestLower_Good(t *testing.T) {
|
||||
func TestString_Lower_Good(t *testing.T) {
|
||||
assert.Equal(t, "hello", Lower("HELLO"))
|
||||
}
|
||||
|
||||
func TestUpper_Good(t *testing.T) {
|
||||
func TestString_Upper_Good(t *testing.T) {
|
||||
assert.Equal(t, "HELLO", Upper("hello"))
|
||||
}
|
||||
|
||||
func TestTrim_Good(t *testing.T) {
|
||||
func TestString_Trim_Good(t *testing.T) {
|
||||
assert.Equal(t, "hello", Trim(" hello "))
|
||||
}
|
||||
|
||||
func TestRuneCount_Good(t *testing.T) {
|
||||
func TestString_RuneCount_Good(t *testing.T) {
|
||||
assert.Equal(t, 5, RuneCount("hello"))
|
||||
assert.Equal(t, 1, RuneCount("🔥"))
|
||||
assert.Equal(t, 0, RuneCount(""))
|
||||
|
|
|
|||
111
task.go
111
task.go
|
|
@ -1,92 +1,61 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Background task dispatch for the Core framework.
|
||||
// Background action dispatch for the Core framework.
|
||||
// PerformAsync runs a named Action in a background goroutine with
|
||||
// panic recovery and progress broadcasting.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
import "context"
|
||||
|
||||
// TaskState holds background task state.
|
||||
type TaskState struct {
|
||||
Identifier string
|
||||
Task Task
|
||||
Result any
|
||||
Error error
|
||||
}
|
||||
|
||||
// PerformAsync dispatches a task in a background goroutine.
|
||||
func (c *Core) PerformAsync(t Task) Result {
|
||||
// PerformAsync dispatches a named action in a background goroutine.
|
||||
// Broadcasts ActionTaskStarted, ActionTaskProgress, and ActionTaskCompleted
|
||||
// as IPC messages so other services can track progress.
|
||||
//
|
||||
// r := c.PerformAsync("agentic.dispatch", opts)
|
||||
// taskID := r.Value.(string)
|
||||
func (c *Core) PerformAsync(action string, opts Options) Result {
|
||||
if c.shutdown.Load() {
|
||||
return Result{}
|
||||
}
|
||||
taskID := Concat("task-", strconv.FormatUint(c.taskIDCounter.Add(1), 10))
|
||||
if tid, ok := t.(TaskWithIdentifier); ok {
|
||||
tid.SetTaskIdentifier(taskID)
|
||||
}
|
||||
c.ACTION(ActionTaskStarted{TaskIdentifier: taskID, Task: t})
|
||||
taskID := ID()
|
||||
|
||||
c.ACTION(ActionTaskStarted{TaskIdentifier: taskID, Action: action, Options: opts})
|
||||
|
||||
c.waitGroup.Go(func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
err := E("core.PerformAsync", Sprint("panic: ", rec), nil)
|
||||
c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: nil, Error: err})
|
||||
c.ACTION(ActionTaskCompleted{
|
||||
TaskIdentifier: taskID,
|
||||
Action: action,
|
||||
Result: Result{E("core.PerformAsync", Sprint("panic: ", rec), nil), false},
|
||||
})
|
||||
}
|
||||
}()
|
||||
r := c.PERFORM(t)
|
||||
var err error
|
||||
if !r.OK {
|
||||
if e, ok := r.Value.(error); ok {
|
||||
err = e
|
||||
} else {
|
||||
taskType := reflect.TypeOf(t)
|
||||
typeName := "<nil>"
|
||||
if taskType != nil {
|
||||
typeName = taskType.String()
|
||||
}
|
||||
err = E("core.PerformAsync", Join(" ", "no handler found for task type", typeName), nil)
|
||||
}
|
||||
}
|
||||
c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: r.Value, Error: err})
|
||||
|
||||
r := c.Action(action).Run(context.Background(), opts)
|
||||
|
||||
c.ACTION(ActionTaskCompleted{
|
||||
TaskIdentifier: taskID,
|
||||
Action: action,
|
||||
Result: r,
|
||||
})
|
||||
})
|
||||
|
||||
return Result{taskID, true}
|
||||
}
|
||||
|
||||
// Progress broadcasts a progress update for a background task.
|
||||
func (c *Core) Progress(taskID string, progress float64, message string, t Task) {
|
||||
c.ACTION(ActionTaskProgress{TaskIdentifier: taskID, Task: t, Progress: progress, Message: message})
|
||||
//
|
||||
// c.Progress(taskID, 0.5, "halfway done", "agentic.dispatch")
|
||||
func (c *Core) Progress(taskID string, progress float64, message string, action string) {
|
||||
c.ACTION(ActionTaskProgress{
|
||||
TaskIdentifier: taskID,
|
||||
Action: action,
|
||||
Progress: progress,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Core) Perform(t Task) Result {
|
||||
c.ipc.taskMu.RLock()
|
||||
handlers := slices.Clone(c.ipc.taskHandlers)
|
||||
c.ipc.taskMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
r := h(c, t)
|
||||
if r.OK {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Core) RegisterTask(handler TaskHandler) {
|
||||
c.ipc.taskMu.Lock()
|
||||
c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler)
|
||||
c.ipc.taskMu.Unlock()
|
||||
}
|
||||
// Registration methods (RegisterAction, RegisterActions)
|
||||
// are in ipc.go — registration is IPC's responsibility.
|
||||
|
|
|
|||
50
task_example_test.go
Normal file
50
task_example_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
)
|
||||
|
||||
func ExampleTask_Run() {
|
||||
c := New()
|
||||
var order string
|
||||
|
||||
c.Action("step.a", func(_ context.Context, _ Options) Result {
|
||||
order += "a"
|
||||
return Result{Value: "from-a", OK: true}
|
||||
})
|
||||
c.Action("step.b", func(_ context.Context, opts Options) Result {
|
||||
order += "b"
|
||||
input := opts.Get("_input")
|
||||
if input.OK {
|
||||
return Result{Value: "got:" + input.Value.(string), OK: true}
|
||||
}
|
||||
return Result{OK: true}
|
||||
})
|
||||
|
||||
c.Task("pipe", Task{
|
||||
Steps: []Step{
|
||||
{Action: "step.a"},
|
||||
{Action: "step.b", Input: "previous"},
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Task("pipe").Run(context.Background(), c, NewOptions())
|
||||
Println(order)
|
||||
Println(r.Value)
|
||||
// Output:
|
||||
// ab
|
||||
// got:from-a
|
||||
}
|
||||
|
||||
func ExampleCore_PerformAsync() {
|
||||
c := New()
|
||||
c.Action("bg.work", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "done", OK: true}
|
||||
})
|
||||
|
||||
r := c.PerformAsync("bg.work", NewOptions())
|
||||
Println(HasPrefix(r.Value.(string), "id-"))
|
||||
// Output: true
|
||||
}
|
||||
68
task_test.go
68
task_test.go
|
|
@ -12,22 +12,21 @@ import (
|
|||
|
||||
// --- PerformAsync ---
|
||||
|
||||
func TestPerformAsync_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestTask_PerformAsync_Good(t *testing.T) {
|
||||
c := New()
|
||||
var mu sync.Mutex
|
||||
var result string
|
||||
|
||||
c.RegisterTask(func(_ *Core, task Task) Result {
|
||||
c.Action("work", func(_ context.Context, _ Options) Result {
|
||||
mu.Lock()
|
||||
result = "done"
|
||||
mu.Unlock()
|
||||
return Result{"completed", true}
|
||||
return Result{Value: "done", OK: true}
|
||||
})
|
||||
|
||||
r := c.PerformAsync("work")
|
||||
r := c.PerformAsync("work", NewOptions())
|
||||
assert.True(t, r.OK)
|
||||
taskID := r.Value.(string)
|
||||
assert.NotEmpty(t, taskID)
|
||||
assert.True(t, HasPrefix(r.Value.(string), "id-"), "should return task ID")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
|
|
@ -36,24 +35,25 @@ func TestPerformAsync_Good(t *testing.T) {
|
|||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestPerformAsync_Progress_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.RegisterTask(func(_ *Core, task Task) Result {
|
||||
func TestTask_PerformAsync_Good_Progress(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("tracked", func(_ context.Context, _ Options) Result {
|
||||
return Result{OK: true}
|
||||
})
|
||||
|
||||
r := c.PerformAsync("work")
|
||||
r := c.PerformAsync("tracked", NewOptions())
|
||||
taskID := r.Value.(string)
|
||||
c.Progress(taskID, 0.5, "halfway", "work")
|
||||
c.Progress(taskID, 0.5, "halfway", "tracked")
|
||||
}
|
||||
|
||||
func TestPerformAsync_Completion_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestTask_PerformAsync_Good_Completion(t *testing.T) {
|
||||
c := New()
|
||||
completed := make(chan ActionTaskCompleted, 1)
|
||||
|
||||
c.RegisterTask(func(_ *Core, task Task) Result {
|
||||
return Result{Value: "result", OK: true}
|
||||
c.Action("completable", func(_ context.Context, _ Options) Result {
|
||||
return Result{Value: "output", OK: true}
|
||||
})
|
||||
|
||||
c.RegisterAction(func(_ *Core, msg Message) Result {
|
||||
if evt, ok := msg.(ActionTaskCompleted); ok {
|
||||
completed <- evt
|
||||
|
|
@ -61,19 +61,19 @@ func TestPerformAsync_Completion_Good(t *testing.T) {
|
|||
return Result{OK: true}
|
||||
})
|
||||
|
||||
c.PerformAsync("work")
|
||||
c.PerformAsync("completable", NewOptions())
|
||||
|
||||
select {
|
||||
case evt := <-completed:
|
||||
assert.Nil(t, evt.Error)
|
||||
assert.Equal(t, "result", evt.Result)
|
||||
assert.True(t, evt.Result.OK)
|
||||
assert.Equal(t, "output", evt.Result.Value)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformAsync_NoHandler_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestTask_PerformAsync_Bad_ActionNotRegistered(t *testing.T) {
|
||||
c := New()
|
||||
completed := make(chan ActionTaskCompleted, 1)
|
||||
|
||||
c.RegisterAction(func(_ *Core, msg Message) Result {
|
||||
|
|
@ -83,43 +83,45 @@ func TestPerformAsync_NoHandler_Good(t *testing.T) {
|
|||
return Result{OK: true}
|
||||
})
|
||||
|
||||
c.PerformAsync("unhandled")
|
||||
c.PerformAsync("nonexistent", NewOptions())
|
||||
|
||||
select {
|
||||
case evt := <-completed:
|
||||
assert.NotNil(t, evt.Error)
|
||||
assert.False(t, evt.Result.OK, "unregistered action should fail")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformAsync_AfterShutdown_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestTask_PerformAsync_Bad_AfterShutdown(t *testing.T) {
|
||||
c := New()
|
||||
c.Action("work", func(_ context.Context, _ Options) Result { return Result{OK: true} })
|
||||
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
c.ServiceShutdown(context.Background())
|
||||
|
||||
r := c.PerformAsync("should not run")
|
||||
r := c.PerformAsync("work", NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- RegisterAction + RegisterActions ---
|
||||
// --- RegisterAction + RegisterActions (broadcast handlers) ---
|
||||
|
||||
func TestRegisterAction_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestTask_RegisterAction_Good(t *testing.T) {
|
||||
c := New()
|
||||
called := false
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
called = true
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.Action(nil)
|
||||
c.ACTION(nil)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestRegisterActions_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
func TestTask_RegisterActions_Good(t *testing.T) {
|
||||
c := New()
|
||||
count := 0
|
||||
h := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
|
||||
c.RegisterActions(h, h)
|
||||
c.Action(nil)
|
||||
c.ACTION(nil)
|
||||
assert.Equal(t, 2, count)
|
||||
}
|
||||
|
|
|
|||
64
utils.go
64
utils.go
|
|
@ -6,11 +6,75 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// --- ID Generation ---
|
||||
|
||||
var idCounter atomic.Uint64
|
||||
|
||||
// ID returns a unique identifier. Format: "id-{counter}-{random}".
|
||||
// Counter is process-wide atomic. Random suffix prevents collision across restarts.
|
||||
//
|
||||
// id := core.ID() // "id-1-a3f2b1"
|
||||
// id2 := core.ID() // "id-2-c7e4d9"
|
||||
func ID() string {
|
||||
return Concat("id-", strconv.FormatUint(idCounter.Add(1), 10), "-", shortRand())
|
||||
}
|
||||
|
||||
func shortRand() string {
|
||||
b := make([]byte, 3)
|
||||
crand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
// ValidateName checks that a string is a valid service/action/command name.
|
||||
// Rejects empty, ".", "..", and names containing path separators.
|
||||
//
|
||||
// r := core.ValidateName("brain") // Result{"brain", true}
|
||||
// r := core.ValidateName("") // Result{error, false}
|
||||
// r := core.ValidateName("../escape") // Result{error, false}
|
||||
func ValidateName(name string) Result {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return Result{E("validate", Concat("invalid name: ", name), nil), false}
|
||||
}
|
||||
if Contains(name, "/") || Contains(name, "\\") {
|
||||
return Result{E("validate", Concat("name contains path separator: ", name), nil), false}
|
||||
}
|
||||
return Result{name, true}
|
||||
}
|
||||
|
||||
// SanitisePath extracts the base filename and rejects traversal attempts.
|
||||
// Returns "invalid" for dangerous inputs.
|
||||
//
|
||||
// core.SanitisePath("../../etc/passwd") // "passwd"
|
||||
// core.SanitisePath("") // "invalid"
|
||||
// core.SanitisePath("..") // "invalid"
|
||||
func SanitisePath(path string) string {
|
||||
safe := PathBase(path)
|
||||
if safe == "." || safe == ".." || safe == "" {
|
||||
return "invalid"
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// --- I/O ---
|
||||
|
||||
// Println prints values to stdout with a newline. Replaces fmt.Println.
|
||||
//
|
||||
// core.Println("hello", 42, true)
|
||||
func Println(args ...any) {
|
||||
fmt.Println(args...)
|
||||
}
|
||||
|
||||
// Print writes a formatted line to a writer, defaulting to os.Stdout.
|
||||
//
|
||||
// core.Print(nil, "hello %s", "world") // → stdout
|
||||
|
|
|
|||
149
utils_test.go
149
utils_test.go
|
|
@ -1,29 +1,112 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- ID ---
|
||||
|
||||
func TestUtils_ID_Good(t *testing.T) {
|
||||
id := ID()
|
||||
assert.True(t, HasPrefix(id, "id-"))
|
||||
assert.True(t, len(id) > 5, "ID should have counter + random suffix")
|
||||
}
|
||||
|
||||
func TestUtils_ID_Good_Unique(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 1000; i++ {
|
||||
id := ID()
|
||||
assert.False(t, seen[id], "ID collision: %s", id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestUtils_ID_Ugly_CounterMonotonic(t *testing.T) {
|
||||
// IDs should contain increasing counter values
|
||||
id1 := ID()
|
||||
id2 := ID()
|
||||
// Both should start with "id-" and have different counter parts
|
||||
assert.NotEqual(t, id1, id2)
|
||||
assert.True(t, HasPrefix(id1, "id-"))
|
||||
assert.True(t, HasPrefix(id2, "id-"))
|
||||
}
|
||||
|
||||
// --- ValidateName ---
|
||||
|
||||
func TestUtils_ValidateName_Good(t *testing.T) {
|
||||
r := ValidateName("brain")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "brain", r.Value)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Good_WithDots(t *testing.T) {
|
||||
r := ValidateName("process.run")
|
||||
assert.True(t, r.OK, "dots in names are valid — used for action namespacing")
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_Empty(t *testing.T) {
|
||||
r := ValidateName("")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_Dot(t *testing.T) {
|
||||
r := ValidateName(".")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_DotDot(t *testing.T) {
|
||||
r := ValidateName("..")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Bad_Slash(t *testing.T) {
|
||||
r := ValidateName("../escape")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestUtils_ValidateName_Ugly_Backslash(t *testing.T) {
|
||||
r := ValidateName("windows\\path")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- SanitisePath ---
|
||||
|
||||
func TestUtils_SanitisePath_Good(t *testing.T) {
|
||||
assert.Equal(t, "file.txt", SanitisePath("/some/path/file.txt"))
|
||||
}
|
||||
|
||||
func TestUtils_SanitisePath_Bad_Empty(t *testing.T) {
|
||||
assert.Equal(t, "invalid", SanitisePath(""))
|
||||
}
|
||||
|
||||
func TestUtils_SanitisePath_Bad_DotDot(t *testing.T) {
|
||||
assert.Equal(t, "invalid", SanitisePath(".."))
|
||||
}
|
||||
|
||||
func TestUtils_SanitisePath_Ugly_Traversal(t *testing.T) {
|
||||
// PathBase extracts "passwd" — the traversal is stripped
|
||||
assert.Equal(t, "passwd", SanitisePath("../../etc/passwd"))
|
||||
}
|
||||
|
||||
// --- FilterArgs ---
|
||||
|
||||
func TestFilterArgs_Good(t *testing.T) {
|
||||
func TestUtils_FilterArgs_Good(t *testing.T) {
|
||||
args := []string{"deploy", "", "to", "-test.v", "homelab", "-test.paniconexit0"}
|
||||
clean := FilterArgs(args)
|
||||
assert.Equal(t, []string{"deploy", "to", "homelab"}, clean)
|
||||
}
|
||||
|
||||
func TestFilterArgs_Empty_Good(t *testing.T) {
|
||||
func TestUtils_FilterArgs_Empty_Good(t *testing.T) {
|
||||
clean := FilterArgs(nil)
|
||||
assert.Nil(t, clean)
|
||||
}
|
||||
|
||||
// --- ParseFlag ---
|
||||
|
||||
func TestParseFlag_ShortValid_Good(t *testing.T) {
|
||||
func TestUtils_ParseFlag_ShortValid_Good(t *testing.T) {
|
||||
// Single letter
|
||||
k, v, ok := ParseFlag("-v")
|
||||
assert.True(t, ok)
|
||||
|
|
@ -43,7 +126,7 @@ func TestParseFlag_ShortValid_Good(t *testing.T) {
|
|||
assert.Equal(t, "8080", v)
|
||||
}
|
||||
|
||||
func TestParseFlag_ShortInvalid_Bad(t *testing.T) {
|
||||
func TestUtils_ParseFlag_ShortInvalid_Bad(t *testing.T) {
|
||||
// Multiple chars with single dash — invalid
|
||||
_, _, ok := ParseFlag("-verbose")
|
||||
assert.False(t, ok)
|
||||
|
|
@ -52,7 +135,7 @@ func TestParseFlag_ShortInvalid_Bad(t *testing.T) {
|
|||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseFlag_LongValid_Good(t *testing.T) {
|
||||
func TestUtils_ParseFlag_LongValid_Good(t *testing.T) {
|
||||
k, v, ok := ParseFlag("--verbose")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "verbose", k)
|
||||
|
|
@ -64,13 +147,13 @@ func TestParseFlag_LongValid_Good(t *testing.T) {
|
|||
assert.Equal(t, "8080", v)
|
||||
}
|
||||
|
||||
func TestParseFlag_LongInvalid_Bad(t *testing.T) {
|
||||
func TestUtils_ParseFlag_LongInvalid_Bad(t *testing.T) {
|
||||
// Single char with double dash — invalid
|
||||
_, _, ok := ParseFlag("--v")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseFlag_NotAFlag_Bad(t *testing.T) {
|
||||
func TestUtils_ParseFlag_NotAFlag_Bad(t *testing.T) {
|
||||
_, _, ok := ParseFlag("hello")
|
||||
assert.False(t, ok)
|
||||
|
||||
|
|
@ -80,57 +163,57 @@ func TestParseFlag_NotAFlag_Bad(t *testing.T) {
|
|||
|
||||
// --- IsFlag ---
|
||||
|
||||
func TestIsFlag_Good(t *testing.T) {
|
||||
func TestUtils_IsFlag_Good(t *testing.T) {
|
||||
assert.True(t, IsFlag("-v"))
|
||||
assert.True(t, IsFlag("--verbose"))
|
||||
assert.True(t, IsFlag("-"))
|
||||
}
|
||||
|
||||
func TestIsFlag_Bad(t *testing.T) {
|
||||
func TestUtils_IsFlag_Bad(t *testing.T) {
|
||||
assert.False(t, IsFlag("hello"))
|
||||
assert.False(t, IsFlag(""))
|
||||
}
|
||||
|
||||
// --- Arg ---
|
||||
|
||||
func TestArg_String_Good(t *testing.T) {
|
||||
func TestUtils_Arg_String_Good(t *testing.T) {
|
||||
r := Arg(0, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello", r.Value)
|
||||
}
|
||||
|
||||
func TestArg_Int_Good(t *testing.T) {
|
||||
func TestUtils_Arg_Int_Good(t *testing.T) {
|
||||
r := Arg(1, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 42, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_Bool_Good(t *testing.T) {
|
||||
func TestUtils_Arg_Bool_Good(t *testing.T) {
|
||||
r := Arg(2, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, true, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_UnsupportedType_Good(t *testing.T) {
|
||||
func TestUtils_Arg_UnsupportedType_Good(t *testing.T) {
|
||||
r := Arg(0, 3.14)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 3.14, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_Arg_OutOfBounds_Bad(t *testing.T) {
|
||||
r := Arg(5, "only", "two")
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_NoArgs_Bad(t *testing.T) {
|
||||
func TestUtils_Arg_NoArgs_Bad(t *testing.T) {
|
||||
r := Arg(0)
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_ErrorDetection_Good(t *testing.T) {
|
||||
err := errors.New("fail")
|
||||
func TestUtils_Arg_ErrorDetection_Good(t *testing.T) {
|
||||
err := NewError("fail")
|
||||
r := Arg(0, err)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, err, r.Value)
|
||||
|
|
@ -138,78 +221,78 @@ func TestArg_ErrorDetection_Good(t *testing.T) {
|
|||
|
||||
// --- ArgString ---
|
||||
|
||||
func TestArgString_Good(t *testing.T) {
|
||||
func TestUtils_ArgString_Good(t *testing.T) {
|
||||
assert.Equal(t, "hello", ArgString(0, "hello", 42))
|
||||
assert.Equal(t, "world", ArgString(1, "hello", "world"))
|
||||
}
|
||||
|
||||
func TestArgString_WrongType_Bad(t *testing.T) {
|
||||
func TestUtils_ArgString_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, "", ArgString(0, 42))
|
||||
}
|
||||
|
||||
func TestArgString_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_ArgString_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, "", ArgString(3, "only"))
|
||||
}
|
||||
|
||||
// --- ArgInt ---
|
||||
|
||||
func TestArgInt_Good(t *testing.T) {
|
||||
func TestUtils_ArgInt_Good(t *testing.T) {
|
||||
assert.Equal(t, 42, ArgInt(0, 42, "hello"))
|
||||
assert.Equal(t, 99, ArgInt(1, 0, 99))
|
||||
}
|
||||
|
||||
func TestArgInt_WrongType_Bad(t *testing.T) {
|
||||
func TestUtils_ArgInt_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, 0, ArgInt(0, "not an int"))
|
||||
}
|
||||
|
||||
func TestArgInt_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_ArgInt_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, 0, ArgInt(5, 1, 2))
|
||||
}
|
||||
|
||||
// --- ArgBool ---
|
||||
|
||||
func TestArgBool_Good(t *testing.T) {
|
||||
func TestUtils_ArgBool_Good(t *testing.T) {
|
||||
assert.Equal(t, true, ArgBool(0, true, "hello"))
|
||||
assert.Equal(t, false, ArgBool(1, true, false))
|
||||
}
|
||||
|
||||
func TestArgBool_WrongType_Bad(t *testing.T) {
|
||||
func TestUtils_ArgBool_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, false, ArgBool(0, "not a bool"))
|
||||
}
|
||||
|
||||
func TestArgBool_OutOfBounds_Bad(t *testing.T) {
|
||||
func TestUtils_ArgBool_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, false, ArgBool(5, true))
|
||||
}
|
||||
|
||||
// --- Result.Result() ---
|
||||
|
||||
func TestResult_Result_SingleArg_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_SingleArg_Good(t *testing.T) {
|
||||
r := Result{}.Result("value")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_NilError_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_NilError_Good(t *testing.T) {
|
||||
r := Result{}.Result("value", nil)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_WithError_Bad(t *testing.T) {
|
||||
err := errors.New("fail")
|
||||
func TestUtils_Result_Result_WithError_Bad(t *testing.T) {
|
||||
err := NewError("fail")
|
||||
r := Result{}.Result("value", err)
|
||||
assert.False(t, r.OK)
|
||||
assert.Equal(t, err, r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_ZeroArgs_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_ZeroArgs_Good(t *testing.T) {
|
||||
r := Result{"hello", true}
|
||||
got := r.Result()
|
||||
assert.Equal(t, "hello", got.Value)
|
||||
assert.True(t, got.OK)
|
||||
}
|
||||
|
||||
func TestResult_Result_ZeroArgs_Empty_Good(t *testing.T) {
|
||||
func TestUtils_Result_Result_ZeroArgs_Empty_Good(t *testing.T) {
|
||||
r := Result{}
|
||||
got := r.Result()
|
||||
assert.Nil(t, got.Value)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue