Compare commits
201 commits
| 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 | |||
|
|
c45b22849f | ||
|
|
927f830be4 | ||
|
|
e0c190ca8f | ||
| fb04b28419 | |||
|
|
2312801d43 | ||
| ce597be0d3 | |||
|
|
7e2783dcf5 | ||
|
|
8c2b9c2506 | ||
| a06b779e3c | |||
|
|
77780812cf | ||
|
|
2d52b83f60 | ||
| df1576b101 | |||
|
|
954cd714a1 | ||
| 76f8ae41b9 | |||
|
|
b01b7f4d88 | ||
| 397ec2cec5 | |||
|
|
01135ac8bd | ||
| 3bf175b3d1 | |||
|
|
fbd646456a | ||
| 3642a819f1 | |||
|
|
2fa8b32db2 | ||
| 8de5e20ab5 | |||
|
|
3e507c9813 | ||
| 6942a019cb | |||
|
|
104416676b | ||
| dcf677309d | |||
|
|
b34899ca00 | ||
| d6dada1461 | |||
|
|
1728c2930c | ||
|
|
41c50da68b | ||
| cee07f05dd | |||
|
|
73eed891ca | ||
|
|
af6b618196 | ||
|
|
e17217a630 | ||
|
|
d5f295cb7d | ||
|
|
bde8d4c7cc | ||
|
|
629adb056b | ||
|
|
61b034335a | ||
|
|
ee9e715243 | ||
|
|
bf1f8e51ad | ||
|
|
4c3a671b48 | ||
|
|
f1bd36db2e | ||
|
|
bc06480b58 | ||
|
|
2f39e8e1f4 | ||
|
|
298322ed89 | ||
|
|
cf25af1a13 | ||
|
|
b2d0deb99b | ||
|
|
8801e2ea10 | ||
|
|
f8e1459bd1 | ||
|
|
a845866c25 | ||
|
|
b0ec660e78 | ||
|
|
9bcb367dd0 | ||
|
|
3bab201229 | ||
|
|
7d34436fc6 | ||
|
|
9161ed2a79 | ||
|
|
01dec6dbe7 | ||
|
|
2d6415b3aa | ||
|
|
94f2e54abe | ||
|
|
f5611b1002 | ||
|
|
cb16b63b19 | ||
|
|
5d67088080 | ||
|
|
996853bd53 | ||
|
|
4cc2e5bf15 | ||
|
|
0c97415d77 | ||
|
|
02d966d184 | ||
|
|
f1d6c2a174 | ||
|
|
2fab391cc9 | ||
|
|
e12526dca6 | ||
|
|
c8ebf40e78 | ||
|
|
c3f457c151 | ||
|
|
e220b9faab | ||
|
|
d8ad60ce8a | ||
|
|
6687db76f3 | ||
|
|
8854d5c79f | ||
|
|
c61a2d3dfe | ||
|
|
afc235796f | ||
|
|
b2d07e7883 | ||
|
|
1ca010e1fb | ||
|
|
f51c748f49 | ||
| 3ee58576a5 | |||
|
|
7c7a257c19 | ||
|
|
4fa90a8294 | ||
|
|
ead9ea00e5 | ||
|
|
2406e81c20 | ||
|
|
c2227fbb33 | ||
|
|
173067719e | ||
|
|
2525d10515 | ||
|
|
8199727537 | ||
|
|
bcaf1554f8 | ||
|
|
3b3b042509 | ||
|
|
8f2e3d9457 | ||
|
|
16a985ad5c | ||
|
|
dd6803df10 | ||
|
|
55cbfea7ca | ||
|
|
81eba2777a | ||
|
|
d1c9d4e4ad | ||
|
|
8935905ac9 | ||
|
|
d7f9447e7a | ||
|
|
077fde9516 | ||
|
|
9331f5067c | ||
|
|
8765458bc6 | ||
|
|
66b4b08600 | ||
|
|
9a57a7bc88 | ||
|
|
c0d50bdf92 | ||
|
|
21c4f718d3 | ||
|
|
7a9c9caabc |
128 changed files with 16798 additions and 6299 deletions
26
.github/workflows/ci.yml
vendored
Normal file
26
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
go test -coverprofile=coverage.out ./tests/...
|
||||
sed -i 's|dappco.re/go/core/||g' coverage.out
|
||||
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage.out
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
run:
|
||||
timeout: 5m
|
||||
go: "1.26"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosimple
|
||||
- ineffassign
|
||||
- typecheck
|
||||
- gocritic
|
||||
- gofmt
|
||||
disable:
|
||||
- exhaustive
|
||||
- wrapcheck
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-same-issues: 0
|
||||
145
CLAUDE.md
145
CLAUDE.md
|
|
@ -1,106 +1,103 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code and Codex when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
## Module
|
||||
|
||||
Core (`forge.lthn.ai/core/go`) is a **dependency injection and service lifecycle framework** for Go. It provides a typed service registry, lifecycle hooks, and a message-passing bus for decoupled communication between services.
|
||||
`dappco.re/go/core` — dependency injection, service lifecycle, permission, and message-passing for Go.
|
||||
|
||||
This is the foundation layer — it has no CLI, no GUI, and minimal dependencies (`go-io`, `go-log`, `testify`).
|
||||
Source files and tests live at the module root. No `pkg/` nesting.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
This project uses `core go` commands (no Taskfile). Build configuration lives in `.core/build.yaml`.
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
go test ./... -count=1 # run all tests (483 tests, 84.7% coverage)
|
||||
go build ./... # verify compilation
|
||||
```
|
||||
|
||||
Or via the Core CLI:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
core go test
|
||||
|
||||
# Generate test coverage
|
||||
core go cov
|
||||
core go cov --open # Opens coverage HTML report
|
||||
|
||||
# Format, lint, vet
|
||||
core go fmt
|
||||
core go lint
|
||||
core go vet
|
||||
|
||||
# Quality assurance
|
||||
core go qa # fmt + vet + lint + test
|
||||
core go qa full # + race, vuln, security
|
||||
|
||||
# Build
|
||||
core build # Auto-detects project type
|
||||
core build --ci # All targets, JSON output
|
||||
core go qa # fmt + vet + lint + test
|
||||
```
|
||||
|
||||
Run a single test: `core go test --run TestName`
|
||||
## API Shape
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Framework (`pkg/core/`)
|
||||
|
||||
The `Core` struct is the central application container managing:
|
||||
- **Services**: Named service registry with type-safe retrieval via `ServiceFor[T]()`
|
||||
- **Actions/IPC**: Message-passing system where services communicate via `ACTION(msg Message)` and register handlers via `RegisterAction()`
|
||||
- **Lifecycle**: Services implementing `Startable` (OnStartup) and/or `Stoppable` (OnShutdown) interfaces are automatically called during app lifecycle
|
||||
|
||||
Creating a Core instance:
|
||||
```go
|
||||
core, err := core.New(
|
||||
core.WithService(myServiceFactory),
|
||||
core.WithAssets(assets),
|
||||
core.WithServiceLock(), // Prevents late service registration
|
||||
c := core.New(
|
||||
core.WithOption("name", "myapp"),
|
||||
core.WithService(mypackage.Register),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.Run() // or: if err := c.RunE(); err != nil { ... }
|
||||
```
|
||||
|
||||
### Service Registration Pattern
|
||||
Service factory:
|
||||
|
||||
Services are registered via factory functions that receive the Core instance:
|
||||
```go
|
||||
func NewMyService(c *core.Core) (any, error) {
|
||||
return &MyService{runtime: core.NewServiceRuntime(c, opts)}, nil
|
||||
}
|
||||
|
||||
core.New(core.WithService(NewMyService))
|
||||
```
|
||||
|
||||
- `WithService`: Auto-discovers service name from package path, registers IPC handler if service has `HandleIPCEvents` method
|
||||
- `WithName`: Explicitly names a service
|
||||
|
||||
### ServiceRuntime Generic Helper (`runtime_pkg.go`)
|
||||
|
||||
Embed `ServiceRuntime[T]` in services to get access to Core and typed options:
|
||||
```go
|
||||
type MyService struct {
|
||||
*core.ServiceRuntime[MyServiceOptions]
|
||||
func Register(c *core.Core) core.Result {
|
||||
svc := &MyService{ServiceRuntime: core.NewServiceRuntime(c, MyOpts{})}
|
||||
return core.Result{Value: svc, OK: true}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling (go-log)
|
||||
## Subsystems
|
||||
|
||||
| Accessor | Returns | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `c.Options()` | `*Options` | Input configuration |
|
||||
| `c.App()` | `*App` | Application identity |
|
||||
| `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 (panic recovery per handler) |
|
||||
| `c.QUERY(q)` | First responder wins |
|
||||
| `c.QUERYALL(q)` | Collect all responses |
|
||||
| `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
|
||||
|
||||
Use `core.E()` for structured errors:
|
||||
|
||||
All errors MUST use `E()` from `go-log` (re-exported in `e.go`), never `fmt.Errorf`:
|
||||
```go
|
||||
return core.E("service.Method", "what failed", underlyingErr)
|
||||
return core.E("service.Method", fmt.Sprintf("service %q not found", name), nil)
|
||||
```
|
||||
|
||||
### Test Naming Convention
|
||||
**Never** use `fmt.Errorf`, `errors.New`, `os/exec`, or `unsafe.Pointer` on Core types.
|
||||
|
||||
Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern:
|
||||
- `_Good`: Happy path tests
|
||||
- `_Bad`: Expected error conditions
|
||||
- `_Ugly`: Panic/edge cases
|
||||
## Test Naming (AX-7)
|
||||
|
||||
## Packages
|
||||
`TestFile_Function_{Good,Bad,Ugly}` — 100% compliance.
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `pkg/core` | DI container, service registry, lifecycle, query/task bus |
|
||||
| `pkg/log` | Structured logger service with Core integration |
|
||||
## Docs
|
||||
|
||||
Full API contract: `docs/RFC.md` (1476 lines, 21 sections).
|
||||
|
||||
## Go Workspace
|
||||
|
||||
Uses Go 1.26 workspaces. This module is part of the workspace at `~/Code/go.work`.
|
||||
|
||||
After adding modules: `go work sync`
|
||||
Part of `~/Code/go.work`. Use `GOWORK=off` to test in isolation.
|
||||
|
|
|
|||
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.
|
||||
477
README.md
477
README.md
|
|
@ -1,443 +1,74 @@
|
|||
# Core
|
||||
# CoreGO
|
||||
|
||||
[](https://codecov.io/gh/host-uk/core)
|
||||
[](https://forge.lthn.ai/core/cli/actions/workflows/coverage.yml)
|
||||
[](https://forge.lthn.ai/core/cli/actions/workflows/codescan.yml)
|
||||
[](https://go.dev/)
|
||||
[](https://opensource.org/licenses/EUPL-1.2)
|
||||
Dependency injection, service lifecycle, permission, and message-passing for Go.
|
||||
|
||||
Core is a Web3 Framework, written in Go using Wails.io to replace Electron and the bloat of browsers that, at their core, still live in their mum's basement.
|
||||
```go
|
||||
import "dappco.re/go/core"
|
||||
```
|
||||
|
||||
- Repo: https://forge.lthn.ai/core/cli
|
||||
CoreGO is the foundation layer for the Core ecosystem. It gives you:
|
||||
|
||||
## Vision
|
||||
- one container: `Core`
|
||||
- one input shape: `Options`
|
||||
- one output shape: `Result`
|
||||
- one command tree: `Command`
|
||||
- one message bus: `ACTION`, `QUERY` + named `Action` callables
|
||||
- one permission gate: `Entitled`
|
||||
- one collection primitive: `Registry[T]`
|
||||
|
||||
Core is an **opinionated Web3 desktop application framework** providing:
|
||||
## Quick Example
|
||||
|
||||
1. **Service-Oriented Architecture** - Pluggable services with dependency injection
|
||||
2. **Encrypted Workspaces** - Each workspace gets its own PGP keypair, files are obfuscated
|
||||
3. **Cross-Platform Storage** - Abstract storage backends (local, SFTP, WebDAV) behind a `Medium` interface
|
||||
4. **Multi-Brand Support** - Same codebase powers different "hub" apps (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub)
|
||||
5. **Built-in Crypto** - PGP encryption/signing, hashing, checksums as first-class citizens
|
||||
```go
|
||||
package main
|
||||
|
||||
**Mental model:** A secure, encrypted workspace manager where each "workspace" is a cryptographically isolated environment. The framework handles windows, menus, trays, config, and i18n.
|
||||
import "dappco.re/go/core"
|
||||
|
||||
## CLI Quick Start
|
||||
func main() {
|
||||
c := core.New(
|
||||
core.WithOption("name", "agent-workbench"),
|
||||
core.WithService(cache.Register),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.Run()
|
||||
}
|
||||
```
|
||||
|
||||
## Core Surfaces
|
||||
|
||||
| Surface | Purpose |
|
||||
|---------|---------|
|
||||
| `Core` | Central container and access point |
|
||||
| `Service` | Managed lifecycle component |
|
||||
| `Command` | Path-based executable operation |
|
||||
| `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 (sandboxable) |
|
||||
| `Config` | Runtime settings and feature flags |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# 1. Install Core
|
||||
go install forge.lthn.ai/core/cli/cmd/core@latest
|
||||
|
||||
# 2. Verify environment
|
||||
core doctor
|
||||
|
||||
# 3. Run tests in any Go/PHP project
|
||||
core go test # or core php test
|
||||
|
||||
# 4. Build and preview release
|
||||
core build
|
||||
core ci
|
||||
go get dappco.re/go/core
|
||||
```
|
||||
|
||||
For more details, see the [User Guide](docs/user-guide.md).
|
||||
Requires Go 1.26 or later.
|
||||
|
||||
## Framework Quick Start (Go)
|
||||
|
||||
```go
|
||||
import core "forge.lthn.ai/core/cli/pkg/framework/core"
|
||||
|
||||
app, err := core.New(
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Go](https://go.dev/) 1.25+
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [Wails](https://wails.io/) v3
|
||||
- [Task](https://taskfile.dev/)
|
||||
|
||||
## Development Workflow (TDD)
|
||||
## Test
|
||||
|
||||
```bash
|
||||
task test-gen # 1. Generate test stubs
|
||||
task test # 2. Run tests (watch them fail)
|
||||
# 3. Implement your feature
|
||||
task test # 4. Run tests (watch them pass)
|
||||
task review # 5. CodeRabbit review
|
||||
go test ./... # 483 tests, 84.7% coverage
|
||||
```
|
||||
|
||||
## Building & Running
|
||||
## Docs
|
||||
|
||||
```bash
|
||||
# GUI (Wails)
|
||||
task gui:dev # Development with hot-reload
|
||||
task gui:build # Production build
|
||||
The authoritative API contract is `docs/RFC.md` (21 sections).
|
||||
|
||||
# CLI
|
||||
task cli:build # Build to cmd/core/bin/core
|
||||
task cli:run # Build and run
|
||||
```
|
||||
## License
|
||||
|
||||
## Configuration
|
||||
|
||||
Core uses a layered configuration system where values are resolved in the following priority:
|
||||
|
||||
1. **Command-line flags** (if applicable)
|
||||
2. **Environment variables**
|
||||
3. **Configuration file**
|
||||
4. **Default values**
|
||||
|
||||
### Configuration File
|
||||
|
||||
The default configuration file is located at `~/.core/config.yaml`.
|
||||
|
||||
#### Format
|
||||
|
||||
The file uses YAML format and supports nested structures.
|
||||
|
||||
```yaml
|
||||
# ~/.core/config.yaml
|
||||
dev:
|
||||
editor: vim
|
||||
debug: true
|
||||
|
||||
log:
|
||||
level: info
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Layered Configuration Mapping
|
||||
|
||||
Any configuration value can be overridden using environment variables with the `CORE_CONFIG_` prefix. After stripping the `CORE_CONFIG_` prefix, the remaining variable name is converted to lowercase and underscores are replaced with dots to map to the configuration hierarchy.
|
||||
|
||||
**Examples:**
|
||||
- `CORE_CONFIG_DEV_EDITOR=nano` maps to `dev.editor: nano`
|
||||
- `CORE_CONFIG_LOG_LEVEL=debug` maps to `log.level: debug`
|
||||
|
||||
#### Common Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CORE_DAEMON` | Set to `1` to run the application in daemon mode. |
|
||||
| `NO_COLOR` | If set (to any value), disables ANSI color output. |
|
||||
| `MCP_ADDR` | Address for the MCP TCP server (e.g., `localhost:9100`). If not set, MCP uses Stdio. |
|
||||
| `COOLIFY_TOKEN` | API token for Coolify deployments. |
|
||||
| `AGENTIC_TOKEN` | API token for Agentic services. |
|
||||
| `UNIFI_URL` | URL of the UniFi controller (e.g., `https://192.168.1.1`). |
|
||||
| `UNIFI_INSECURE` | Set to `1` or `true` to skip UniFi TLS verification. |
|
||||
|
||||
## All Tasks
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| `task test` | Run all Go tests |
|
||||
| `task test-gen` | Generate test stubs for public API |
|
||||
| `task check` | go mod tidy + tests + review |
|
||||
| `task review` | CodeRabbit review |
|
||||
| `task cov` | Run tests with coverage report |
|
||||
| `task cov-view` | Open HTML coverage report |
|
||||
| `task sync` | Update public API Go files |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── main.go # CLI application entry point
|
||||
├── pkg/
|
||||
│ ├── framework/core/ # Service container, DI, Runtime[T]
|
||||
│ ├── crypt/ # Hashing, checksums, PGP
|
||||
│ ├── io/ # Medium interface + backends
|
||||
│ ├── help/ # In-app documentation
|
||||
│ ├── i18n/ # Internationalization
|
||||
│ ├── repos/ # Multi-repo registry & management
|
||||
│ ├── agentic/ # AI agent task management
|
||||
│ └── mcp/ # Model Context Protocol service
|
||||
├── internal/
|
||||
│ ├── cmd/ # CLI command implementations
|
||||
│ └── variants/ # Build variants (full, minimal, etc.)
|
||||
└── go.mod # Go module definition
|
||||
```
|
||||
|
||||
### Service Pattern (Dual-Constructor DI)
|
||||
|
||||
Every service follows this pattern:
|
||||
|
||||
```go
|
||||
// Static DI - standalone use/testing (no core.Runtime)
|
||||
func New() (*Service, error)
|
||||
|
||||
// Dynamic DI - for core.WithService() registration
|
||||
func Register(c *core.Core) (any, error)
|
||||
```
|
||||
|
||||
Services embed `*core.Runtime[Options]` for access to `Core()` and `Config()`.
|
||||
|
||||
### IPC/Action System
|
||||
|
||||
Services implement `HandleIPCEvents(c *core.Core, msg core.Message) error` - auto-discovered via reflection. Handles typed actions like `core.ActionServiceStartup`.
|
||||
|
||||
---
|
||||
|
||||
## Wails v3 Frontend Bindings
|
||||
|
||||
Core uses [Wails v3](https://v3alpha.wails.io/) to expose Go methods to a WebView2 browser runtime. Wails automatically generates TypeScript bindings for registered services.
|
||||
|
||||
**Documentation:** [Wails v3 Method Bindings](https://v3alpha.wails.io/features/bindings/methods/)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Go services** with exported methods are registered with Wails
|
||||
2. Run `wails3 generate bindings` (or `wails3 dev` / `wails3 build`)
|
||||
3. **TypeScript SDK** is generated in `frontend/bindings/`
|
||||
4. Frontend calls Go methods with full type safety, no HTTP overhead
|
||||
|
||||
### Current Binding Architecture
|
||||
|
||||
```go
|
||||
// cmd/core-gui/main.go
|
||||
app.RegisterService(application.NewService(coreService)) // Only Core is registered
|
||||
```
|
||||
|
||||
**Problem:** Only `Core` is registered with Wails. Sub-services (crypt, workspace, display, etc.) are internal to Core's service map - their methods aren't directly exposed to JS.
|
||||
|
||||
**Currently exposed** (see `cmd/core-gui/public/bindings/`):
|
||||
```typescript
|
||||
// From frontend:
|
||||
import { ACTION, Config, Service } from './bindings/forge.lthn.ai/core/cli/pkg/core'
|
||||
|
||||
ACTION(msg) // Broadcast IPC message
|
||||
Config() // Get config service reference
|
||||
Service("workspace") // Get service by name (returns any)
|
||||
```
|
||||
|
||||
**NOT exposed:** Direct calls like `workspace.CreateWorkspace()` or `crypt.Hash()`.
|
||||
|
||||
## Configuration Management
|
||||
|
||||
Core uses a **centralized configuration service** implemented in `pkg/config`, with YAML-based persistence and layered overrides.
|
||||
|
||||
The `pkg/config` package provides:
|
||||
|
||||
- YAML-backed persistence at `~/.core/config.yaml`
|
||||
- Dot-notation key access (for example: `cfg.Set("dev.editor", "vim")`, `cfg.GetString("dev.editor")`)
|
||||
- Environment variable overlay support (env vars can override persisted values)
|
||||
- Thread-safe operations for concurrent reads/writes
|
||||
|
||||
Application code should treat `pkg/config` as the **primary configuration mechanism**. Direct reads/writes to YAML files should generally be avoided from application logic in favour of using this centralized service.
|
||||
|
||||
### Project and Service Configuration Files
|
||||
|
||||
In addition to the centralized configuration service, Core uses several YAML files for project-specific build/CI and service configuration. These live alongside (but are distinct from) the centralized configuration:
|
||||
|
||||
- **Project Configuration** (in the `.core/` directory of the project root):
|
||||
- `build.yaml`: Build targets, flags, and project metadata.
|
||||
- `release.yaml`: Release automation, changelog settings, and publishing targets.
|
||||
- `ci.yaml`: CI pipeline configuration.
|
||||
- **Global Configuration** (in the `~/.core/` directory):
|
||||
- `config.yaml`: Centralized user/framework settings and defaults, managed via `pkg/config`.
|
||||
- `agentic.yaml`: Configuration for agentic services (BaseURL, Token, etc.).
|
||||
- **Registry Configuration** (`repos.yaml`, auto-discovered):
|
||||
- Multi-repo registry definition.
|
||||
- Searched in the current directory and its parent directories (walking up).
|
||||
- Then in `~/Code/host-uk/repos.yaml`.
|
||||
- Finally in `~/.config/core/repos.yaml`.
|
||||
|
||||
### Format
|
||||
|
||||
All persisted configuration files described above use **YAML** format for readability and nested structure support.
|
||||
|
||||
### The IPC Bridge Pattern (Chosen Architecture)
|
||||
|
||||
Sub-services are accessed via Core's **IPC/ACTION system**, not direct Wails bindings:
|
||||
|
||||
```typescript
|
||||
// Frontend calls Core.ACTION() with typed messages
|
||||
import { ACTION } from './bindings/forge.lthn.ai/core/cli/pkg/core'
|
||||
|
||||
// Open a window
|
||||
ACTION({ action: "display.open_window", name: "settings", options: { Title: "Settings", Width: 800 } })
|
||||
|
||||
// Switch workspace
|
||||
ACTION({ action: "workspace.switch_workspace", name: "myworkspace" })
|
||||
```
|
||||
|
||||
Each service implements `HandleIPCEvents(c *core.Core, msg core.Message)` to process these messages:
|
||||
|
||||
```go
|
||||
// pkg/display/display.go
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case map[string]any:
|
||||
if action, ok := m["action"].(string); ok && action == "display.open_window" {
|
||||
return s.handleOpenWindowAction(m)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Why this pattern:**
|
||||
- Single Wails service (Core) = simpler binding generation
|
||||
- Services remain decoupled from Wails
|
||||
- Centralized message routing via `ACTION()`
|
||||
- Services can communicate internally using same pattern
|
||||
|
||||
**Current gap:** Not all service methods have IPC handlers yet. See `HandleIPCEvents` in each service to understand what's wired up.
|
||||
|
||||
### Generating Bindings
|
||||
|
||||
Wails v3 bindings are typically generated in the GUI repository (e.g., `core-gui`).
|
||||
|
||||
```bash
|
||||
wails3 generate bindings # Regenerate after Go changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Service Interfaces (`pkg/framework/core/interfaces.go`)
|
||||
|
||||
```go
|
||||
type Config interface {
|
||||
Get(key string, out any) error
|
||||
Set(key string, v any) error
|
||||
}
|
||||
|
||||
type Display interface {
|
||||
OpenWindow(opts ...WindowOption) error
|
||||
}
|
||||
|
||||
type Workspace interface {
|
||||
CreateWorkspace(identifier, password string) (string, error)
|
||||
SwitchWorkspace(name string) error
|
||||
WorkspaceFileGet(filename string) (string, error)
|
||||
WorkspaceFileSet(filename, content string) error
|
||||
}
|
||||
|
||||
type Crypt interface {
|
||||
EncryptPGP(writer io.Writer, recipientPath, data string, ...) (string, error)
|
||||
DecryptPGP(recipientPath, message, passphrase string, ...) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State (Prototype)
|
||||
|
||||
### Working
|
||||
|
||||
| Package | Notes |
|
||||
|---------|-------|
|
||||
| `pkg/framework/core` | Service container, DI, thread-safe - solid |
|
||||
| `pkg/config` | Layered YAML configuration, XDG paths - solid |
|
||||
| `pkg/crypt` | Hashing, checksums, symmetric/asymmetric - solid, well-tested |
|
||||
| `pkg/help` | Embedded docs, full-text search - solid |
|
||||
| `pkg/i18n` | Multi-language with go-i18n - solid |
|
||||
| `pkg/io` | Medium interface + local backend - solid |
|
||||
| `pkg/repos` | Multi-repo registry & management - solid |
|
||||
| `pkg/agentic` | AI agent task management - solid |
|
||||
| `pkg/mcp` | Model Context Protocol service - solid |
|
||||
|
||||
---
|
||||
|
||||
## Package Deep Dives
|
||||
|
||||
### pkg/crypt
|
||||
|
||||
The crypt package provides a comprehensive suite of cryptographic primitives:
|
||||
- **Hashing & Checksums**: SHA-256, SHA-512, and CRC32 support.
|
||||
- **Symmetric Encryption**: AES-GCM and ChaCha20-Poly1305 for secure data at rest.
|
||||
- **Key Derivation**: Argon2id for secure password hashing.
|
||||
- **Asymmetric Encryption**: PGP implementation in the `pkg/crypt/openpgp` subpackage using `github.com/ProtonMail/go-crypto`.
|
||||
|
||||
### pkg/io - Storage Abstraction
|
||||
|
||||
```go
|
||||
type Medium interface {
|
||||
Read(path string) (string, error)
|
||||
Write(path, content string) error
|
||||
EnsureDir(path string) error
|
||||
IsFile(path string) bool
|
||||
FileGet(path string) (string, error)
|
||||
FileSet(path, content string) error
|
||||
}
|
||||
```
|
||||
|
||||
Implementations: `local/`, `sftp/`, `webdav/`
|
||||
|
||||
---
|
||||
|
||||
## Future Work
|
||||
|
||||
### Phase 1: Core Stability
|
||||
- [x] ~~Fix workspace medium injection (critical blocker)~~
|
||||
- [x] ~~Initialize `io.Local` global~~
|
||||
- [x] ~~Clean up dead code (orphaned vars, broken wrappers)~~
|
||||
- [x] ~~Wire up IPC handlers for all services (config, crypt, display, help, i18n, workspace)~~
|
||||
- [x] ~~Complete display menu handlers (New/List workspace)~~
|
||||
- [x] ~~Tray icon setup with asset embedding~~
|
||||
- [x] ~~Test coverage for io packages~~
|
||||
- [ ] System tray brand-specific menus
|
||||
|
||||
### Phase 2: Multi-Brand Support
|
||||
- [ ] Define brand configuration system (config? build flags?)
|
||||
- [ ] Implement brand-specific tray menus (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub)
|
||||
- [ ] Brand-specific theming/assets
|
||||
- [ ] Per-brand default workspace configurations
|
||||
|
||||
### Phase 3: Remote Storage
|
||||
- [ ] Complete SFTP backend (`pkg/io/sftp/`)
|
||||
- [ ] Complete WebDAV backend (`pkg/io/webdav/`)
|
||||
- [ ] Workspace sync across storage backends
|
||||
- [ ] Conflict resolution for multi-device access
|
||||
|
||||
### Phase 4: Enhanced Crypto
|
||||
- [ ] Key management UI (import/export, key rotation)
|
||||
- [ ] Multi-recipient encryption
|
||||
- [ ] Hardware key support (YubiKey, etc.)
|
||||
- [ ] Encrypted workspace backup/restore
|
||||
|
||||
### Phase 5: Developer Experience
|
||||
- [ ] TypeScript types for IPC messages (codegen from Go structs)
|
||||
- [ ] Hot-reload for service registration
|
||||
- [ ] Plugin system for third-party services
|
||||
- [ ] CLI tooling for workspace management
|
||||
|
||||
### Phase 6: Distribution
|
||||
- [ ] Auto-update mechanism
|
||||
- [ ] Platform installers (DMG, MSI, AppImage)
|
||||
- [ ] Signing and notarization
|
||||
- [ ] Crash reporting integration
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **[User Guide](docs/user-guide.md)**: Detailed usage and concepts.
|
||||
- **[FAQ](docs/faq.md)**: Frequently asked questions.
|
||||
- **[Workflows](docs/workflows.md)**: Common task sequences.
|
||||
- **[Troubleshooting](docs/troubleshooting.md)**: Solving common issues.
|
||||
- **[Configuration](docs/configuration.md)**: Config file reference.
|
||||
|
||||
```bash
|
||||
# Check environment
|
||||
core doctor
|
||||
|
||||
# Command help
|
||||
core <command> --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For New Contributors
|
||||
|
||||
1. Run `task test` to verify all tests pass
|
||||
2. Follow TDD: `task test-gen` creates stubs, implement to pass
|
||||
3. The dual-constructor pattern is intentional: `New(deps)` for tests, `Register()` for runtime
|
||||
4. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge
|
||||
EUPL-1.2
|
||||
|
|
|
|||
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()))
|
||||
}
|
||||
93
app.go
Normal file
93
app.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Application identity for the Core framework.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"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 string
|
||||
Version string
|
||||
Description string
|
||||
Filename string
|
||||
Path string
|
||||
Runtime any // GUI runtime (e.g., Wails App). Nil for CLI-only.
|
||||
}
|
||||
|
||||
// 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.App{}.Find("node", "Node.js")
|
||||
// if r.OK { app := r.Value.(*App) }
|
||||
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}
|
||||
}
|
||||
|
||||
// Search PATH
|
||||
pathEnv := os.Getenv("PATH")
|
||||
if pathEnv == "" {
|
||||
return Result{E("app.Find", "PATH is empty", nil), false}
|
||||
}
|
||||
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
|
||||
}
|
||||
68
app_test.go
Normal file
68
app_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- App.New ---
|
||||
|
||||
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_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()
|
||||
c.App().Runtime = &struct{ Name string }{Name: "wails"}
|
||||
assert.NotNil(t, c.App().Runtime)
|
||||
}
|
||||
|
||||
// --- App.Find ---
|
||||
|
||||
func TestApp_Find_Good(t *testing.T) {
|
||||
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 := App{}.Find("nonexistent-binary-xyz", "test")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
101
array.go
Normal file
101
array.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Generic slice operations for the Core framework.
|
||||
// Based on leaanthony/slicer, rewritten with Go 1.18+ generics.
|
||||
|
||||
package core
|
||||
|
||||
// Array is a typed slice with common operations.
|
||||
type Array[T comparable] struct {
|
||||
items []T
|
||||
}
|
||||
|
||||
// NewArray creates an empty Array.
|
||||
func NewArray[T comparable](items ...T) *Array[T] {
|
||||
return &Array[T]{items: items}
|
||||
}
|
||||
|
||||
// Add appends values.
|
||||
func (s *Array[T]) Add(values ...T) {
|
||||
s.items = append(s.items, values...)
|
||||
}
|
||||
|
||||
// AddUnique appends values only if not already present.
|
||||
func (s *Array[T]) AddUnique(values ...T) {
|
||||
for _, v := range values {
|
||||
if !s.Contains(v) {
|
||||
s.items = append(s.items, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contains returns true if the value is in the slice.
|
||||
func (s *Array[T]) Contains(val T) bool {
|
||||
for _, v := range s.items {
|
||||
if v == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter returns a new Array with elements matching the predicate.
|
||||
func (s *Array[T]) Filter(fn func(T) bool) Result {
|
||||
filtered := &Array[T]{}
|
||||
for _, v := range s.items {
|
||||
if fn(v) {
|
||||
filtered.items = append(filtered.items, v)
|
||||
}
|
||||
}
|
||||
return Result{filtered, true}
|
||||
}
|
||||
|
||||
// Each runs a function on every element.
|
||||
func (s *Array[T]) Each(fn func(T)) {
|
||||
for _, v := range s.items {
|
||||
fn(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove removes the first occurrence of a value.
|
||||
func (s *Array[T]) Remove(val T) {
|
||||
for i, v := range s.items {
|
||||
if v == val {
|
||||
s.items = append(s.items[:i], s.items[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate removes duplicate values, preserving order.
|
||||
func (s *Array[T]) Deduplicate() {
|
||||
seen := make(map[T]struct{})
|
||||
result := make([]T, 0, len(s.items))
|
||||
for _, v := range s.items {
|
||||
if _, exists := seen[v]; !exists {
|
||||
seen[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
s.items = result
|
||||
}
|
||||
|
||||
// Len returns the number of elements.
|
||||
func (s *Array[T]) Len() int {
|
||||
return len(s.items)
|
||||
}
|
||||
|
||||
// Clear removes all elements.
|
||||
func (s *Array[T]) Clear() {
|
||||
s.items = nil
|
||||
}
|
||||
|
||||
// AsSlice returns a copy of the underlying slice.
|
||||
func (s *Array[T]) AsSlice() []T {
|
||||
if s.items == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]T, len(s.items))
|
||||
copy(out, s.items)
|
||||
return out
|
||||
}
|
||||
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
|
||||
}
|
||||
90
array_test.go
Normal file
90
array_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Array[T] ---
|
||||
|
||||
func TestArray_New_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "c")
|
||||
assert.Equal(t, 3, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Add_Good(t *testing.T) {
|
||||
a := NewArray[string]()
|
||||
a.Add("x", "y")
|
||||
assert.Equal(t, 2, a.Len())
|
||||
assert.True(t, a.Contains("x"))
|
||||
assert.True(t, a.Contains("y"))
|
||||
}
|
||||
|
||||
func TestArray_AddUnique_Good(t *testing.T) {
|
||||
a := NewArray("a", "b")
|
||||
a.AddUnique("b", "c")
|
||||
assert.Equal(t, 3, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Contains_Good(t *testing.T) {
|
||||
a := NewArray(1, 2, 3)
|
||||
assert.True(t, a.Contains(2))
|
||||
assert.False(t, a.Contains(99))
|
||||
}
|
||||
|
||||
func TestArray_Filter_Good(t *testing.T) {
|
||||
a := NewArray(1, 2, 3, 4, 5)
|
||||
r := a.Filter(func(n int) bool { return n%2 == 0 })
|
||||
assert.True(t, r.OK)
|
||||
evens := r.Value.(*Array[int])
|
||||
assert.Equal(t, 2, evens.Len())
|
||||
assert.True(t, evens.Contains(2))
|
||||
assert.True(t, evens.Contains(4))
|
||||
}
|
||||
|
||||
func TestArray_Each_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "c")
|
||||
var collected []string
|
||||
a.Each(func(s string) { collected = append(collected, s) })
|
||||
assert.Equal(t, []string{"a", "b", "c"}, collected)
|
||||
}
|
||||
|
||||
func TestArray_Remove_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "c")
|
||||
a.Remove("b")
|
||||
assert.Equal(t, 2, a.Len())
|
||||
assert.False(t, a.Contains("b"))
|
||||
}
|
||||
|
||||
func TestArray_Remove_Bad(t *testing.T) {
|
||||
a := NewArray("a", "b")
|
||||
a.Remove("missing")
|
||||
assert.Equal(t, 2, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Deduplicate_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "a", "c", "b")
|
||||
a.Deduplicate()
|
||||
assert.Equal(t, 3, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Clear_Good(t *testing.T) {
|
||||
a := NewArray(1, 2, 3)
|
||||
a.Clear()
|
||||
assert.Equal(t, 0, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_AsSlice_Good(t *testing.T) {
|
||||
a := NewArray("x", "y")
|
||||
s := a.AsSlice()
|
||||
assert.Equal(t, []string{"x", "y"}, s)
|
||||
}
|
||||
|
||||
func TestArray_Empty_Good(t *testing.T) {
|
||||
a := NewArray[int]()
|
||||
assert.Equal(t, 0, a.Len())
|
||||
assert.False(t, a.Contains(0))
|
||||
assert.Equal(t, []int(nil), a.AsSlice())
|
||||
}
|
||||
166
cli.go
Normal file
166
cli.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Cli is the CLI surface layer for the Core command tree.
|
||||
//
|
||||
// c := core.New(core.WithOption("name", "myapp")).Value.(*Core)
|
||||
// c.Command("deploy", core.Command{Action: handler})
|
||||
// c.Cli().Run()
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CliOptions holds configuration for the Cli service.
|
||||
type CliOptions struct{}
|
||||
|
||||
// Cli is the CLI surface for the Core command tree.
|
||||
type Cli struct {
|
||||
*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")
|
||||
func (cl *Cli) Print(format string, args ...any) {
|
||||
Print(cl.output, format, args...)
|
||||
}
|
||||
|
||||
// SetOutput sets the CLI output writer.
|
||||
//
|
||||
// c.Cli().SetOutput(os.Stderr)
|
||||
func (cl *Cli) SetOutput(w io.Writer) {
|
||||
cl.output = w
|
||||
}
|
||||
|
||||
// Run resolves os.Args to a command path and executes it.
|
||||
//
|
||||
// c.Cli().Run()
|
||||
// c.Cli().Run("deploy", "to", "homelab")
|
||||
func (cl *Cli) Run(args ...string) Result {
|
||||
if len(args) == 0 {
|
||||
args = os.Args[1:]
|
||||
}
|
||||
|
||||
clean := FilterArgs(args)
|
||||
c := cl.Core()
|
||||
|
||||
if c == nil || c.commands == nil {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
if c.commands.Len() == 0 {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Resolve command path from args
|
||||
var cmd *Command
|
||||
var remaining []string
|
||||
|
||||
for i := len(clean); i > 0; i-- {
|
||||
path := JoinPath(clean[:i]...)
|
||||
if r := c.commands.Get(path); r.OK {
|
||||
cmd = r.Value.(*Command)
|
||||
remaining = clean[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cmd == nil {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
cl.PrintHelp()
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Build options from remaining args
|
||||
opts := NewOptions()
|
||||
for _, arg := range remaining {
|
||||
key, val, valid := ParseFlag(arg)
|
||||
if valid {
|
||||
if Contains(arg, "=") {
|
||||
opts.Set(key, val)
|
||||
} else {
|
||||
opts.Set(key, true)
|
||||
}
|
||||
} else if !IsFlag(arg) {
|
||||
opts.Set("_arg", arg)
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Action != nil {
|
||||
return cmd.Run(opts)
|
||||
}
|
||||
return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
|
||||
}
|
||||
|
||||
// PrintHelp prints available commands.
|
||||
//
|
||||
// c.Cli().PrintHelp()
|
||||
func (cl *Cli) PrintHelp() {
|
||||
c := cl.Core()
|
||||
if c == nil || c.commands == nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := ""
|
||||
if c.app != nil {
|
||||
name = c.app.Name
|
||||
}
|
||||
if name != "" {
|
||||
cl.Print("%s commands:", name)
|
||||
} else {
|
||||
cl.Print("Commands:")
|
||||
}
|
||||
|
||||
c.commands.Each(func(path string, cmd *Command) {
|
||||
if cmd.Hidden || (cmd.Action == nil && !cmd.IsManaged()) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
//
|
||||
// c.Cli().SetBanner(func(_ *core.Cli) string { return "My App v1.0" })
|
||||
func (cl *Cli) SetBanner(fn func(*Cli) string) {
|
||||
cl.banner = fn
|
||||
}
|
||||
|
||||
// Banner returns the banner string.
|
||||
func (cl *Cli) Banner() string {
|
||||
if cl.banner != nil {
|
||||
return cl.banner(cl)
|
||||
}
|
||||
c := cl.Core()
|
||||
if c != nil && c.app != nil && c.app.Name != "" {
|
||||
return c.app.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
85
cli_test.go
Normal file
85
cli_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Cli Surface ---
|
||||
|
||||
func TestCli_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.Cli())
|
||||
}
|
||||
|
||||
func TestCli_Banner_Good(t *testing.T) {
|
||||
c := New(WithOption("name", "myapp"))
|
||||
assert.Equal(t, "myapp", c.Cli().Banner())
|
||||
}
|
||||
|
||||
func TestCli_SetBanner_Good(t *testing.T) {
|
||||
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()
|
||||
executed := false
|
||||
c.Command("hello", Command{Action: func(_ Options) Result {
|
||||
executed = true
|
||||
return Result{Value: "world", OK: true}
|
||||
}})
|
||||
r := c.Cli().Run("hello")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "world", r.Value)
|
||||
assert.True(t, executed)
|
||||
}
|
||||
|
||||
func TestCli_Run_Nested_Good(t *testing.T) {
|
||||
c := New()
|
||||
executed := false
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
|
||||
executed = true
|
||||
return Result{OK: true}
|
||||
}})
|
||||
r := c.Cli().Run("deploy", "to", "homelab")
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, executed)
|
||||
}
|
||||
|
||||
func TestCli_Run_WithFlags_Good(t *testing.T) {
|
||||
c := New()
|
||||
var received Options
|
||||
c.Command("serve", Command{Action: func(opts Options) Result {
|
||||
received = opts
|
||||
return Result{OK: true}
|
||||
}})
|
||||
c.Cli().Run("serve", "--port=8080", "--debug")
|
||||
assert.Equal(t, "8080", received.String("port"))
|
||||
assert.True(t, received.Bool("debug"))
|
||||
}
|
||||
|
||||
func TestCli_Run_NoCommand_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Cli().Run()
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCli_PrintHelp_Good(t *testing.T) {
|
||||
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()
|
||||
var buf bytes.Buffer
|
||||
c.Cli().SetOutput(&buf)
|
||||
c.Cli().Print("hello %s", "world")
|
||||
assert.Contains(t, buf.String(), "hello world")
|
||||
}
|
||||
163
command.go
Normal file
163
command.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Command is a DTO representing an executable operation.
|
||||
// Commands don't know if they're root, child, or nested — the tree
|
||||
// structure comes from composition via path-based registration.
|
||||
//
|
||||
// Register a command:
|
||||
//
|
||||
// c.Command("deploy", func(opts core.Options) core.Result {
|
||||
// return core.Result{"deployed", true}
|
||||
// })
|
||||
//
|
||||
// Register a nested command:
|
||||
//
|
||||
// c.Command("deploy/to/homelab", handler)
|
||||
//
|
||||
// Description is an i18n key — derived from path if omitted:
|
||||
//
|
||||
// "deploy" → "cmd.deploy.description"
|
||||
// "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
|
||||
package core
|
||||
|
||||
|
||||
// CommandAction is the function signature for command handlers.
|
||||
//
|
||||
// func(opts core.Options) core.Result
|
||||
type CommandAction func(Options) 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
|
||||
Managed string // "" = one-shot, "process.daemon" = managed lifecycle
|
||||
Flags Options // declared flags
|
||||
Hidden bool
|
||||
commands map[string]*Command // child commands (internal)
|
||||
}
|
||||
|
||||
// I18nKey returns the i18n key for this command's description.
|
||||
//
|
||||
// cmd with path "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
|
||||
func (cmd *Command) I18nKey() string {
|
||||
if cmd.Description != "" {
|
||||
return cmd.Description
|
||||
}
|
||||
path := cmd.Path
|
||||
if path == "" {
|
||||
path = cmd.Name
|
||||
}
|
||||
return Concat("cmd.", Replace(path, "/", "."), ".description")
|
||||
}
|
||||
|
||||
// Run executes the command's action with the given options.
|
||||
//
|
||||
// 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}
|
||||
}
|
||||
return cmd.Action(opts)
|
||||
}
|
||||
|
||||
// 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. Embeds Registry[*Command]
|
||||
// for thread-safe named storage with insertion order.
|
||||
type CommandRegistry struct {
|
||||
*Registry[*Command]
|
||||
}
|
||||
|
||||
// Command gets or registers a command by path.
|
||||
//
|
||||
// c.Command("deploy", Command{Action: handler})
|
||||
// r := c.Command("deploy")
|
||||
func (c *Core) Command(path string, command ...Command) Result {
|
||||
if len(command) == 0 {
|
||||
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}
|
||||
}
|
||||
|
||||
// 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]
|
||||
cmd.Name = pathName(path)
|
||||
cmd.Path = path
|
||||
if cmd.commands == nil {
|
||||
cmd.commands = make(map[string]*Command)
|
||||
}
|
||||
|
||||
// Preserve existing subtree when overwriting a placeholder parent
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 !c.commands.Has(parentPath) {
|
||||
c.commands.Set(parentPath, &Command{
|
||||
Name: parts[i-1],
|
||||
Path: parentPath,
|
||||
commands: make(map[string]*Command),
|
||||
})
|
||||
}
|
||||
parent := c.commands.Get(parentPath).Value.(*Command)
|
||||
parent.commands[parts[i]] = cmd
|
||||
cmd = parent
|
||||
}
|
||||
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Commands returns all registered command paths in registration order.
|
||||
//
|
||||
// paths := c.Commands()
|
||||
func (c *Core) Commands() []string {
|
||||
if c.commands == nil {
|
||||
return nil
|
||||
}
|
||||
return c.commands.Names()
|
||||
}
|
||||
|
||||
// pathName extracts the last segment of a path.
|
||||
// "deploy/to/homelab" → "homelab"
|
||||
func pathName(path string) string {
|
||||
parts := Split(path, "/")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
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]
|
||||
}
|
||||
167
command_test.go
Normal file
167
command_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Command DTO ---
|
||||
|
||||
func TestCommand_Register_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Command("deploy", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed", OK: true}
|
||||
}})
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCommand_Get_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
r := c.Command("deploy")
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestCommand_Get_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Command("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCommand_Run_Good(t *testing.T) {
|
||||
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(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()
|
||||
c.Command("empty", Command{Description: "no action"})
|
||||
cmd := c.Command("empty").Value.(*Command)
|
||||
r := cmd.Run(NewOptions())
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Nested Commands ---
|
||||
|
||||
func TestCommand_Nested_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed to homelab", OK: true}
|
||||
}})
|
||||
|
||||
r := c.Command("deploy/to/homelab")
|
||||
assert.True(t, r.OK)
|
||||
|
||||
// Parent auto-created
|
||||
assert.True(t, c.Command("deploy").OK)
|
||||
assert.True(t, c.Command("deploy/to").OK)
|
||||
}
|
||||
|
||||
func TestCommand_Paths_Good(t *testing.T) {
|
||||
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} }})
|
||||
|
||||
paths := c.Commands()
|
||||
assert.Contains(t, paths, "deploy")
|
||||
assert.Contains(t, paths, "serve")
|
||||
assert.Contains(t, paths, "deploy/to/homelab")
|
||||
assert.Contains(t, paths, "deploy/to")
|
||||
}
|
||||
|
||||
// --- I18n Key Derivation ---
|
||||
|
||||
func TestCommand_I18nKey_Good(t *testing.T) {
|
||||
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()
|
||||
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()
|
||||
c.Command("serve", Command{})
|
||||
cmd := c.Command("serve").Value.(*Command)
|
||||
assert.Equal(t, "cmd.serve.description", cmd.I18nKey())
|
||||
}
|
||||
|
||||
// --- Managed ---
|
||||
|
||||
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)
|
||||
assert.True(t, cmd.IsManaged())
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
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 Managed ---
|
||||
|
||||
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, ran)
|
||||
}
|
||||
|
||||
func TestCli_Run_NoAction_Bad(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("empty", Command{})
|
||||
r := c.Cli().Run("empty")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Empty path ---
|
||||
|
||||
func TestCommand_EmptyPath_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Command("", Command{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
186
config.go
Normal file
186
config.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Settings, feature flags, and typed configuration for the Core framework.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConfigVar is a variable that can be set, unset, and queried for its state.
|
||||
type ConfigVar[T any] struct {
|
||||
val T
|
||||
set bool
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// ConfigOptions holds configuration data.
|
||||
type ConfigOptions struct {
|
||||
Settings map[string]any
|
||||
Features map[string]bool
|
||||
}
|
||||
|
||||
func (o *ConfigOptions) init() {
|
||||
if o.Settings == nil {
|
||||
o.Settings = make(map[string]any)
|
||||
}
|
||||
if o.Features == nil {
|
||||
o.Features = make(map[string]bool)
|
||||
}
|
||||
}
|
||||
|
||||
// Config holds configuration settings and feature flags.
|
||||
type Config struct {
|
||||
*ConfigOptions
|
||||
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()
|
||||
if e.ConfigOptions == nil {
|
||||
e.ConfigOptions = &ConfigOptions{}
|
||||
}
|
||||
e.ConfigOptions.init()
|
||||
e.Settings[key] = val
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value by key.
|
||||
func (e *Config) Get(key string) Result {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
if e.ConfigOptions == nil || e.Settings == nil {
|
||||
return Result{}
|
||||
}
|
||||
val, ok := e.Settings[key]
|
||||
if !ok {
|
||||
return 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) }
|
||||
|
||||
// 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 {
|
||||
r := e.Get(key)
|
||||
if !r.OK {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
typed, _ := r.Value.(T)
|
||||
return typed
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
e.ConfigOptions = &ConfigOptions{}
|
||||
}
|
||||
e.ConfigOptions.init()
|
||||
e.Features[feature] = true
|
||||
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 {
|
||||
e.ConfigOptions = &ConfigOptions{}
|
||||
}
|
||||
e.ConfigOptions.init()
|
||||
e.Features[feature] = false
|
||||
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()
|
||||
if e.ConfigOptions == nil || e.Features == nil {
|
||||
return false
|
||||
}
|
||||
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()
|
||||
if e.ConfigOptions == nil || e.Features == nil {
|
||||
return nil
|
||||
}
|
||||
var result []string
|
||||
for k, v := range e.Features {
|
||||
if v {
|
||||
result = append(result, k)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
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
|
||||
}
|
||||
102
config_test.go
Normal file
102
config_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Config ---
|
||||
|
||||
func TestConfig_SetGet_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Set("api_url", "https://api.lthn.ai")
|
||||
c.Config().Set("max_agents", 5)
|
||||
|
||||
r := c.Config().Get("api_url")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "https://api.lthn.ai", r.Value)
|
||||
}
|
||||
|
||||
func TestConfig_Get_Bad(t *testing.T) {
|
||||
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()
|
||||
c.Config().Set("url", "https://lthn.ai")
|
||||
c.Config().Set("port", 8080)
|
||||
c.Config().Set("debug", true)
|
||||
|
||||
assert.Equal(t, "https://lthn.ai", c.Config().String("url"))
|
||||
assert.Equal(t, 8080, c.Config().Int("port"))
|
||||
assert.True(t, c.Config().Bool("debug"))
|
||||
}
|
||||
|
||||
func TestConfig_TypedAccessors_Bad(t *testing.T) {
|
||||
c := New()
|
||||
// Missing keys return zero values
|
||||
assert.Equal(t, "", c.Config().String("missing"))
|
||||
assert.Equal(t, 0, c.Config().Int("missing"))
|
||||
assert.False(t, c.Config().Bool("missing"))
|
||||
}
|
||||
|
||||
// --- Feature Flags ---
|
||||
|
||||
func TestConfig_Features_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Enable("dark-mode")
|
||||
c.Config().Enable("beta")
|
||||
|
||||
assert.True(t, c.Config().Enabled("dark-mode"))
|
||||
assert.True(t, c.Config().Enabled("beta"))
|
||||
assert.False(t, c.Config().Enabled("missing"))
|
||||
}
|
||||
|
||||
func TestConfig_Features_Disable_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Enable("feature")
|
||||
assert.True(t, c.Config().Enabled("feature"))
|
||||
|
||||
c.Config().Disable("feature")
|
||||
assert.False(t, c.Config().Enabled("feature"))
|
||||
}
|
||||
|
||||
func TestConfig_Features_CaseSensitive(t *testing.T) {
|
||||
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()
|
||||
c.Config().Enable("a")
|
||||
c.Config().Enable("b")
|
||||
c.Config().Enable("c")
|
||||
c.Config().Disable("b")
|
||||
|
||||
features := c.Config().EnabledFeatures()
|
||||
assert.Contains(t, features, "a")
|
||||
assert.Contains(t, features, "c")
|
||||
assert.NotContains(t, features, "b")
|
||||
}
|
||||
|
||||
// --- ConfigVar ---
|
||||
|
||||
func TestConfig_ConfigVar_Good(t *testing.T) {
|
||||
v := NewConfigVar("hello")
|
||||
assert.True(t, v.IsSet())
|
||||
assert.Equal(t, "hello", v.Get())
|
||||
|
||||
v.Set("world")
|
||||
assert.Equal(t, "world", v.Get())
|
||||
|
||||
v.Unset()
|
||||
assert.False(t, v.IsSet())
|
||||
assert.Equal(t, "", v.Get())
|
||||
}
|
||||
226
contract.go
Normal file
226
contract.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Contracts, options, and type definitions for the Core framework.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Message is the type for IPC broadcasts (fire-and-forget).
|
||||
type Message any
|
||||
|
||||
// Query is the type for read-only IPC requests.
|
||||
type Query any
|
||||
|
||||
// QueryHandler handles Query requests. Returns Result{Value, OK}.
|
||||
type QueryHandler func(*Core, Query) 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) 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) Result
|
||||
}
|
||||
|
||||
// --- Action Messages ---
|
||||
|
||||
type ActionServiceStartup struct{}
|
||||
type ActionServiceShutdown struct{}
|
||||
|
||||
type ActionTaskStarted struct {
|
||||
TaskIdentifier string
|
||||
Action string
|
||||
Options Options
|
||||
}
|
||||
|
||||
type ActionTaskProgress struct {
|
||||
TaskIdentifier string
|
||||
Action string
|
||||
Progress float64
|
||||
Message string
|
||||
}
|
||||
|
||||
type ActionTaskCompleted struct {
|
||||
TaskIdentifier string
|
||||
Action string
|
||||
Result Result
|
||||
}
|
||||
|
||||
// --- Constructor ---
|
||||
|
||||
// CoreOption is a functional option applied during Core construction.
|
||||
// Returns Result — if !OK, New() stops and returns the error.
|
||||
//
|
||||
// core.New(
|
||||
// core.WithService(agentic.Register),
|
||||
// core.WithService(monitor.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
type CoreOption func(*Core) Result
|
||||
|
||||
// New initialises a Core instance by applying options in order.
|
||||
// Services registered here form the application conclave — they share
|
||||
// IPC access and participate in the lifecycle (ServiceStartup/ServiceShutdown).
|
||||
//
|
||||
// c := core.New(
|
||||
// core.WithOption("name", "myapp"),
|
||||
// core.WithService(auth.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
// c.Run()
|
||||
func New(opts ...CoreOption) *Core {
|
||||
c := &Core{
|
||||
app: &App{},
|
||||
data: &Data{Registry: NewRegistry[*Embed]()},
|
||||
drive: &Drive{Registry: NewRegistry[*DriveHandle]()},
|
||||
fs: (&Fs{}).New("/"),
|
||||
config: (&Config{}).New(),
|
||||
error: &ErrorPanic{},
|
||||
log: &ErrorLog{},
|
||||
lock: &Lock{locks: NewRegistry[*sync.RWMutex]()},
|
||||
ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*Task]()},
|
||||
info: systemInfo,
|
||||
i18n: &I18n{},
|
||||
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.api.core = c
|
||||
|
||||
// Core services
|
||||
CliRegister(c)
|
||||
|
||||
for _, opt := range opts {
|
||||
if r := opt(c); !r.OK {
|
||||
Error("core.New failed", "err", r.Value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Apply service lock after all opts — v0.3.3 parity
|
||||
c.LockApply()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// WithOptions applies key-value configuration to Core.
|
||||
//
|
||||
// core.WithOptions(core.NewOptions(core.Option{Key: "name", Value: "myapp"}))
|
||||
func WithOptions(opts Options) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
c.options = &opts
|
||||
if name := opts.String("name"); name != "" {
|
||||
c.app.Name = name
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
|
||||
// WithService registers a service via its factory function.
|
||||
// If the factory returns a non-nil Value, WithService auto-discovers the
|
||||
// service name from the factory's package path (last path segment, lowercase,
|
||||
// with any "_test" suffix stripped) and calls RegisterService on the instance.
|
||||
// IPC handler auto-registration is handled by RegisterService.
|
||||
//
|
||||
// If the factory returns nil Value (it registered itself), WithService
|
||||
// returns success without a second registration.
|
||||
//
|
||||
// core.WithService(agentic.Register)
|
||||
// core.WithService(display.Register(nil))
|
||||
func WithService(factory func(*Core) Result) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
r := factory(c)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
if r.Value == nil {
|
||||
// Factory self-registered — nothing more to do.
|
||||
return Result{OK: true}
|
||||
}
|
||||
// Auto-discover the service name from the instance's package path.
|
||||
instance := r.Value
|
||||
typeOf := reflect.TypeOf(instance)
|
||||
if typeOf.Kind() == reflect.Ptr {
|
||||
typeOf = typeOf.Elem()
|
||||
}
|
||||
pkgPath := typeOf.PkgPath()
|
||||
parts := Split(pkgPath, "/")
|
||||
name := Lower(parts[len(parts)-1])
|
||||
if name == "" {
|
||||
return Result{E("core.WithService", Sprintf("service name could not be discovered for type %T", instance), nil), false}
|
||||
}
|
||||
|
||||
// RegisterService handles Startable/Stoppable/HandleIPCEvents discovery
|
||||
return c.RegisterService(name, instance)
|
||||
}
|
||||
}
|
||||
|
||||
// WithName registers a service with an explicit name (no reflect discovery).
|
||||
//
|
||||
// core.WithName("ws", func(c *Core) Result {
|
||||
// return Result{Value: hub, OK: true}
|
||||
// })
|
||||
func WithName(name string, factory func(*Core) Result) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
r := factory(c)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
if r.Value == nil {
|
||||
return Result{E("core.WithName", Sprintf("failed to create service %q", name), nil), false}
|
||||
}
|
||||
return c.RegisterService(name, r.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// WithOption is a convenience for setting a single key-value option.
|
||||
//
|
||||
// core.New(
|
||||
// core.WithOption("name", "myapp"),
|
||||
// core.WithOption("port", 8080),
|
||||
// )
|
||||
func WithOption(key string, value any) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
if c.options == nil {
|
||||
opts := NewOptions()
|
||||
c.options = &opts
|
||||
}
|
||||
c.options.Set(key, value)
|
||||
if key == "name" {
|
||||
if s, ok := value.(string); ok {
|
||||
c.app.Name = s
|
||||
}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceLock prevents further service registration after construction.
|
||||
//
|
||||
// core.New(
|
||||
// core.WithService(auth.Register),
|
||||
// core.WithServiceLock(),
|
||||
// )
|
||||
func WithServiceLock() CoreOption {
|
||||
return func(c *Core) Result {
|
||||
c.LockEnable()
|
||||
return Result{OK: true}
|
||||
}
|
||||
}
|
||||
133
contract_test.go
Normal file
133
contract_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- WithService ---
|
||||
|
||||
// stub service used only for name-discovery tests.
|
||||
type stubNamedService struct{}
|
||||
|
||||
// stubFactory is a package-level factory so the runtime function name carries
|
||||
// the package path "core_test.stubFactory" — last segment after '/' is
|
||||
// "core_test", and after stripping a "_test" suffix we get "core".
|
||||
// For a real service package such as "dappco.re/go/agentic" the discovered
|
||||
// name would be "agentic".
|
||||
func stubFactory(c *Core) Result {
|
||||
return Result{Value: &stubNamedService{}, OK: true}
|
||||
}
|
||||
|
||||
// TestWithService_NameDiscovery_Good verifies that WithService discovers the
|
||||
// service name from the factory's package path and registers the instance via
|
||||
// RegisterService, making it retrievable through c.Services().
|
||||
//
|
||||
// stubFactory lives in package "dappco.re/go/core_test", so the last path
|
||||
// segment is "core_test" — WithService strips the "_test" suffix and registers
|
||||
// the service under the name "core".
|
||||
func 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")
|
||||
}
|
||||
239
core.go
Normal file
239
core.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Package core is a dependency injection and service lifecycle framework for Go.
|
||||
// This file defines the Core struct, accessors, and IPC/error wrappers.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// --- Core Struct ---
|
||||
|
||||
// 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 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
|
||||
waitGroup sync.WaitGroup
|
||||
shutdown atomic.Bool
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
// 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 }
|
||||
|
||||
// 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) ---
|
||||
|
||||
// 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 ---
|
||||
|
||||
// LogError logs an error and returns the Result from ErrorLog.
|
||||
func (c *Core) LogError(err error, op, msg string) Result {
|
||||
return c.log.Error(err, op, msg)
|
||||
}
|
||||
|
||||
// LogWarn logs a warning and returns the Result from ErrorLog.
|
||||
func (c *Core) LogWarn(err error, op, msg string) Result {
|
||||
return c.log.Warn(err, op, msg)
|
||||
}
|
||||
|
||||
// Must logs and panics if err is not nil.
|
||||
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 ---
|
||||
245
core_test.go
Normal file
245
core_test.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- New ---
|
||||
|
||||
func TestCore_New_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
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 TestCore_New_WithOptions_Bad(t *testing.T) {
|
||||
// Empty options — should still create a valid Core
|
||||
c := New(WithOptions(NewOptions()))
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestCore_New_WithService_Good(t *testing.T) {
|
||||
started := false
|
||||
c := New(
|
||||
WithOptions(NewOptions(Option{Key: "name", Value: "myapp"})),
|
||||
WithService(func(c *Core) Result {
|
||||
c.Service("test", Service{
|
||||
OnStart: func() Result { started = true; return Result{OK: true} },
|
||||
})
|
||||
return Result{OK: true}
|
||||
}),
|
||||
)
|
||||
|
||||
svc := c.Service("test")
|
||||
assert.True(t, svc.OK)
|
||||
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
assert.True(t, started)
|
||||
}
|
||||
|
||||
func TestCore_New_WithServiceLock_Good(t *testing.T) {
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
c.Service("allowed", Service{})
|
||||
return Result{OK: true}
|
||||
}),
|
||||
WithServiceLock(),
|
||||
)
|
||||
|
||||
// Registration after lock should fail
|
||||
reg := c.Service("blocked", Service{})
|
||||
assert.False(t, reg.OK)
|
||||
}
|
||||
|
||||
func 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 TestCore_Accessors_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.App())
|
||||
assert.NotNil(t, c.Data())
|
||||
assert.NotNil(t, c.Drive())
|
||||
assert.NotNil(t, c.Fs())
|
||||
assert.NotNil(t, c.Config())
|
||||
assert.NotNil(t, c.Error())
|
||||
assert.NotNil(t, c.Log())
|
||||
assert.NotNil(t, c.Cli())
|
||||
assert.NotNil(t, c.IPC())
|
||||
assert.NotNil(t, c.I18n())
|
||||
assert.Equal(t, c, c.Core())
|
||||
}
|
||||
|
||||
func TestOptions_Accessor_Good(t *testing.T) {
|
||||
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"))
|
||||
assert.Equal(t, 8080, opts.Int("port"))
|
||||
assert.True(t, opts.Bool("debug"))
|
||||
}
|
||||
|
||||
func TestOptions_Accessor_Nil(t *testing.T) {
|
||||
c := New()
|
||||
// No options passed — Options() returns nil
|
||||
assert.Nil(t, c.Options())
|
||||
}
|
||||
|
||||
// --- Core Error/Log Helpers ---
|
||||
|
||||
func TestCore_LogError_Good(t *testing.T) {
|
||||
c := New()
|
||||
cause := assert.AnError
|
||||
r := c.LogError(cause, "test.Operation", "something broke")
|
||||
|
||||
err, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestCore_LogWarn_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.LogWarn(assert.AnError, "test.Operation", "heads up")
|
||||
|
||||
_, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestCore_Must_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
assert.Panics(t, func() {
|
||||
c.Must(assert.AnError, "test.Operation", "fatal")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCore_Must_Nil_Good(t *testing.T) {
|
||||
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.
|
||||
168
data.go
Normal file
168
data.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Data is the embedded/stored content system for core packages.
|
||||
// Packages mount their embedded content here and other packages
|
||||
// read from it by path.
|
||||
//
|
||||
// Mount a package's assets:
|
||||
//
|
||||
// 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:
|
||||
//
|
||||
// content := c.Data().ReadString("brain/coding.md")
|
||||
// entries := c.Data().List("agent/flow")
|
||||
//
|
||||
// Extract a template directory:
|
||||
//
|
||||
// c.Data().Extract("agent/workspace/default", "/tmp/ws", data)
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Data manages mounted embedded filesystems from core packages.
|
||||
// Embeds Registry[*Embed] for thread-safe named storage.
|
||||
type Data struct {
|
||||
*Registry[*Embed]
|
||||
}
|
||||
|
||||
// New registers an embedded filesystem under a named prefix.
|
||||
//
|
||||
// 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 == "" {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
r := opts.Get("source")
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
|
||||
fsys, ok := r.Value.(fs.FS)
|
||||
if !ok {
|
||||
return Result{E("data.New", "source is not fs.FS", nil), false}
|
||||
}
|
||||
|
||||
path := opts.String("path")
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
mr := Mount(fsys, path)
|
||||
if !mr.OK {
|
||||
return mr
|
||||
}
|
||||
|
||||
emb := mr.Value.(*Embed)
|
||||
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) {
|
||||
parts := SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil, ""
|
||||
}
|
||||
r := d.Get(parts[0])
|
||||
if !r.OK {
|
||||
return nil, ""
|
||||
}
|
||||
return r.Value.(*Embed), parts[1]
|
||||
}
|
||||
|
||||
// ReadFile reads a file by full path.
|
||||
//
|
||||
// r := c.Data().ReadFile("brain/prompts/coding.md")
|
||||
// if r.OK { data := r.Value.([]byte) }
|
||||
func (d *Data) ReadFile(path string) Result {
|
||||
emb, rel := d.resolve(path)
|
||||
if emb == nil {
|
||||
return Result{}
|
||||
}
|
||||
return emb.ReadFile(rel)
|
||||
}
|
||||
|
||||
// ReadString reads a file as a string.
|
||||
//
|
||||
// r := c.Data().ReadString("agent/flow/deploy/to/homelab.yaml")
|
||||
// if r.OK { content := r.Value.(string) }
|
||||
func (d *Data) ReadString(path string) Result {
|
||||
r := d.ReadFile(path)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{string(r.Value.([]byte)), true}
|
||||
}
|
||||
|
||||
// List returns directory entries at a path.
|
||||
//
|
||||
// r := c.Data().List("agent/persona/code")
|
||||
// if r.OK { entries := r.Value.([]fs.DirEntry) }
|
||||
func (d *Data) List(path string) Result {
|
||||
emb, rel := d.resolve(path)
|
||||
if emb == nil {
|
||||
return Result{}
|
||||
}
|
||||
r := emb.ReadDir(rel)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{r.Value, true}
|
||||
}
|
||||
|
||||
// ListNames returns filenames (without extensions) at a path.
|
||||
//
|
||||
// r := c.Data().ListNames("agent/flow")
|
||||
// if r.OK { names := r.Value.([]string) }
|
||||
func (d *Data) ListNames(path string) Result {
|
||||
r := d.List(path)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
entries := r.Value.([]fs.DirEntry)
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.IsDir() {
|
||||
name = TrimSuffix(name, filepath.Ext(name))
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return Result{names, true}
|
||||
}
|
||||
|
||||
// Extract copies a template directory to targetDir.
|
||||
//
|
||||
// r := c.Data().Extract("agent/workspace/default", "/tmp/ws", templateData)
|
||||
func (d *Data) Extract(path, targetDir string, templateData any) Result {
|
||||
emb, rel := d.resolve(path)
|
||||
if emb == nil {
|
||||
return Result{}
|
||||
}
|
||||
r := emb.Sub(rel)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Extract(r.Value.(*Embed).FS(), targetDir, templateData)
|
||||
}
|
||||
|
||||
// Mounts returns the names of all mounted content in registration order.
|
||||
//
|
||||
// names := c.Data().Mounts()
|
||||
func (d *Data) Mounts() []string {
|
||||
return d.Names()
|
||||
}
|
||||
133
data_test.go
Normal file
133
data_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testFS embed.FS
|
||||
|
||||
// --- Data (Embedded Content Mounts) ---
|
||||
|
||||
func mountTestData(t *testing.T, c *Core, name string) {
|
||||
t.Helper()
|
||||
|
||||
r := c.Data().New(NewOptions(
|
||||
Option{Key: "name", Value: name},
|
||||
Option{Key: "source", Value: testFS},
|
||||
Option{Key: "path", Value: "testdata"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_New_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().New(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()
|
||||
|
||||
r := c.Data().New(NewOptions(Option{Key: "source", Value: testFS}))
|
||||
assert.False(t, r.OK)
|
||||
|
||||
r = c.Data().New(NewOptions(Option{Key: "name", Value: "test"}))
|
||||
assert.False(t, r.OK)
|
||||
|
||||
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()
|
||||
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()
|
||||
r := c.Data().ReadString("nonexistent/file.txt")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ReadFile_Good(t *testing.T) {
|
||||
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()
|
||||
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)
|
||||
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()
|
||||
r := c.Data().Get("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_Mounts_Good(t *testing.T) {
|
||||
c := New()
|
||||
mountTestData(t, c, "a")
|
||||
mountTestData(t, c, "b")
|
||||
mounts := c.Data().Mounts()
|
||||
assert.Len(t, mounts, 2)
|
||||
}
|
||||
|
||||
func TestData_List_Good(t *testing.T) {
|
||||
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()
|
||||
r := c.Data().List("nonexistent/path")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ListNames_Good(t *testing.T) {
|
||||
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()
|
||||
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()
|
||||
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
177
docs/commands.md
Normal file
177
docs/commands.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
title: Commands
|
||||
description: Path-based command registration and CLI execution.
|
||||
---
|
||||
|
||||
# Commands
|
||||
|
||||
Commands are one of the most AX-native parts of CoreGO. The path is the identity.
|
||||
|
||||
## Register a Command
|
||||
|
||||
```go
|
||||
c.Command("deploy/to/homelab", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
target := opts.String("target")
|
||||
return core.Result{Value: "deploying to " + target, OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Command Paths
|
||||
|
||||
Paths must be clean:
|
||||
|
||||
- no empty path
|
||||
- no leading slash
|
||||
- no trailing slash
|
||||
- no double slash
|
||||
|
||||
These paths are valid:
|
||||
|
||||
```text
|
||||
deploy
|
||||
deploy/to/homelab
|
||||
workspace/create
|
||||
```
|
||||
|
||||
These are rejected:
|
||||
|
||||
```text
|
||||
/deploy
|
||||
deploy/
|
||||
deploy//to
|
||||
```
|
||||
|
||||
## Parent Commands Are Auto-Created
|
||||
|
||||
When you register `deploy/to/homelab`, CoreGO also creates placeholder parents if they do not already exist:
|
||||
|
||||
- `deploy`
|
||||
- `deploy/to`
|
||||
|
||||
This makes the path tree navigable without extra setup.
|
||||
|
||||
## Read a Command Back
|
||||
|
||||
```go
|
||||
r := c.Command("deploy/to/homelab")
|
||||
if r.OK {
|
||||
cmd := r.Value.(*core.Command)
|
||||
_ = cmd
|
||||
}
|
||||
```
|
||||
|
||||
## Run a Command Directly
|
||||
|
||||
```go
|
||||
cmd := c.Command("deploy/to/homelab").Value.(*core.Command)
|
||||
|
||||
r := cmd.Run(core.Options{
|
||||
{Key: "target", Value: "uk-prod"},
|
||||
})
|
||||
```
|
||||
|
||||
If `Action` is nil, `Run` returns `Result{OK:false}` with a structured error.
|
||||
|
||||
## Run Through the CLI Surface
|
||||
|
||||
```go
|
||||
r := c.Cli().Run("deploy", "to", "homelab", "--target=uk-prod", "--debug")
|
||||
```
|
||||
|
||||
`Cli.Run` resolves the longest matching command path from the arguments, then converts the remaining args into `core.Options`.
|
||||
|
||||
## Flag Parsing Rules
|
||||
|
||||
### Double Dash
|
||||
|
||||
```text
|
||||
--target=uk-prod -> key "target", value "uk-prod"
|
||||
--debug -> key "debug", value true
|
||||
```
|
||||
|
||||
### Single Dash
|
||||
|
||||
```text
|
||||
-v -> key "v", value true
|
||||
-n=4 -> key "n", value "4"
|
||||
```
|
||||
|
||||
### Positional Arguments
|
||||
|
||||
Non-flag arguments after the command path are stored as repeated `_arg` options.
|
||||
|
||||
```go
|
||||
r := c.Cli().Run("workspace", "open", "alpha")
|
||||
```
|
||||
|
||||
That produces an option like:
|
||||
|
||||
```go
|
||||
core.Option{Key: "_arg", Value: "alpha"}
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- flag values stay as strings
|
||||
- `opts.Int("port")` only works if some code stored an actual `int`
|
||||
- invalid flags such as `-verbose` and `--v` are ignored
|
||||
|
||||
## Help Output
|
||||
|
||||
`Cli.PrintHelp()` prints executable commands:
|
||||
|
||||
```go
|
||||
c.Cli().PrintHelp()
|
||||
```
|
||||
|
||||
It skips:
|
||||
|
||||
- hidden commands
|
||||
- placeholder parents with no `Action` and no `Lifecycle`
|
||||
|
||||
Descriptions are resolved through `cmd.I18nKey()`.
|
||||
|
||||
## I18n Description Keys
|
||||
|
||||
If `Description` is empty, CoreGO derives a key from the path.
|
||||
|
||||
```text
|
||||
deploy -> cmd.deploy.description
|
||||
deploy/to/homelab -> cmd.deploy.to.homelab.description
|
||||
workspace/create -> cmd.workspace.create.description
|
||||
```
|
||||
|
||||
If `Description` is already set, CoreGO uses it as-is.
|
||||
|
||||
## Lifecycle Commands
|
||||
|
||||
Commands can also delegate to a lifecycle implementation.
|
||||
|
||||
```go
|
||||
type daemonCommand struct{}
|
||||
|
||||
func (d *daemonCommand) Start(opts core.Options) core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Stop() core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Restart() core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Reload() core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Signal(sig string) core.Result { return core.Result{Value: sig, OK: true} }
|
||||
|
||||
c.Command("agent/serve", core.Command{
|
||||
Lifecycle: &daemonCommand{},
|
||||
})
|
||||
```
|
||||
|
||||
Important behavior:
|
||||
|
||||
- `Start` falls back to `Run` when `Lifecycle` is nil
|
||||
- `Stop`, `Restart`, `Reload`, and `Signal` return an empty `Result` when `Lifecycle` is nil
|
||||
|
||||
## List Command Paths
|
||||
|
||||
```go
|
||||
paths := c.Commands()
|
||||
```
|
||||
|
||||
Like the service registry, the command registry is map-backed, so iteration order is not guaranteed.
|
||||
|
|
@ -1,178 +1,96 @@
|
|||
---
|
||||
title: Configuration Options
|
||||
description: WithService, WithName, WithApp, WithAssets, and WithServiceLock options.
|
||||
title: Configuration
|
||||
description: Constructor options, runtime settings, and feature flags.
|
||||
---
|
||||
|
||||
# Configuration Options
|
||||
# Configuration
|
||||
|
||||
The `Core` is configured through **options** -- functions with the signature `func(*Core) error`. These are passed to `core.New()` and applied in order during initialisation.
|
||||
CoreGO uses two different configuration layers:
|
||||
|
||||
- constructor-time `core.Options`
|
||||
- runtime `c.Config()`
|
||||
|
||||
## Constructor-Time Options
|
||||
|
||||
```go
|
||||
type Option func(*Core) error
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
```
|
||||
|
||||
## Available Options
|
||||
### Current Behavior
|
||||
|
||||
### WithService
|
||||
- `New` accepts `opts ...Options`
|
||||
- the current implementation copies only the first `Options` slice
|
||||
- the `name` key is applied to `c.App().Name`
|
||||
|
||||
If you need more constructor data, put it in the first `core.Options` slice.
|
||||
|
||||
## Runtime Settings with `Config`
|
||||
|
||||
Use `c.Config()` for mutable process settings.
|
||||
|
||||
```go
|
||||
func WithService(factory func(*Core) (any, error)) Option
|
||||
c.Config().Set("workspace.root", "/srv/workspaces")
|
||||
c.Config().Set("max_agents", 8)
|
||||
c.Config().Set("debug", true)
|
||||
```
|
||||
|
||||
Registers a service using a factory function. The service name is **auto-discovered** from the Go package path of the returned type (the last path segment, lowercased).
|
||||
Read them back with:
|
||||
|
||||
```go
|
||||
// If the returned type is from package "myapp/services/calculator",
|
||||
// the service name becomes "calculator".
|
||||
core.New(
|
||||
core.WithService(calculator.NewService),
|
||||
)
|
||||
root := c.Config().String("workspace.root")
|
||||
maxAgents := c.Config().Int("max_agents")
|
||||
debug := c.Config().Bool("debug")
|
||||
raw := c.Config().Get("workspace.root")
|
||||
```
|
||||
|
||||
`WithService` also performs two automatic behaviours:
|
||||
### Important Details
|
||||
|
||||
1. **Name discovery** -- uses `reflect` to extract the package name from the returned type.
|
||||
2. **IPC handler discovery** -- if the service has a `HandleIPCEvents(c *Core, msg Message) error` method, it is registered as an action handler automatically.
|
||||
- missing keys return zero values
|
||||
- typed accessors do not coerce strings into ints or bools
|
||||
- `Get` returns `core.Result`
|
||||
|
||||
If the factory returns an error or `nil`, `New()` fails with an error.
|
||||
## Feature Flags
|
||||
|
||||
If the returned type has no package path (e.g. a primitive or anonymous type), `New()` fails with a descriptive error.
|
||||
|
||||
### WithName
|
||||
`Config` also tracks named feature flags.
|
||||
|
||||
```go
|
||||
func WithName(name string, factory func(*Core) (any, error)) Option
|
||||
c.Config().Enable("workspace.templates")
|
||||
c.Config().Enable("agent.review")
|
||||
c.Config().Disable("agent.review")
|
||||
```
|
||||
|
||||
Registers a service with an **explicit name**. Use this when the auto-discovered name would be wrong (e.g. anonymous functions, or when you want a different name).
|
||||
Read them with:
|
||||
|
||||
```go
|
||||
core.New(
|
||||
core.WithName("greet", func(c *core.Core) (any, error) {
|
||||
return &Greeter{}, nil
|
||||
}),
|
||||
)
|
||||
enabled := c.Config().Enabled("workspace.templates")
|
||||
features := c.Config().EnabledFeatures()
|
||||
```
|
||||
|
||||
Unlike `WithService`, `WithName` does **not** auto-discover IPC handlers. If your service needs to handle actions, register the handler manually:
|
||||
Feature names are case-sensitive.
|
||||
|
||||
## `ConfigVar[T]`
|
||||
|
||||
Use `ConfigVar[T]` when you need a typed value that can also represent “set versus unset”.
|
||||
|
||||
```go
|
||||
core.WithName("greet", func(c *core.Core) (any, error) {
|
||||
svc := &Greeter{}
|
||||
c.RegisterAction(svc.HandleIPCEvents)
|
||||
return svc, nil
|
||||
}),
|
||||
```
|
||||
theme := core.NewConfigVar("amber")
|
||||
|
||||
### WithApp
|
||||
|
||||
```go
|
||||
func WithApp(app any) Option
|
||||
```
|
||||
|
||||
Injects a GUI runtime (e.g. a Wails App instance) into the Core. The app is stored in the `Core.App` field and can be accessed globally via `core.App()` after `SetInstance` is called.
|
||||
|
||||
```go
|
||||
core.New(
|
||||
core.WithApp(wailsApp),
|
||||
)
|
||||
```
|
||||
|
||||
This is primarily used for desktop applications where services need access to the windowing runtime.
|
||||
|
||||
### WithAssets
|
||||
|
||||
```go
|
||||
func WithAssets(fs embed.FS) Option
|
||||
```
|
||||
|
||||
Registers the application's embedded assets filesystem. Retrieve it later with `c.Assets()`.
|
||||
|
||||
```go
|
||||
//go:embed frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
core.New(
|
||||
core.WithAssets(assets),
|
||||
)
|
||||
```
|
||||
|
||||
### WithServiceLock
|
||||
|
||||
```go
|
||||
func WithServiceLock() Option
|
||||
```
|
||||
|
||||
Prevents any services from being registered after `New()` returns. Any call to `RegisterService` after initialisation will return an error.
|
||||
|
||||
```go
|
||||
c, err := core.New(
|
||||
core.WithService(myService),
|
||||
core.WithServiceLock(), // no more services can be added
|
||||
)
|
||||
// c.RegisterService("late", &svc) -> error
|
||||
```
|
||||
|
||||
This is a safety measure to ensure all services are declared upfront, preventing accidental late-binding that could cause ordering or lifecycle issues.
|
||||
|
||||
**How it works:** The lock is recorded during option processing but only **applied** after all options have been processed. This means options that register services (like `WithService`) can appear in any order relative to `WithServiceLock`.
|
||||
|
||||
## Option Ordering
|
||||
|
||||
Options are applied in the order they are passed to `New()`. This means:
|
||||
|
||||
- Services registered earlier are available to later factories (via `c.Service()`).
|
||||
- `WithServiceLock()` can appear at any position -- it only takes effect after all options have been processed.
|
||||
- `WithApp` and `WithAssets` can appear at any position.
|
||||
|
||||
```go
|
||||
core.New(
|
||||
core.WithServiceLock(), // recorded, not yet applied
|
||||
core.WithService(factory1), // succeeds (lock not yet active)
|
||||
core.WithService(factory2), // succeeds
|
||||
// After New() returns, the lock is applied
|
||||
)
|
||||
```
|
||||
|
||||
## Global Instance
|
||||
|
||||
For applications that need global access to the Core (typically GUI runtimes), there is a global instance mechanism:
|
||||
|
||||
```go
|
||||
// Set the global instance (typically during app startup)
|
||||
core.SetInstance(c)
|
||||
|
||||
// Retrieve it (panics if not set)
|
||||
app := core.App()
|
||||
|
||||
// Non-panicking access
|
||||
c := core.GetInstance()
|
||||
if c == nil {
|
||||
// not set
|
||||
if theme.IsSet() {
|
||||
fmt.Println(theme.Get())
|
||||
}
|
||||
|
||||
// Clear it (useful in tests)
|
||||
core.ClearInstance()
|
||||
theme.Unset()
|
||||
```
|
||||
|
||||
These functions are thread-safe.
|
||||
This is useful for package-local state where zero values are not enough to describe configuration presence.
|
||||
|
||||
## Features
|
||||
## Recommended Pattern
|
||||
|
||||
The `Core` struct includes a `Features` field for simple feature flagging:
|
||||
Use the two layers for different jobs:
|
||||
|
||||
```go
|
||||
c.Features.Flags = []string{"experimental-ui", "beta-api"}
|
||||
- put startup identity such as `name` into `core.Options`
|
||||
- put mutable runtime values and feature switches into `c.Config()`
|
||||
|
||||
if c.Features.IsEnabled("experimental-ui") {
|
||||
// enable experimental UI
|
||||
}
|
||||
```
|
||||
|
||||
Feature flags are string-matched (case-sensitive). This is a lightweight mechanism -- for complex feature management, register a dedicated service.
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Services](services.md) -- service registration and retrieval
|
||||
- [Lifecycle](lifecycle.md) -- startup/shutdown after configuration
|
||||
- [Getting Started](getting-started.md) -- end-to-end example
|
||||
That keeps constructor intent separate from live process state.
|
||||
|
|
|
|||
181
docs/errors.md
181
docs/errors.md
|
|
@ -1,139 +1,120 @@
|
|||
---
|
||||
title: Errors
|
||||
description: The E() helper function and Error struct for contextual error handling.
|
||||
description: Structured errors, logging helpers, and panic recovery.
|
||||
---
|
||||
|
||||
# Errors
|
||||
|
||||
Core provides a standardised error type and constructor for wrapping errors with operational context. This makes it easier to trace where an error originated and provide meaningful feedback.
|
||||
CoreGO treats failures as structured operational data.
|
||||
|
||||
## The Error Struct
|
||||
Repository convention: use `E()` instead of `fmt.Errorf` for framework and service errors.
|
||||
|
||||
## `Err`
|
||||
|
||||
The structured error type is:
|
||||
|
||||
```go
|
||||
type Error struct {
|
||||
Op string // the operation, e.g. "config.Load"
|
||||
Msg string // human-readable explanation
|
||||
Err error // the underlying error (may be nil)
|
||||
type Err struct {
|
||||
Operation string
|
||||
Message string
|
||||
Cause error
|
||||
Code string
|
||||
}
|
||||
```
|
||||
|
||||
- **Op** identifies the operation that failed. Use the format `package.Function` or `service.Method`.
|
||||
- **Msg** is a human-readable message explaining what went wrong.
|
||||
- **Err** is the underlying error being wrapped. May be `nil` for root errors.
|
||||
## Create Errors
|
||||
|
||||
## The E() Helper
|
||||
|
||||
`E()` is the primary way to create contextual errors:
|
||||
### `E`
|
||||
|
||||
```go
|
||||
func E(op, msg string, err error) error
|
||||
err := core.E("workspace.Load", "failed to read workspace manifest", cause)
|
||||
```
|
||||
|
||||
### With an Underlying Error
|
||||
### `Wrap`
|
||||
|
||||
```go
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return core.E("config.Load", "failed to read config file", err)
|
||||
}
|
||||
err := core.Wrap(cause, "workspace.Load", "manifest parse failed")
|
||||
```
|
||||
|
||||
This produces: `config.Load: failed to read config file: open /path/to/file: no such file or directory`
|
||||
|
||||
### Without an Underlying Error (Root Error)
|
||||
### `WrapCode`
|
||||
|
||||
```go
|
||||
if name == "" {
|
||||
return core.E("user.Create", "name cannot be empty", nil)
|
||||
}
|
||||
err := core.WrapCode(cause, "WORKSPACE_INVALID", "workspace.Load", "manifest parse failed")
|
||||
```
|
||||
|
||||
This produces: `user.Create: name cannot be empty`
|
||||
|
||||
When `err` is `nil`, the `Err` field is not set and the output omits the trailing error.
|
||||
|
||||
## Error Output Format
|
||||
|
||||
The `Error()` method produces a string in one of two formats:
|
||||
|
||||
```
|
||||
// With underlying error:
|
||||
op: msg: underlying error text
|
||||
|
||||
// Without underlying error:
|
||||
op: msg
|
||||
```
|
||||
|
||||
## Unwrapping
|
||||
|
||||
`Error` implements the `Unwrap() error` method, making it compatible with Go's `errors.Is` and `errors.As`:
|
||||
### `NewCode`
|
||||
|
||||
```go
|
||||
originalErr := errors.New("connection refused")
|
||||
wrapped := core.E("db.Connect", "failed to connect", originalErr)
|
||||
|
||||
// errors.Is traverses the chain
|
||||
errors.Is(wrapped, originalErr) // true
|
||||
|
||||
// errors.As extracts the Error
|
||||
var coreErr *core.Error
|
||||
if errors.As(wrapped, &coreErr) {
|
||||
fmt.Println(coreErr.Op) // "db.Connect"
|
||||
fmt.Println(coreErr.Msg) // "failed to connect"
|
||||
}
|
||||
err := core.NewCode("NOT_FOUND", "workspace not found")
|
||||
```
|
||||
|
||||
## Building Error Chains
|
||||
|
||||
Because `E()` wraps errors, you can build a logical call stack by wrapping at each layer:
|
||||
## Inspect Errors
|
||||
|
||||
```go
|
||||
// Low-level
|
||||
func readConfig(path string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, core.E("config.readConfig", "failed to read file", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Mid-level
|
||||
func loadConfig() (*Config, error) {
|
||||
data, err := readConfig("/etc/app/config.yaml")
|
||||
if err != nil {
|
||||
return nil, core.E("config.Load", "failed to load configuration", err)
|
||||
}
|
||||
// parse data...
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Top-level
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return core.E("service.OnStartup", "startup failed", err)
|
||||
}
|
||||
s.config = cfg
|
||||
return nil
|
||||
}
|
||||
op := core.Operation(err)
|
||||
code := core.ErrorCode(err)
|
||||
msg := core.ErrorMessage(err)
|
||||
root := core.Root(err)
|
||||
stack := core.StackTrace(err)
|
||||
pretty := core.FormatStackTrace(err)
|
||||
```
|
||||
|
||||
The resulting error message reads like a stack trace:
|
||||
These helpers keep the operational chain visible without extra type assertions.
|
||||
|
||||
```
|
||||
service.OnStartup: startup failed: config.Load: failed to load configuration: config.readConfig: failed to read file: open /etc/app/config.yaml: no such file or directory
|
||||
## Join and Standard Wrappers
|
||||
|
||||
```go
|
||||
combined := core.ErrorJoin(err1, err2)
|
||||
same := core.Is(combined, err1)
|
||||
```
|
||||
|
||||
## Conventions
|
||||
`core.As` and `core.NewError` mirror the standard library for convenience.
|
||||
|
||||
1. **Op format**: Use `package.Function` or `service.Method`. Keep it short and specific.
|
||||
2. **Msg format**: Use lowercase, describe what failed (not what succeeded). Write messages that make sense to a developer reading logs.
|
||||
3. **Wrap at boundaries**: Wrap with `E()` when crossing package or layer boundaries, not at every function call.
|
||||
4. **Always return `error`**: `E()` returns the `error` interface, not `*Error`. Callers should not need to know the concrete type.
|
||||
5. **Nil underlying error**: Pass `nil` for `err` when creating root errors (errors that do not wrap another error).
|
||||
## Log-and-Return Helpers
|
||||
|
||||
## Related Pages
|
||||
`Core` exposes two convenience wrappers:
|
||||
|
||||
- [Services](services.md) -- services that return errors
|
||||
- [Lifecycle](lifecycle.md) -- lifecycle error aggregation
|
||||
- [Testing](testing.md) -- testing error conditions (`_Bad` suffix)
|
||||
```go
|
||||
r1 := c.LogError(err, "workspace.Load", "workspace load failed")
|
||||
r2 := c.LogWarn(err, "workspace.Load", "workspace load degraded")
|
||||
```
|
||||
|
||||
These log through the default logger and return `core.Result`.
|
||||
|
||||
You can also use the underlying `ErrorLog` directly:
|
||||
|
||||
```go
|
||||
r := c.Log().Error(err, "workspace.Load", "workspace load failed")
|
||||
```
|
||||
|
||||
`Must` logs and then panics when the error is non-nil:
|
||||
|
||||
```go
|
||||
c.Must(err, "workspace.Load", "workspace load failed")
|
||||
```
|
||||
|
||||
## Panic Recovery
|
||||
|
||||
`ErrorPanic` handles process-safe panic capture.
|
||||
|
||||
```go
|
||||
defer c.Error().Recover()
|
||||
```
|
||||
|
||||
Run background work with recovery:
|
||||
|
||||
```go
|
||||
c.Error().SafeGo(func() {
|
||||
panic("captured")
|
||||
})
|
||||
```
|
||||
|
||||
If `ErrorPanic` has a configured crash file path, it appends JSON crash reports and `Reports(n)` reads them back.
|
||||
|
||||
That crash file path is currently internal state on `ErrorPanic`, not a public constructor option on `Core.New()`.
|
||||
|
||||
## Logging and Error Context
|
||||
|
||||
The logging subsystem automatically extracts `op` and logical stack information from structured errors when those values are present in the key-value list.
|
||||
|
||||
That makes errors created with `E`, `Wrap`, or `WrapCode` much easier to follow in logs.
|
||||
|
|
|
|||
|
|
@ -1,191 +1,198 @@
|
|||
---
|
||||
title: Getting Started
|
||||
description: How to create a Core application and register services.
|
||||
description: Build a first CoreGO application with the current API.
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
This guide walks you through creating a Core application, registering services, and running the lifecycle.
|
||||
This page shows the shortest path to a useful CoreGO application using the API that exists in this repository today.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go get forge.lthn.ai/core/go
|
||||
go get dappco.re/go/core
|
||||
```
|
||||
|
||||
## Creating a Core Instance
|
||||
## Create a Core
|
||||
|
||||
Everything starts with `core.New()`. It accepts a variadic list of `Option` functions that configure the container before it is returned.
|
||||
`New` takes zero or more `core.Options` slices, but the current implementation only reads the first one. In practice, treat the constructor as `core.New(core.Options{...})`.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
import "dappco.re/go/core"
|
||||
|
||||
func main() {
|
||||
c, err := core.New()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = c // empty container, ready for use
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
|
||||
_ = c
|
||||
}
|
||||
```
|
||||
|
||||
In practice you will pass options to register services, embed assets, or lock the registry:
|
||||
The `name` option is copied into `c.App().Name`.
|
||||
|
||||
## Register a Service
|
||||
|
||||
Services are registered explicitly with a name and a `core.Service` DTO.
|
||||
|
||||
```go
|
||||
c, err := core.New(
|
||||
core.WithService(mypackage.NewService),
|
||||
core.WithAssets(embeddedFS),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("audit service started", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("audit service stopped", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
See [Configuration](configuration.md) for the full list of options.
|
||||
This registry stores `core.Service` values. It is a lifecycle registry, not a typed object container.
|
||||
|
||||
## Registering a Service
|
||||
|
||||
Services are registered via **factory functions**. A factory receives the `*Core` and returns `(any, error)`:
|
||||
## Register a Query, Task, and Command
|
||||
|
||||
```go
|
||||
package greeter
|
||||
type workspaceCountQuery struct{}
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
type Service struct {
|
||||
greeting string
|
||||
type createWorkspaceTask struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *Service) Hello(name string) string {
|
||||
return s.greeting + ", " + name + "!"
|
||||
}
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
switch q.(type) {
|
||||
case workspaceCountQuery:
|
||||
return core.Result{Value: 1, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
func NewService(c *core.Core) (any, error) {
|
||||
return &Service{greeting: "Hello"}, nil
|
||||
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.Action("workspace.create").Run(context.Background(), opts)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Start the Runtime
|
||||
|
||||
```go
|
||||
if !c.ServiceStartup(context.Background(), nil).OK {
|
||||
panic("startup failed")
|
||||
}
|
||||
```
|
||||
|
||||
Register it with `WithService`:
|
||||
`ServiceStartup` returns `core.Result`, not `error`.
|
||||
|
||||
## Run Through the CLI Surface
|
||||
|
||||
```go
|
||||
c, err := core.New(
|
||||
core.WithService(greeter.NewService),
|
||||
)
|
||||
r := c.Cli().Run("workspace", "create", "--name=alpha")
|
||||
if r.OK {
|
||||
fmt.Println("created:", r.Value)
|
||||
}
|
||||
```
|
||||
|
||||
`WithService` automatically discovers the service name from the package path. In this case, the service is registered under the name `"greeter"`.
|
||||
For flags with values, the CLI stores the value as a string. `--name=alpha` becomes `opts.String("name") == "alpha"`.
|
||||
|
||||
If you need to control the name explicitly, use `WithName`:
|
||||
## Query the System
|
||||
|
||||
```go
|
||||
c, err := core.New(
|
||||
core.WithName("greet", greeter.NewService),
|
||||
)
|
||||
count := c.QUERY(workspaceCountQuery{})
|
||||
if count.OK {
|
||||
fmt.Println("workspace count:", count.Value)
|
||||
}
|
||||
```
|
||||
|
||||
See [Services](services.md) for the full registration API and the `ServiceRuntime` helper.
|
||||
|
||||
## Retrieving a Service
|
||||
|
||||
Once registered, services can be retrieved by name:
|
||||
## Shut Down Cleanly
|
||||
|
||||
```go
|
||||
// Untyped retrieval (returns any)
|
||||
svc := c.Service("greeter")
|
||||
|
||||
// Type-safe retrieval (returns error if not found or wrong type)
|
||||
greet, err := core.ServiceFor[*greeter.Service](c, "greeter")
|
||||
|
||||
// Panicking retrieval (for init-time wiring where failure is fatal)
|
||||
greet := core.MustServiceFor[*greeter.Service](c, "greeter")
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
```
|
||||
|
||||
## Running the Lifecycle
|
||||
Shutdown cancels `c.Context()`, broadcasts `ActionServiceShutdown{}`, waits for background tasks to finish, and then runs service stop hooks.
|
||||
|
||||
Services that implement `Startable` and/or `Stoppable` are automatically called during startup and shutdown:
|
||||
|
||||
```go
|
||||
import "context"
|
||||
|
||||
// Start all Startable services (in registration order)
|
||||
err := c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
// ... application runs ...
|
||||
|
||||
// Stop all Stoppable services (in reverse registration order)
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
```
|
||||
|
||||
See [Lifecycle](lifecycle.md) for details on the `Startable` and `Stoppable` interfaces.
|
||||
|
||||
## Sending Messages
|
||||
|
||||
Services communicate through the message bus without needing direct imports of each other:
|
||||
|
||||
```go
|
||||
// Broadcast to all handlers (fire-and-forget)
|
||||
err := c.ACTION(MyEvent{Data: "something happened"})
|
||||
|
||||
// Request data from the first handler that responds
|
||||
result, handled, err := c.QUERY(MyQuery{Key: "setting"})
|
||||
|
||||
// Ask a handler to perform work
|
||||
result, handled, err := c.PERFORM(MyTask{Input: "data"})
|
||||
```
|
||||
|
||||
See [Messaging](messaging.md) for the full message bus API.
|
||||
|
||||
## Putting It All Together
|
||||
|
||||
Here is a minimal but complete application:
|
||||
## Full Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
type workspaceCountQuery struct{}
|
||||
|
||||
type createWorkspaceTask struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func main() {
|
||||
c, err := core.New(
|
||||
core.WithName("log", log.NewService(log.Options{Level: log.LevelInfo})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
|
||||
// Start lifecycle
|
||||
if err := c.ServiceStartup(context.Background(), nil); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c.Config().Set("workspace.root", "/tmp/agent-workbench")
|
||||
c.Config().Enable("workspace.templates")
|
||||
|
||||
// Use services
|
||||
logger := core.MustServiceFor[*log.Service](c, "log")
|
||||
fmt.Println("Logger started at level:", logger.Level())
|
||||
c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("service started", "service", "audit")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("service stopped", "service", "audit")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
// Query the log level through the message bus
|
||||
level, handled, _ := c.QUERY(log.QueryLevel{})
|
||||
if handled {
|
||||
fmt.Println("Log level via QUERY:", level)
|
||||
}
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
switch q.(type) {
|
||||
case workspaceCountQuery:
|
||||
return core.Result{Value: 1, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
// Clean shutdown
|
||||
if err := c.ServiceShutdown(context.Background()); err != nil {
|
||||
fmt.Println("shutdown error:", err)
|
||||
}
|
||||
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.Action("workspace.create").Run(context.Background(), opts)
|
||||
},
|
||||
})
|
||||
|
||||
if !c.ServiceStartup(context.Background(), nil).OK {
|
||||
panic("startup failed")
|
||||
}
|
||||
|
||||
created := c.Cli().Run("workspace", "create", "--name=alpha")
|
||||
fmt.Println("created:", created.Value)
|
||||
|
||||
count := c.QUERY(workspaceCountQuery{})
|
||||
fmt.Println("workspace count:", count.Value)
|
||||
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Services](services.md) -- service registration patterns in depth
|
||||
- [Lifecycle](lifecycle.md) -- startup/shutdown ordering and error handling
|
||||
- [Messaging](messaging.md) -- ACTION, QUERY, and PERFORM
|
||||
- [Configuration](configuration.md) -- all `With*` options
|
||||
- [Errors](errors.md) -- the `E()` error helper
|
||||
- [Testing](testing.md) -- test conventions and helpers
|
||||
- Read [primitives.md](primitives.md) next so the repeated shapes are clear.
|
||||
- Read [commands.md](commands.md) if you are building a CLI-first system.
|
||||
- Read [messaging.md](messaging.md) if services need to collaborate without direct imports.
|
||||
|
|
|
|||
114
docs/index.md
114
docs/index.md
|
|
@ -1,96 +1,60 @@
|
|||
---
|
||||
title: Core Go Framework
|
||||
description: Dependency injection and service lifecycle framework for Go.
|
||||
title: CoreGO
|
||||
description: AX-first documentation for the CoreGO framework.
|
||||
---
|
||||
|
||||
# Core Go Framework
|
||||
# CoreGO
|
||||
|
||||
Core (`forge.lthn.ai/core/go`) is a dependency injection and service lifecycle framework for Go. It provides a typed service registry, lifecycle hooks, and a message-passing bus for decoupled communication between services.
|
||||
CoreGO is the foundation layer for the Core ecosystem. Module: `dappco.re/go/core`.
|
||||
|
||||
This is the foundation layer of the ecosystem. It has no CLI, no GUI, and minimal dependencies.
|
||||
## What CoreGO Provides
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get forge.lthn.ai/core/go
|
||||
```
|
||||
|
||||
Requires Go 1.26 or later.
|
||||
|
||||
## What It Does
|
||||
|
||||
Core solves three problems that every non-trivial Go application eventually faces:
|
||||
|
||||
1. **Service wiring** -- how do you register, retrieve, and type-check services without import cycles?
|
||||
2. **Lifecycle management** -- how do you start and stop services in the right order?
|
||||
3. **Decoupled communication** -- how do services talk to each other without knowing each other's types?
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| [`pkg/core`](services.md) | DI container, service registry, lifecycle, message bus |
|
||||
| `pkg/log` | Structured logger service with Core integration |
|
||||
| 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"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
import "dappco.re/go/core"
|
||||
|
||||
func main() {
|
||||
c, err := core.New(
|
||||
core.WithName("log", log.NewService(log.Options{Level: log.LevelInfo})),
|
||||
core.WithServiceLock(), // Prevent late registration
|
||||
c := core.New(
|
||||
core.WithOption("name", "agent-workbench"),
|
||||
core.WithService(cache.Register),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start all services
|
||||
if err := c.ServiceStartup(context.Background(), nil); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Type-safe retrieval
|
||||
logger, err := core.ServiceFor[*log.Service](c, "log")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Log level:", logger.Level())
|
||||
|
||||
// Shut down (reverse order)
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
c.Run()
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
| Page | Covers |
|
||||
| Path | Covers |
|
||||
|------|--------|
|
||||
| [Getting Started](getting-started.md) | Creating a Core app, registering your first service |
|
||||
| [Services](services.md) | Service registration, `ServiceRuntime`, factory pattern |
|
||||
| [Lifecycle](lifecycle.md) | `Startable`/`Stoppable` interfaces, startup/shutdown order |
|
||||
| [Messaging](messaging.md) | ACTION, QUERY, PERFORM -- the message bus |
|
||||
| [Configuration](configuration.md) | `WithService`, `WithName`, `WithAssets`, `WithServiceLock` options |
|
||||
| [Testing](testing.md) | Test naming conventions, test helpers, fuzz testing |
|
||||
| [Errors](errors.md) | `E()` helper, `Error` struct, unwrapping |
|
||||
|
||||
## Dependencies
|
||||
|
||||
Core is deliberately minimal:
|
||||
|
||||
- `forge.lthn.ai/core/go-io` -- abstract storage (local, S3, SFTP, WebDAV)
|
||||
- `forge.lthn.ai/core/go-log` -- structured logging
|
||||
- `github.com/stretchr/testify` -- test assertions (test-only)
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
| [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,165 +1,111 @@
|
|||
---
|
||||
title: Lifecycle
|
||||
description: Startable and Stoppable interfaces, startup and shutdown ordering.
|
||||
description: Startup, shutdown, context ownership, and background task draining.
|
||||
---
|
||||
|
||||
# Lifecycle
|
||||
|
||||
Core manages the startup and shutdown of services through two opt-in interfaces. Services implement one or both to participate in the application lifecycle.
|
||||
CoreGO manages lifecycle through `core.Service` callbacks, not through reflection or implicit interfaces.
|
||||
|
||||
## Interfaces
|
||||
|
||||
### Startable
|
||||
## Service Hooks
|
||||
|
||||
```go
|
||||
type Startable interface {
|
||||
OnStartup(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
Services implementing `Startable` have their `OnStartup` method called during `ServiceStartup`. This is the place to:
|
||||
|
||||
- Open database connections
|
||||
- Register message bus handlers (queries, tasks)
|
||||
- Start background workers
|
||||
- Validate configuration
|
||||
|
||||
### Stoppable
|
||||
|
||||
```go
|
||||
type Stoppable interface {
|
||||
OnShutdown(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
Services implementing `Stoppable` have their `OnShutdown` method called during `ServiceShutdown`. This is the place to:
|
||||
|
||||
- Close database connections
|
||||
- Flush buffers
|
||||
- Save state
|
||||
- Cancel background workers
|
||||
|
||||
A service can implement both interfaces:
|
||||
|
||||
```go
|
||||
type Service struct{}
|
||||
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
// Initialise resources
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
// Release resources
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Ordering
|
||||
|
||||
### Startup: Registration Order
|
||||
|
||||
Services are started in the order they were registered. If you register services A, B, C (in that order), their `OnStartup` methods are called as A, B, C.
|
||||
|
||||
### Shutdown: Reverse Registration Order
|
||||
|
||||
Services are stopped in **reverse** registration order. If A, B, C were registered, their `OnShutdown` methods are called as C, B, A.
|
||||
|
||||
This ensures that services which depend on earlier services are torn down first.
|
||||
|
||||
```go
|
||||
c, err := core.New()
|
||||
|
||||
_ = c.RegisterService("database", dbService) // started 1st, stopped 3rd
|
||||
_ = c.RegisterService("cache", cacheService) // started 2nd, stopped 2nd
|
||||
_ = c.RegisterService("api", apiService) // started 3rd, stopped 1st
|
||||
|
||||
_ = c.ServiceStartup(ctx, nil) // database -> cache -> api
|
||||
_ = c.ServiceShutdown(ctx) // api -> cache -> database
|
||||
```
|
||||
|
||||
## ServiceStartup
|
||||
|
||||
```go
|
||||
func (c *Core) ServiceStartup(ctx context.Context, options any) error
|
||||
```
|
||||
|
||||
`ServiceStartup` does two things, in order:
|
||||
|
||||
1. Calls `OnStartup(ctx)` on every `Startable` service, in registration order.
|
||||
2. Broadcasts an `ActionServiceStartup{}` message via the message bus.
|
||||
|
||||
If any service returns an error, it is collected but does **not** prevent other services from starting. All errors are aggregated with `errors.Join` and returned together.
|
||||
|
||||
If the context is cancelled before all services have started, the remaining services are skipped and the context error is included in the aggregate.
|
||||
|
||||
## ServiceShutdown
|
||||
|
||||
```go
|
||||
func (c *Core) ServiceShutdown(ctx context.Context) error
|
||||
```
|
||||
|
||||
`ServiceShutdown` does three things, in order:
|
||||
|
||||
1. Broadcasts an `ActionServiceShutdown{}` message via the message bus.
|
||||
2. Calls `OnShutdown(ctx)` on every `Stoppable` service, in reverse registration order.
|
||||
3. Waits for any in-flight `PerformAsync` background tasks to complete (respecting the context deadline).
|
||||
|
||||
As with startup, errors are aggregated rather than short-circuiting. If the context is cancelled during shutdown, the remaining services are skipped but the method still waits for background tasks.
|
||||
|
||||
## Built-in Lifecycle Messages
|
||||
|
||||
Core broadcasts two action messages as part of the lifecycle. You can listen for these in any registered action handler:
|
||||
|
||||
| Message | When |
|
||||
|---------|------|
|
||||
| `ActionServiceStartup{}` | After all `Startable` services have been called |
|
||||
| `ActionServiceShutdown{}` | Before `Stoppable` services are called |
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
switch msg.(type) {
|
||||
case core.ActionServiceStartup:
|
||||
// All services are up
|
||||
case core.ActionServiceShutdown:
|
||||
// Shutdown is beginning
|
||||
}
|
||||
return nil
|
||||
c.Service("cache", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
Only services with `OnStart` appear in `Startables()`. Only services with `OnStop` appear in `Stoppables()`.
|
||||
|
||||
Lifecycle methods never panic. All errors from individual services are collected via `errors.Join` and returned as a single error. You can inspect individual errors with `errors.Is` and `errors.As`:
|
||||
## `ServiceStartup`
|
||||
|
||||
```go
|
||||
err := c.ServiceStartup(ctx, nil)
|
||||
if err != nil {
|
||||
// err may contain multiple wrapped errors
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// context was cancelled
|
||||
}
|
||||
}
|
||||
r := c.ServiceStartup(context.Background(), nil)
|
||||
```
|
||||
|
||||
## Context Cancellation
|
||||
### What It Does
|
||||
|
||||
Both `ServiceStartup` and `ServiceShutdown` respect context cancellation. If the context is cancelled or its deadline is exceeded, the remaining services are skipped:
|
||||
1. clears the shutdown flag
|
||||
2. stores a new cancellable context on `c.Context()`
|
||||
3. runs each `OnStart`
|
||||
4. broadcasts `ActionServiceStartup{}`
|
||||
|
||||
### Failure Behavior
|
||||
|
||||
- if the input context is already cancelled, startup returns that error
|
||||
- if any `OnStart` returns `OK:false`, startup stops immediately and returns that result
|
||||
|
||||
## `ServiceShutdown`
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := c.ServiceStartup(ctx, nil)
|
||||
// If startup takes longer than 5 seconds, remaining services are skipped
|
||||
r := c.ServiceShutdown(context.Background())
|
||||
```
|
||||
|
||||
## Detection
|
||||
### What It Does
|
||||
|
||||
Lifecycle interface detection happens at registration time. When you call `RegisterService`, Core checks whether the service implements `Startable` and/or `Stoppable` and adds it to the appropriate internal list. There is no need to declare anything beyond implementing the interface.
|
||||
1. sets the shutdown flag
|
||||
2. cancels `c.Context()`
|
||||
3. broadcasts `ActionServiceShutdown{}`
|
||||
4. waits for background tasks created by `PerformAsync`
|
||||
5. runs each `OnStop`
|
||||
|
||||
## Related Pages
|
||||
### Failure Behavior
|
||||
|
||||
- [Services](services.md) -- how services are registered
|
||||
- [Messaging](messaging.md) -- the `ACTION` broadcast used during lifecycle
|
||||
- [Configuration](configuration.md) -- `WithServiceLock` and other options
|
||||
- if draining background tasks hits the shutdown context deadline, shutdown returns that context error
|
||||
- when service stop hooks fail, CoreGO returns the first error it sees
|
||||
|
||||
## Ordering
|
||||
|
||||
The current implementation builds `Startables()` and `Stoppables()` by iterating over a map-backed registry.
|
||||
|
||||
That means lifecycle order is not guaranteed today.
|
||||
|
||||
If your application needs strict startup or shutdown ordering, orchestrate it explicitly inside a smaller number of service callbacks instead of relying on registry order.
|
||||
|
||||
## `c.Context()`
|
||||
|
||||
`ServiceStartup` creates the context returned by `c.Context()`.
|
||||
|
||||
Use it for background work that should stop when the application shuts down:
|
||||
|
||||
```go
|
||||
c.Service("watcher", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
go func(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
}(c.Context())
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Built-In Lifecycle Actions
|
||||
|
||||
You can listen for lifecycle state changes through the action bus.
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
switch msg.(type) {
|
||||
case core.ActionServiceStartup:
|
||||
core.Info("core startup completed")
|
||||
case core.ActionServiceShutdown:
|
||||
core.Info("core shutdown started")
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
```
|
||||
|
||||
## Background Task Draining
|
||||
|
||||
`ServiceShutdown` waits for the internal task waitgroup to finish before calling stop hooks.
|
||||
|
||||
This is what makes `PerformAsync` safe for long-running work that should complete before teardown.
|
||||
|
||||
## `OnReload`
|
||||
|
||||
`Service` includes an `OnReload` callback field, but CoreGO does not currently expose a top-level lifecycle runner for reload operations.
|
||||
|
|
|
|||
|
|
@ -1,286 +1,127 @@
|
|||
---
|
||||
title: Messaging
|
||||
description: ACTION, QUERY, and PERFORM -- the message bus for decoupled service communication.
|
||||
description: ACTION, QUERY, QUERYALL, named Actions, and async dispatch.
|
||||
---
|
||||
|
||||
# Messaging
|
||||
|
||||
The message bus enables services to communicate without importing each other. It supports three patterns:
|
||||
CoreGO has two messaging layers: anonymous broadcast (ACTION/QUERY) and named Actions.
|
||||
|
||||
| Pattern | Method | Semantics |
|
||||
|---------|--------|-----------|
|
||||
| **ACTION** | `c.ACTION(msg)` | Broadcast to all handlers (fire-and-forget) |
|
||||
| **QUERY** | `c.QUERY(q)` | First responder wins (read-only) |
|
||||
| **PERFORM** | `c.PERFORM(t)` | First responder executes (side effects) |
|
||||
## Anonymous Broadcast
|
||||
|
||||
All three are type-safe at the handler level through Go type switches, while the bus itself uses `any` to avoid import cycles.
|
||||
### `ACTION`
|
||||
|
||||
## Message Types
|
||||
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
|
||||
// Any struct can be a message -- no interface to implement.
|
||||
type Message any // Used with ACTION
|
||||
type Query any // Used with QUERY / QUERYALL
|
||||
type Task any // Used with PERFORM / PerformAsync
|
||||
```
|
||||
|
||||
Define your message types as plain structs:
|
||||
|
||||
```go
|
||||
// In your package
|
||||
type UserCreated struct {
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
type GetUserCount struct{}
|
||||
|
||||
type SendEmail struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
```
|
||||
|
||||
## ACTION -- Broadcast
|
||||
|
||||
`ACTION` dispatches a message to **every** registered action handler. Handlers receive the message and can inspect it via type switch. All handlers are called regardless of whether they handle the specific message type.
|
||||
|
||||
### Dispatching
|
||||
|
||||
```go
|
||||
err := c.ACTION(UserCreated{UserID: "123", Email: "user@example.com"})
|
||||
```
|
||||
|
||||
Errors from all handlers are aggregated with `errors.Join`. If no handlers are registered, `ACTION` returns `nil`.
|
||||
|
||||
### Handling
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case UserCreated:
|
||||
fmt.Printf("New user: %s (%s)\n", m.UserID, m.Email)
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
if ev, ok := msg.(repositoryIndexed); ok {
|
||||
core.Info("indexed", "name", ev.Name)
|
||||
}
|
||||
return nil
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
|
||||
c.ACTION(repositoryIndexed{Name: "core-go"})
|
||||
```
|
||||
|
||||
### `QUERY`
|
||||
|
||||
First handler to return `OK:true` wins.
|
||||
|
||||
```go
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
if _, ok := q.(repositoryCountQuery); ok {
|
||||
return core.Result{Value: 42, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
r := c.QUERY(repositoryCountQuery{})
|
||||
```
|
||||
|
||||
### `QUERYALL`
|
||||
|
||||
Collects every successful non-nil response.
|
||||
|
||||
```go
|
||||
r := c.QUERYALL(repositoryCountQuery{})
|
||||
results := r.Value.([]any)
|
||||
```
|
||||
|
||||
## Named Actions
|
||||
|
||||
Named Actions are the typed, inspectable replacement for anonymous dispatch. See Section 18 of `RFC.md`.
|
||||
|
||||
### Register and Invoke
|
||||
|
||||
```go
|
||||
// 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}
|
||||
})
|
||||
|
||||
// Invoke by name
|
||||
r := c.Action("repo.sync").Run(ctx, core.NewOptions(
|
||||
core.Option{Key: "name", Value: "core-go"},
|
||||
))
|
||||
```
|
||||
|
||||
### Capability Check
|
||||
|
||||
```go
|
||||
if c.Action("process.run").Exists() {
|
||||
// go-process is registered
|
||||
}
|
||||
|
||||
c.Actions() // []string of all registered action names
|
||||
```
|
||||
|
||||
### Permission Gate
|
||||
|
||||
Every `Action.Run()` checks `c.Entitled(action.Name)` before executing. See Section 21 of `RFC.md`.
|
||||
|
||||
## Task Composition
|
||||
|
||||
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 {
|
||||
if ev, ok := msg.(core.ActionTaskCompleted); ok {
|
||||
core.Info("done", "task", ev.TaskIdentifier, "ok", ev.Result.OK)
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
```
|
||||
|
||||
You can register multiple handlers. Each handler receives every message -- use a type switch to filter.
|
||||
## Shutdown
|
||||
|
||||
```go
|
||||
// Register multiple handlers at once
|
||||
c.RegisterActions(handler1, handler2, handler3)
|
||||
```
|
||||
|
||||
### Auto-Discovery
|
||||
|
||||
If a service registered via `WithService` has a method called `HandleIPCEvents` with the signature `func(*Core, Message) error`, it is automatically registered as an action handler:
|
||||
|
||||
```go
|
||||
type Service struct{}
|
||||
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
switch msg.(type) {
|
||||
case UserCreated:
|
||||
// react to event
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## QUERY -- Request/Response
|
||||
|
||||
`QUERY` dispatches a query to handlers in registration order. The **first** handler that returns `handled == true` wins -- subsequent handlers are not called.
|
||||
|
||||
### Dispatching
|
||||
|
||||
```go
|
||||
result, handled, err := c.QUERY(GetUserCount{})
|
||||
if !handled {
|
||||
// no handler recognised this query
|
||||
}
|
||||
count := result.(int)
|
||||
```
|
||||
|
||||
### Handling
|
||||
|
||||
```go
|
||||
c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case GetUserCount:
|
||||
return 42, true, nil
|
||||
}
|
||||
return nil, false, nil // not handled -- pass to next handler
|
||||
})
|
||||
```
|
||||
|
||||
Return `false` for `handled` to let the query fall through to the next handler.
|
||||
|
||||
### QUERYALL -- Collect All Responses
|
||||
|
||||
`QUERYALL` calls **every** handler and collects all responses where `handled == true`:
|
||||
|
||||
```go
|
||||
results, err := c.QUERYALL(GetPluginInfo{})
|
||||
// results contains one entry per handler that responded
|
||||
```
|
||||
|
||||
Errors from all handlers are aggregated. Results from handlers that returned `handled == false` or `result == nil` are excluded.
|
||||
|
||||
## PERFORM -- Execute a Task
|
||||
|
||||
`PERFORM` dispatches a task to handlers in registration order. Like `QUERY`, the first handler that returns `handled == true` wins.
|
||||
|
||||
### Dispatching
|
||||
|
||||
```go
|
||||
result, handled, err := c.PERFORM(SendEmail{
|
||||
To: "user@example.com",
|
||||
Subject: "Welcome",
|
||||
Body: "Hello!",
|
||||
})
|
||||
if !handled {
|
||||
// no handler could execute this task
|
||||
}
|
||||
```
|
||||
|
||||
### Handling
|
||||
|
||||
```go
|
||||
c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch m := t.(type) {
|
||||
case SendEmail:
|
||||
err := sendMail(m.To, m.Subject, m.Body)
|
||||
return nil, true, err
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
```
|
||||
|
||||
## PerformAsync -- Background Tasks
|
||||
|
||||
`PerformAsync` dispatches a task to be executed in a background goroutine. It returns a task ID immediately.
|
||||
|
||||
```go
|
||||
taskID := c.PerformAsync(SendEmail{
|
||||
To: "user@example.com",
|
||||
Subject: "Report",
|
||||
Body: "...",
|
||||
})
|
||||
// taskID is something like "task-1"
|
||||
```
|
||||
|
||||
The lifecycle of an async task produces three action messages:
|
||||
|
||||
| Message | When |
|
||||
|---------|------|
|
||||
| `ActionTaskStarted{TaskID, Task}` | Immediately, before execution begins |
|
||||
| `ActionTaskProgress{TaskID, Task, Progress, Message}` | When `c.Progress()` is called |
|
||||
| `ActionTaskCompleted{TaskID, Task, Result, Error}` | After execution finishes |
|
||||
|
||||
### Listening for Completion
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case core.ActionTaskCompleted:
|
||||
fmt.Printf("Task %s finished: result=%v err=%v\n",
|
||||
m.TaskID, m.Result, m.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Reporting Progress
|
||||
|
||||
From within a task handler (or anywhere that has the task ID):
|
||||
|
||||
```go
|
||||
c.Progress(taskID, 0.5, "halfway done", myTask)
|
||||
```
|
||||
|
||||
This broadcasts an `ActionTaskProgress` message.
|
||||
|
||||
### TaskWithID
|
||||
|
||||
If your task struct implements `TaskWithID`, `PerformAsync` will inject the assigned task ID before dispatching:
|
||||
|
||||
```go
|
||||
type TaskWithID interface {
|
||||
Task
|
||||
SetTaskID(id string)
|
||||
GetTaskID() string
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type MyLongTask struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func (t *MyLongTask) SetTaskID(id string) { t.id = id }
|
||||
func (t *MyLongTask) GetTaskID() string { return t.id }
|
||||
```
|
||||
|
||||
### Shutdown Behaviour
|
||||
|
||||
- `PerformAsync` returns an empty string if the Core is already shut down.
|
||||
- `ServiceShutdown` waits for all in-flight async tasks to complete (respecting the context deadline).
|
||||
|
||||
## Real-World Example: Log Service
|
||||
|
||||
The `pkg/log` service demonstrates both query and task handling:
|
||||
|
||||
```go
|
||||
// Query type: "what is the current log level?"
|
||||
type QueryLevel struct{}
|
||||
|
||||
// Task type: "change the log level"
|
||||
type TaskSetLevel struct {
|
||||
Level Level
|
||||
}
|
||||
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case QueryLevel:
|
||||
return s.Level(), true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch m := t.(type) {
|
||||
case TaskSetLevel:
|
||||
s.SetLevel(m.Level)
|
||||
return nil, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
```
|
||||
|
||||
Other services can query or change the log level without importing the log package:
|
||||
|
||||
```go
|
||||
// Query the level
|
||||
level, handled, _ := c.QUERY(log.QueryLevel{})
|
||||
|
||||
// Change the level
|
||||
_, _, _ = c.PERFORM(log.TaskSetLevel{Level: log.LevelDebug})
|
||||
```
|
||||
|
||||
## Thread Safety
|
||||
|
||||
The message bus uses `sync.RWMutex` for each handler list (actions, queries, tasks). Registration and dispatch are safe to call concurrently from multiple goroutines. Handler lists are snapshot-cloned before dispatch, so handlers registered during dispatch will not be called until the next dispatch.
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Services](services.md) -- how services are registered
|
||||
- [Lifecycle](lifecycle.md) -- `ActionServiceStartup` and `ActionServiceShutdown` messages
|
||||
- [Testing](testing.md) -- testing message handlers
|
||||
When shutdown has started, `PerformAsync` returns an empty `Result`. `ServiceShutdown` drains outstanding background work before stopping services.
|
||||
|
|
|
|||
|
|
@ -1,616 +1,138 @@
|
|||
# Core Package Standards
|
||||
# AX Package Standards
|
||||
|
||||
This document defines the standards for creating packages in the Core framework. The `pkg/log` service is the reference implementation within this repo; standalone packages (go-session, go-store, etc.) follow the same patterns.
|
||||
This page describes how to build packages on top of CoreGO in the style described by RFC-025.
|
||||
|
||||
## Package Structure
|
||||
## 1. Prefer Predictable Names
|
||||
|
||||
A well-structured Core package follows this layout:
|
||||
Use names that tell an agent what the thing is without translation.
|
||||
|
||||
```
|
||||
pkg/mypackage/
|
||||
├── types.go # Public types, constants, interfaces
|
||||
├── service.go # Service struct with framework integration
|
||||
├── mypackage.go # Global convenience functions
|
||||
├── actions.go # ACTION messages for Core IPC (if needed)
|
||||
├── hooks.go # Event hooks with atomic handlers (if needed)
|
||||
├── [feature].go # Additional feature files
|
||||
├── [feature]_test.go # Tests alongside implementation
|
||||
└── service_test.go # Service tests
|
||||
```
|
||||
Good:
|
||||
|
||||
## Core Principles
|
||||
- `RepositoryService`
|
||||
- `RepositoryServiceOptions`
|
||||
- `WorkspaceCountQuery`
|
||||
- `SyncRepositoryTask`
|
||||
|
||||
1. **Service-oriented**: Packages expose a `Service` struct that integrates with the Core framework
|
||||
2. **Thread-safe**: All public APIs must be safe for concurrent use
|
||||
3. **Global convenience**: Provide package-level functions that use a default service instance
|
||||
4. **Options pattern**: Use functional options for configuration
|
||||
5. **ACTION-based IPC**: Communicate via Core's ACTION system, not callbacks
|
||||
Avoid shortening names unless the abbreviation is already universal.
|
||||
|
||||
---
|
||||
## 2. Put Real Usage in Comments
|
||||
|
||||
## Service Pattern
|
||||
Write comments that show a real call with realistic values.
|
||||
|
||||
### Service Struct
|
||||
|
||||
Embed `framework.ServiceRuntime[T]` for Core integration:
|
||||
Good:
|
||||
|
||||
```go
|
||||
// pkg/mypackage/service.go
|
||||
package mypackage
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Service provides mypackage functionality with Core integration.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
|
||||
// Internal state (protected by mutex)
|
||||
data map[string]any
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Options configures the service.
|
||||
type Options struct {
|
||||
// Document each option
|
||||
BufferSize int
|
||||
EnableFoo bool
|
||||
}
|
||||
// Sync a repository into the local workspace cache.
|
||||
// svc.SyncRepository("core-go", "/srv/repos/core-go")
|
||||
```
|
||||
|
||||
### Service Factory
|
||||
Avoid comments that only repeat the signature.
|
||||
|
||||
Create a factory function for Core registration:
|
||||
## 3. Keep Paths Semantic
|
||||
|
||||
If a command or template lives at a path, let the path explain the intent.
|
||||
|
||||
Good:
|
||||
|
||||
```text
|
||||
deploy/to/homelab
|
||||
workspace/create
|
||||
template/workspace/go
|
||||
```
|
||||
|
||||
That keeps the CLI, tests, docs, and message vocabulary aligned.
|
||||
|
||||
## 4. Reuse CoreGO Primitives
|
||||
|
||||
At Core boundaries, prefer the shared shapes:
|
||||
|
||||
- `core.Options` for lightweight input
|
||||
- `core.Result` for output
|
||||
- `core.Service` for lifecycle registration
|
||||
- `core.Message`, `core.Query`, `core.Task` for bus protocols
|
||||
|
||||
Inside your package, typed structs are still good. Use `ServiceRuntime[T]` when you want typed package options plus a `Core` reference.
|
||||
|
||||
```go
|
||||
// NewService creates a service factory for Core registration.
|
||||
//
|
||||
// core, _ := core.New(
|
||||
// core.WithName("mypackage", mypackage.NewService(mypackage.Options{})),
|
||||
// )
|
||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
// Apply defaults
|
||||
if opts.BufferSize == 0 {
|
||||
opts.BufferSize = DefaultBufferSize
|
||||
}
|
||||
type repositoryServiceOptions struct {
|
||||
BaseDirectory string
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
data: make(map[string]any),
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
type repositoryService struct {
|
||||
*core.ServiceRuntime[repositoryServiceOptions]
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
## 5. Prefer Explicit Registration
|
||||
|
||||
Implement `core.Startable` and/or `core.Stoppable`:
|
||||
Register services and commands with names and paths that stay readable in grep results.
|
||||
|
||||
```go
|
||||
// OnStartup implements core.Startable.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
// Register query/task handlers
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterAction(s.handleAction)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnShutdown implements core.Stoppable.
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
// Cleanup resources
|
||||
return nil
|
||||
}
|
||||
c.Service("repository", core.Service{...})
|
||||
c.Command("repository/sync", core.Command{...})
|
||||
```
|
||||
|
||||
---
|
||||
## 6. Use the Bus for Decoupling
|
||||
|
||||
## Global Default Pattern
|
||||
|
||||
Provide a global default service with atomic access:
|
||||
When one package needs another package’s behavior, prefer queries and tasks over tight package coupling.
|
||||
|
||||
```go
|
||||
// pkg/mypackage/mypackage.go
|
||||
package mypackage
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Global default service
|
||||
var (
|
||||
defaultService atomic.Pointer[Service]
|
||||
defaultOnce sync.Once
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// Default returns the global service instance.
|
||||
// Returns nil if not initialised.
|
||||
func Default() *Service {
|
||||
return defaultService.Load()
|
||||
}
|
||||
|
||||
// SetDefault sets the global service instance.
|
||||
// Thread-safe. Panics if s is nil.
|
||||
func SetDefault(s *Service) {
|
||||
if s == nil {
|
||||
panic("mypackage: SetDefault called with nil service")
|
||||
}
|
||||
defaultService.Store(s)
|
||||
}
|
||||
|
||||
// Init initialises the default service with a Core instance.
|
||||
func Init(c *core.Core) error {
|
||||
defaultOnce.Do(func() {
|
||||
factory := NewService(Options{})
|
||||
svc, err := factory(c)
|
||||
if err != nil {
|
||||
defaultErr = err
|
||||
return
|
||||
}
|
||||
defaultService.Store(svc.(*Service))
|
||||
})
|
||||
return defaultErr
|
||||
type repositoryCountQuery struct{}
|
||||
type syncRepositoryTask struct {
|
||||
Name string
|
||||
}
|
||||
```
|
||||
|
||||
### Global Convenience Functions
|
||||
That keeps the protocol visible in code and easy for agents to follow.
|
||||
|
||||
Expose the most common operations at package level:
|
||||
## 7. Use Structured Errors
|
||||
|
||||
Use `core.E`, `core.Wrap`, and `core.WrapCode`.
|
||||
|
||||
```go
|
||||
// ErrServiceNotInitialised is returned when the service is not initialised.
|
||||
var ErrServiceNotInitialised = errors.New("mypackage: service not initialised")
|
||||
|
||||
// DoSomething performs an operation using the default service.
|
||||
func DoSomething(arg string) (string, error) {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return "", ErrServiceNotInitialised
|
||||
}
|
||||
return svc.DoSomething(arg)
|
||||
return core.Result{
|
||||
Value: core.E("repository.Sync", "git fetch failed", err),
|
||||
OK: false,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
Do not introduce free-form `fmt.Errorf` chains in framework code.
|
||||
|
||||
## Options Pattern
|
||||
## 8. Keep Testing Names Predictable
|
||||
|
||||
Use functional options for complex configuration:
|
||||
Follow the repository pattern:
|
||||
|
||||
- `_Good`
|
||||
- `_Bad`
|
||||
- `_Ugly`
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
// Option configures a Service during construction.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithBufferSize sets the buffer size.
|
||||
func WithBufferSize(size int) Option {
|
||||
return func(s *Service) {
|
||||
s.bufSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// WithFoo enables foo feature.
|
||||
func WithFoo(enabled bool) Option {
|
||||
return func(s *Service) {
|
||||
s.fooEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a service with options.
|
||||
func New(opts ...Option) (*Service, error) {
|
||||
s := &Service{
|
||||
bufSize: DefaultBufferSize,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func TestRepositorySync_Good(t *testing.T) {}
|
||||
func TestRepositorySync_Bad(t *testing.T) {}
|
||||
func TestRepositorySync_Ugly(t *testing.T) {}
|
||||
```
|
||||
|
||||
---
|
||||
## 9. Prefer Stable Shapes Over Clever APIs
|
||||
|
||||
## ACTION Messages (IPC)
|
||||
For package APIs, avoid patterns that force an agent to infer too much hidden control flow.
|
||||
|
||||
For services that need to communicate events, define ACTION message types:
|
||||
Prefer:
|
||||
|
||||
```go
|
||||
// pkg/mypackage/actions.go
|
||||
package mypackage
|
||||
- clear structs
|
||||
- explicit names
|
||||
- path-based commands
|
||||
- visible message types
|
||||
|
||||
import "time"
|
||||
Avoid:
|
||||
|
||||
// ActionItemCreated is broadcast when an item is created.
|
||||
type ActionItemCreated struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
- implicit global state unless it is truly a default service
|
||||
- panic-hiding constructors
|
||||
- dense option chains when a small explicit struct would do
|
||||
|
||||
// ActionItemUpdated is broadcast when an item changes.
|
||||
type ActionItemUpdated struct {
|
||||
ID string
|
||||
Changes map[string]any
|
||||
}
|
||||
## 10. Document the Current Reality
|
||||
|
||||
// ActionItemDeleted is broadcast when an item is removed.
|
||||
type ActionItemDeleted struct {
|
||||
ID string
|
||||
}
|
||||
```
|
||||
If the implementation is in transition, document what the code does now, not the API shape you plan to have later.
|
||||
|
||||
Dispatch actions via `s.Core().ACTION()`:
|
||||
|
||||
```go
|
||||
func (s *Service) CreateItem(name string) (*Item, error) {
|
||||
item := &Item{ID: generateID(), Name: name}
|
||||
|
||||
// Store item...
|
||||
|
||||
// Broadcast to listeners
|
||||
s.Core().ACTION(ActionItemCreated{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
return item, nil
|
||||
}
|
||||
```
|
||||
|
||||
Consumers register handlers:
|
||||
|
||||
```go
|
||||
core.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case mypackage.ActionItemCreated:
|
||||
log.Printf("Item created: %s", m.Name)
|
||||
case mypackage.ActionItemDeleted:
|
||||
log.Printf("Item deleted: %s", m.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hooks Pattern
|
||||
|
||||
For user-customisable behaviour, use atomic handlers:
|
||||
|
||||
```go
|
||||
// pkg/mypackage/hooks.go
|
||||
package mypackage
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ErrorHandler is called when an error occurs.
|
||||
type ErrorHandler func(err error)
|
||||
|
||||
var errorHandler atomic.Value // stores ErrorHandler
|
||||
|
||||
// OnError registers an error handler.
|
||||
// Thread-safe. Pass nil to clear.
|
||||
func OnError(h ErrorHandler) {
|
||||
if h == nil {
|
||||
errorHandler.Store((ErrorHandler)(nil))
|
||||
return
|
||||
}
|
||||
errorHandler.Store(h)
|
||||
}
|
||||
|
||||
// dispatchError calls the registered error handler.
|
||||
func dispatchError(err error) {
|
||||
v := errorHandler.Load()
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
h, ok := v.(ErrorHandler)
|
||||
if !ok || h == nil {
|
||||
return
|
||||
}
|
||||
h(err)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thread Safety
|
||||
|
||||
### Mutex Patterns
|
||||
|
||||
Use `sync.RWMutex` for state that is read more than written:
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
data map[string]any
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (s *Service) Get(key string) (any, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
v, ok := s.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (s *Service) Set(key string, value any) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data[key] = value
|
||||
}
|
||||
```
|
||||
|
||||
### Atomic Values
|
||||
|
||||
Use `atomic.Pointer[T]` for single values accessed frequently:
|
||||
|
||||
```go
|
||||
var config atomic.Pointer[Config]
|
||||
|
||||
func GetConfig() *Config {
|
||||
return config.Load()
|
||||
}
|
||||
|
||||
func SetConfig(c *Config) {
|
||||
config.Store(c)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Types
|
||||
|
||||
Define package-level errors:
|
||||
|
||||
```go
|
||||
// Errors
|
||||
var (
|
||||
ErrNotFound = errors.New("mypackage: not found")
|
||||
ErrInvalidArg = errors.New("mypackage: invalid argument")
|
||||
ErrNotRunning = errors.New("mypackage: not running")
|
||||
)
|
||||
```
|
||||
|
||||
### Wrapped Errors
|
||||
|
||||
Use `fmt.Errorf` with `%w` for context:
|
||||
|
||||
```go
|
||||
func (s *Service) Load(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Error Struct (optional)
|
||||
|
||||
For errors needing additional context:
|
||||
|
||||
```go
|
||||
type ServiceError struct {
|
||||
Op string // Operation that failed
|
||||
Path string // Resource path
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *ServiceError) Error() string {
|
||||
return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
|
||||
}
|
||||
|
||||
func (e *ServiceError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Test File Organisation
|
||||
|
||||
Place tests alongside implementation:
|
||||
|
||||
```
|
||||
mypackage.go → mypackage_test.go
|
||||
service.go → service_test.go
|
||||
buffer.go → buffer_test.go
|
||||
```
|
||||
|
||||
### Test Helpers
|
||||
|
||||
Create helpers for common setup:
|
||||
|
||||
```go
|
||||
func newTestService(t *testing.T) (*Service, *core.Core) {
|
||||
t.Helper()
|
||||
|
||||
core, err := core.New(
|
||||
core.WithName("mypackage", NewService(Options{})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := core.ServiceFor[*Service](core, "mypackage")
|
||||
require.NoError(t, err)
|
||||
|
||||
return svc, core
|
||||
}
|
||||
```
|
||||
|
||||
### Test Naming Convention
|
||||
|
||||
Use descriptive subtests:
|
||||
|
||||
```go
|
||||
func TestService_DoSomething(t *testing.T) {
|
||||
t.Run("valid input", func(t *testing.T) {
|
||||
// ...
|
||||
})
|
||||
|
||||
t.Run("empty input returns error", func(t *testing.T) {
|
||||
// ...
|
||||
})
|
||||
|
||||
t.Run("concurrent access", func(t *testing.T) {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Actions
|
||||
|
||||
Verify ACTION broadcasts:
|
||||
|
||||
```go
|
||||
func TestService_BroadcastsActions(t *testing.T) {
|
||||
core, _ := core.New(
|
||||
core.WithName("mypackage", NewService(Options{})),
|
||||
)
|
||||
|
||||
var received []ActionItemCreated
|
||||
var mu sync.Mutex
|
||||
|
||||
core.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
if m, ok := msg.(ActionItemCreated); ok {
|
||||
mu.Lock()
|
||||
received = append(received, m)
|
||||
mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
svc, _ := core.ServiceFor[*Service](core, "mypackage")
|
||||
svc.CreateItem("test")
|
||||
|
||||
mu.Lock()
|
||||
assert.Len(t, received, 1)
|
||||
assert.Equal(t, "test", received[0].Name)
|
||||
mu.Unlock()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Package Doc
|
||||
|
||||
Every package needs a doc comment in the main file:
|
||||
|
||||
```go
|
||||
// Package mypackage provides functionality for X.
|
||||
//
|
||||
// # Getting Started
|
||||
//
|
||||
// svc, err := mypackage.New()
|
||||
// result := svc.DoSomething("input")
|
||||
//
|
||||
// # Core Integration
|
||||
//
|
||||
// core, _ := core.New(
|
||||
// core.WithName("mypackage", mypackage.NewService(mypackage.Options{})),
|
||||
// )
|
||||
package mypackage
|
||||
```
|
||||
|
||||
### Function Documentation
|
||||
|
||||
Document public functions with examples:
|
||||
|
||||
```go
|
||||
// DoSomething performs X operation with the given input.
|
||||
// Returns ErrInvalidArg if input is empty.
|
||||
//
|
||||
// result, err := svc.DoSomething("hello")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
func (s *Service) DoSomething(input string) (string, error) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
When creating a new package, ensure:
|
||||
|
||||
- [ ] `Service` struct embeds `framework.ServiceRuntime[Options]`
|
||||
- [ ] `NewService()` factory function for Core registration
|
||||
- [ ] `Default()` / `SetDefault()` with `atomic.Pointer`
|
||||
- [ ] Package-level convenience functions
|
||||
- [ ] Thread-safe public APIs (mutex or atomic)
|
||||
- [ ] ACTION messages for events (if applicable)
|
||||
- [ ] Hooks with atomic handlers (if applicable)
|
||||
- [ ] Comprehensive tests with helpers
|
||||
- [ ] Package documentation with examples
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
- **`pkg/log`** (this repo) — Service struct with Core integration, query/task handlers
|
||||
- **`core/go-store`** — SQLite KV store with Watch/OnChange, full service pattern
|
||||
- **`core/go-session`** — Transcript parser with analytics, factory pattern
|
||||
|
||||
---
|
||||
|
||||
## Background Operations
|
||||
|
||||
For long-running operations that could block the UI, use the framework's background task mechanism.
|
||||
|
||||
### Principles
|
||||
|
||||
1. **Non-blocking**: Long-running operations must not block the main IPC thread.
|
||||
2. **Lifecycle Events**: Use `PerformAsync` to automatically broadcast start and completion events.
|
||||
3. **Progress Reporting**: Services should broadcast `ActionTaskProgress` for granular updates.
|
||||
|
||||
### Using PerformAsync
|
||||
|
||||
The `Core.PerformAsync(task)` method runs any registered task in a background goroutine and returns a unique `TaskID` immediately.
|
||||
|
||||
```go
|
||||
// From the frontend or another service
|
||||
taskID := core.PerformAsync(git.TaskPush{Path: "/repo"})
|
||||
// taskID is returned immediately, e.g., "task-123"
|
||||
```
|
||||
|
||||
The framework automatically broadcasts lifecycle actions:
|
||||
- `ActionTaskStarted`: When the background goroutine begins.
|
||||
- `ActionTaskCompleted`: When the task finishes (contains Result and Error).
|
||||
|
||||
### Reporting Progress
|
||||
|
||||
For very long operations, the service handler should broadcast progress:
|
||||
|
||||
```go
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch m := t.(type) {
|
||||
case MyLongTask:
|
||||
// Optional: If you need to report progress, you might need to pass
|
||||
// a TaskID or use a specific progress channel.
|
||||
// For now, simple tasks just use ActionTaskCompleted.
|
||||
return s.doLongWork(m), true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Implementing Background-Safe Handlers
|
||||
|
||||
Ensure that handlers for long-running tasks:
|
||||
1. Use `context.Background()` or a long-lived context, as the request context might expire.
|
||||
2. Are thread-safe and don't hold global locks for the duration of the work.
|
||||
3. Do not use interactive CLI functions like `cli.Scanln` if they are intended for GUI use.
|
||||
That keeps agents correct on first pass, which is the real AX metric.
|
||||
|
|
|
|||
681
docs/pkg/core.md
681
docs/pkg/core.md
|
|
@ -1,610 +1,81 @@
|
|||
# pkg/core -- Dependency Injection & Service Framework
|
||||
# Package Reference: `core`
|
||||
|
||||
`pkg/core` is the foundation of the Core application framework. It provides a dependency injection container, service lifecycle management, and a message bus for inter-service communication. Every other package in the ecosystem builds on top of it.
|
||||
|
||||
The package is designed for use with Wails v3 (desktop GUI) but is equally useful in CLI and headless applications.
|
||||
|
||||
---
|
||||
|
||||
## Core Struct
|
||||
|
||||
`Core` is the central application object. It owns the service registry, the message bus, embedded assets, and feature flags.
|
||||
Import path:
|
||||
|
||||
```go
|
||||
type Core struct {
|
||||
App any // GUI runtime (e.g. Wails App), set by WithApp
|
||||
Features *Features // Feature flags
|
||||
// unexported: svc *serviceManager, bus *messageBus, assets embed.FS
|
||||
}
|
||||
import "dappco.re/go/core"
|
||||
```
|
||||
|
||||
### Creating a Core Instance
|
||||
|
||||
`New()` is the sole constructor. It accepts a variadic list of `Option` functions that configure the instance before it is returned. After all options are applied, the service lock is finalised.
|
||||
|
||||
```go
|
||||
c, err := core.New(
|
||||
core.WithService(mypackage.NewMyService),
|
||||
core.WithAssets(embeddedFS),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
```
|
||||
|
||||
If any option returns an error, `New()` returns `nil` and that error immediately.
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Purpose |
|
||||
|--------|---------|
|
||||
| `WithService(factory)` | Register a service via factory function. Auto-discovers the service name from the factory's return type package path and auto-registers an IPC handler if the service has a `HandleIPCEvents` method. |
|
||||
| `WithName(name, factory)` | Register a service with an explicit name. Does **not** auto-discover IPC handlers. |
|
||||
| `WithApp(app)` | Inject a GUI runtime (e.g. Wails `*application.App`) into `Core.App`. |
|
||||
| `WithAssets(fs)` | Attach an `embed.FS` containing frontend assets. |
|
||||
| `WithServiceLock()` | Prevent any further service registration after `New()` completes. Calls to `RegisterService` after the lock is applied return an error. |
|
||||
|
||||
The `Option` type is defined as:
|
||||
|
||||
```go
|
||||
type Option func(*Core) error
|
||||
```
|
||||
|
||||
### Service Retrieval
|
||||
|
||||
Services are retrieved by name. Two generic helpers provide type-safe access:
|
||||
|
||||
```go
|
||||
// Returns (T, error) -- safe version
|
||||
svc, err := core.ServiceFor[*MyService](c, "myservice")
|
||||
|
||||
// Panics if not found or wrong type -- use in init paths
|
||||
svc := core.MustServiceFor[*MyService](c, "myservice")
|
||||
```
|
||||
|
||||
The untyped `Service(name)` method returns `any` (or `nil` if not found).
|
||||
|
||||
### Convenience Accessors
|
||||
|
||||
`Core` provides shorthand methods for well-known services:
|
||||
|
||||
```go
|
||||
c.Config() // returns Config interface
|
||||
c.Display() // returns Display interface
|
||||
c.Workspace() // returns Workspace interface
|
||||
c.Crypt() // returns Crypt interface
|
||||
```
|
||||
|
||||
Each calls `MustServiceFor` internally and will panic if the named service is not registered.
|
||||
|
||||
### Global Instance
|
||||
|
||||
For GUI runtimes that require global access, a singleton pattern is available:
|
||||
|
||||
```go
|
||||
core.SetInstance(c) // store globally (thread-safe)
|
||||
app := core.App() // retrieve Core.App (panics if not set)
|
||||
inst := core.GetInstance() // retrieve *Core (returns nil if not set)
|
||||
core.ClearInstance() // reset to nil (primarily for tests)
|
||||
```
|
||||
|
||||
### Feature Flags
|
||||
|
||||
The `Features` struct holds a simple string slice of enabled flags:
|
||||
|
||||
```go
|
||||
c.Features.Flags = []string{"dark-mode", "beta-api"}
|
||||
c.Features.IsEnabled("dark-mode") // true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Pattern
|
||||
|
||||
### Factory Functions
|
||||
|
||||
Services are created via factory functions that receive the `*Core` and return `(any, error)`:
|
||||
|
||||
```go
|
||||
func NewMyService(c *core.Core) (any, error) {
|
||||
return &MyService{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 64}),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
The factory is called during `New()` when the corresponding `WithService` or `WithName` option is processed.
|
||||
|
||||
### ServiceRuntime[T]
|
||||
|
||||
`ServiceRuntime[T]` is a generic helper struct that services embed to gain access to the `Core` instance and typed options:
|
||||
|
||||
```go
|
||||
type ServiceRuntime[T any] struct {
|
||||
core *core.Core
|
||||
opts T
|
||||
}
|
||||
```
|
||||
|
||||
Constructor:
|
||||
|
||||
```go
|
||||
rt := core.NewServiceRuntime[MyOptions](c, MyOptions{BufferSize: 64})
|
||||
```
|
||||
|
||||
Methods:
|
||||
|
||||
| Method | Returns |
|
||||
|--------|---------|
|
||||
| `Core()` | `*Core` -- the parent container |
|
||||
| `Opts()` | `T` -- the service's typed options |
|
||||
| `Config()` | `Config` -- shorthand for `Core().Config()` |
|
||||
|
||||
Example service:
|
||||
|
||||
```go
|
||||
type MyService struct {
|
||||
*core.ServiceRuntime[MyOptions]
|
||||
items map[string]string
|
||||
}
|
||||
|
||||
type MyOptions struct {
|
||||
BufferSize int
|
||||
}
|
||||
|
||||
func NewMyService(c *core.Core) (any, error) {
|
||||
return &MyService{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 128}),
|
||||
items: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Startable and Stoppable Interfaces
|
||||
|
||||
Services that need lifecycle hooks implement one or both of:
|
||||
|
||||
```go
|
||||
type Startable interface {
|
||||
OnStartup(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Stoppable interface {
|
||||
OnShutdown(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
The service manager detects these interfaces at registration time and stores references internally.
|
||||
|
||||
- **Startup**: `ServiceStartup()` calls `OnStartup` on every `Startable` service in registration order, then broadcasts `ActionServiceStartup{}` via the message bus.
|
||||
- **Shutdown**: `ServiceShutdown()` first broadcasts `ActionServiceShutdown{}`, then calls `OnShutdown` on every `Stoppable` service in **reverse** registration order. This ensures that services which were started last are stopped first, respecting dependency order.
|
||||
|
||||
Errors from individual services are aggregated via `errors.Join` and returned together, so one failing service does not prevent others from completing their lifecycle.
|
||||
|
||||
### Service Lock
|
||||
|
||||
When `WithServiceLock()` is passed to `New()`, the `serviceManager` sets `lockEnabled = true` during option processing. After all options have been applied, `applyLock()` sets `locked = true`. Any subsequent call to `RegisterService` returns an error:
|
||||
|
||||
```
|
||||
core: service "late-service" is not permitted by the serviceLock setting
|
||||
```
|
||||
|
||||
This prevents accidental late-binding of services after the application has been fully wired.
|
||||
|
||||
### Service Name Discovery
|
||||
|
||||
`WithService` derives the service name from the Go package path of the returned struct. For a type `myapp/services.Calculator`, the name becomes `services`. For `myapp/calculator.Service`, it becomes `calculator`.
|
||||
|
||||
To control the name explicitly, use `WithName("calc", factory)`.
|
||||
|
||||
### IPC Handler Discovery
|
||||
|
||||
`WithService` also checks whether the service has a method named `HandleIPCEvents` with signature `func(*Core, Message) error`. If found, it is automatically registered as an ACTION handler via `RegisterAction`.
|
||||
|
||||
`WithName` does **not** perform this discovery. Register handlers manually if needed.
|
||||
|
||||
---
|
||||
|
||||
## Message Bus
|
||||
|
||||
The message bus provides three distinct communication patterns, all thread-safe:
|
||||
|
||||
### 1. ACTION -- Fire-and-Forget Broadcast
|
||||
|
||||
`ACTION` dispatches a message to **all** registered handlers. Every handler is called; errors are aggregated.
|
||||
|
||||
```go
|
||||
// Define a message type
|
||||
type OrderPlaced struct {
|
||||
OrderID string
|
||||
Total float64
|
||||
}
|
||||
|
||||
// Dispatch
|
||||
err := c.ACTION(OrderPlaced{OrderID: "abc", Total: 42.50})
|
||||
|
||||
// Register a handler
|
||||
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case OrderPlaced:
|
||||
log.Printf("Order %s placed for %.2f", m.OrderID, m.Total)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
Multiple handlers can be registered at once with `RegisterActions(h1, h2, h3)`.
|
||||
|
||||
The `Message` type is defined as `any`, so any struct can serve as a message. Handlers use a type switch to filter messages they care about.
|
||||
|
||||
**Built-in action messages:**
|
||||
|
||||
| Message | Broadcast when |
|
||||
|---------|---------------|
|
||||
| `ActionServiceStartup{}` | After all `Startable.OnStartup` calls complete |
|
||||
| `ActionServiceShutdown{}` | Before `Stoppable.OnShutdown` calls begin |
|
||||
| `ActionTaskStarted{TaskID, Task}` | A `PerformAsync` task begins |
|
||||
| `ActionTaskProgress{TaskID, Task, Progress, Message}` | A background task reports progress |
|
||||
| `ActionTaskCompleted{TaskID, Task, Result, Error}` | A `PerformAsync` task finishes |
|
||||
|
||||
### 2. QUERY -- Read-Only Request/Response
|
||||
|
||||
`QUERY` dispatches a query to handlers until the **first** one responds (returns `handled = true`). Remaining handlers are skipped.
|
||||
|
||||
```go
|
||||
type GetUserByID struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// Register
|
||||
c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch req := q.(type) {
|
||||
case GetUserByID:
|
||||
user, err := db.Find(req.ID)
|
||||
return user, true, err
|
||||
}
|
||||
return nil, false, nil // not handled -- pass to next handler
|
||||
})
|
||||
|
||||
// Dispatch
|
||||
result, handled, err := c.QUERY(GetUserByID{ID: "u-123"})
|
||||
if !handled {
|
||||
// no handler recognised this query
|
||||
}
|
||||
user := result.(*User)
|
||||
```
|
||||
|
||||
`QUERYALL` dispatches the query to **all** handlers and collects every non-nil result:
|
||||
|
||||
```go
|
||||
results, err := c.QUERYALL(ListPlugins{})
|
||||
// results is []any containing responses from every handler that responded
|
||||
```
|
||||
|
||||
The `Query` type is `any`. The `QueryHandler` signature is:
|
||||
|
||||
```go
|
||||
type QueryHandler func(*Core, Query) (any, bool, error)
|
||||
```
|
||||
|
||||
### 3. TASK -- Side-Effect Request/Response
|
||||
|
||||
`PERFORM` dispatches a task to handlers until the **first** one executes it (returns `handled = true`). Semantically identical to `QUERY` but intended for operations with side effects.
|
||||
|
||||
```go
|
||||
type SendEmail struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch task := t.(type) {
|
||||
case SendEmail:
|
||||
err := mailer.Send(task.To, task.Subject, task.Body)
|
||||
return nil, true, err
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(SendEmail{
|
||||
To: "user@example.com",
|
||||
Subject: "Welcome",
|
||||
Body: "Hello!",
|
||||
})
|
||||
```
|
||||
|
||||
The `Task` type is `any`. The `TaskHandler` signature is:
|
||||
|
||||
```go
|
||||
type TaskHandler func(*Core, Task) (any, bool, error)
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
`PerformAsync` runs a `PERFORM` dispatch in a background goroutine and returns a task ID immediately:
|
||||
|
||||
```go
|
||||
taskID := c.PerformAsync(BuildProject{Path: "/src"})
|
||||
// taskID is "task-1", "task-2", etc.
|
||||
```
|
||||
|
||||
The framework automatically broadcasts:
|
||||
|
||||
1. `ActionTaskStarted` -- when the goroutine begins
|
||||
2. `ActionTaskCompleted` -- when the task finishes (includes `Result` and `Error`)
|
||||
|
||||
If the task implements `TaskWithID`, the framework injects the assigned ID before execution:
|
||||
|
||||
```go
|
||||
type TaskWithID interface {
|
||||
Task
|
||||
SetTaskID(id string)
|
||||
GetTaskID() string
|
||||
}
|
||||
```
|
||||
|
||||
Services can report progress during long-running tasks:
|
||||
|
||||
```go
|
||||
c.Progress(taskID, 0.5, "Compiling 50%...", task)
|
||||
// Broadcasts ActionTaskProgress{TaskID: taskID, Progress: 0.5, Message: "..."}
|
||||
```
|
||||
|
||||
### Thread Safety
|
||||
|
||||
The message bus uses `sync.RWMutex` for each handler slice (IPC, query, task). Handler registration acquires a write lock; dispatch acquires a read lock and copies the handler slice before iterating, so dispatches never block registrations.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The `Error` struct provides contextual error wrapping:
|
||||
|
||||
```go
|
||||
type Error struct {
|
||||
Op string // operation, e.g. "config.Load"
|
||||
Msg string // human-readable description
|
||||
Err error // underlying error (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### E() Helper
|
||||
|
||||
`E()` is the primary constructor:
|
||||
|
||||
```go
|
||||
return core.E("config.Load", "failed to read config file", err)
|
||||
// Output: "config.Load: failed to read config file: <underlying error>"
|
||||
|
||||
return core.E("auth.Login", "invalid credentials", nil)
|
||||
// Output: "auth.Login: invalid credentials"
|
||||
```
|
||||
|
||||
When `err` is `nil`, the resulting `Error` has no wrapped cause.
|
||||
|
||||
### Error Chain Compatibility
|
||||
|
||||
`Error` implements `Unwrap()`, so it works with `errors.Is()` and `errors.As()`:
|
||||
|
||||
```go
|
||||
var coreErr *core.Error
|
||||
if errors.As(err, &coreErr) {
|
||||
log.Printf("Operation: %s, Message: %s", coreErr.Op, coreErr.Msg)
|
||||
}
|
||||
```
|
||||
|
||||
### Convention
|
||||
|
||||
The `Op` field should follow `package.Function` or `service.Method` format. The `Msg` field should be a human-readable sentence suitable for display to end users.
|
||||
|
||||
---
|
||||
|
||||
## Runtime (Wails Integration)
|
||||
|
||||
The `Runtime` struct wraps `Core` for use as a Wails service. It implements the Wails service interface (`ServiceName`, `ServiceStartup`, `ServiceShutdown`).
|
||||
|
||||
```go
|
||||
type Runtime struct {
|
||||
app any // GUI runtime
|
||||
Core *Core
|
||||
}
|
||||
```
|
||||
|
||||
### NewRuntime
|
||||
|
||||
Creates a minimal runtime with no custom services:
|
||||
|
||||
```go
|
||||
rt, err := core.NewRuntime(wailsApp)
|
||||
```
|
||||
|
||||
### NewWithFactories
|
||||
|
||||
Creates a runtime with named service factories. Factories are called in sorted (alphabetical) order to ensure deterministic initialisation:
|
||||
|
||||
```go
|
||||
rt, err := core.NewWithFactories(wailsApp, map[string]core.ServiceFactory{
|
||||
"calculator": func() (any, error) { return &Calculator{}, nil },
|
||||
"logger": func() (any, error) { return &Logger{}, nil },
|
||||
})
|
||||
```
|
||||
|
||||
`ServiceFactory` is defined as `func() (any, error)` -- note it does **not** receive `*Core`, unlike the `WithService` factory. The `Runtime` wraps each factory result with `WithName` internally.
|
||||
|
||||
### Lifecycle Delegation
|
||||
|
||||
`Runtime.ServiceStartup` and `Runtime.ServiceShutdown` delegate directly to `Core.ServiceStartup` and `Core.ServiceShutdown`. The Wails runtime calls these automatically.
|
||||
|
||||
```go
|
||||
func (r *Runtime) ServiceStartup(ctx context.Context, options any) {
|
||||
_ = r.Core.ServiceStartup(ctx, options)
|
||||
}
|
||||
|
||||
func (r *Runtime) ServiceShutdown(ctx context.Context) {
|
||||
if r.Core != nil {
|
||||
_ = r.Core.ServiceShutdown(ctx)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interfaces
|
||||
|
||||
`pkg/core` defines several interfaces that services may implement or consume. These decouple services from concrete implementations.
|
||||
|
||||
### Lifecycle Interfaces
|
||||
|
||||
| Interface | Method | Purpose |
|
||||
|-----------|--------|---------|
|
||||
| `Startable` | `OnStartup(ctx) error` | Initialisation on app start |
|
||||
| `Stoppable` | `OnShutdown(ctx) error` | Cleanup on app shutdown |
|
||||
|
||||
### Well-Known Service Interfaces
|
||||
|
||||
| Interface | Service name | Key methods |
|
||||
|-----------|-------------|-------------|
|
||||
| `Config` | `"config"` | `Get(key, out) error`, `Set(key, v) error` |
|
||||
| `Display` | `"display"` | `OpenWindow(opts...) error` |
|
||||
| `Workspace` | `"workspace"` | `CreateWorkspace`, `SwitchWorkspace`, `WorkspaceFileGet`, `WorkspaceFileSet` |
|
||||
| `Crypt` | `"crypt"` | `CreateKeyPair`, `EncryptPGP`, `DecryptPGP` |
|
||||
|
||||
These interfaces live in `interfaces.go` and define the contracts that concrete implementations must satisfy.
|
||||
|
||||
### Contract
|
||||
|
||||
The `Contract` struct configures resilience behaviour:
|
||||
|
||||
```go
|
||||
type Contract struct {
|
||||
DontPanic bool // recover from panics, return errors instead
|
||||
DisableLogging bool // suppress all logging
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
Putting it all together -- a service that stores items, broadcasts actions, and responds to queries:
|
||||
|
||||
```go
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options configures the inventory service.
|
||||
type Options struct {
|
||||
MaxItems int
|
||||
}
|
||||
|
||||
// Service manages an inventory of items.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
items map[string]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewService creates a factory for Core registration.
|
||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
if opts.MaxItems == 0 {
|
||||
opts.MaxItems = 1000
|
||||
}
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
items: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup registers query and task handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -- Query: look up an item --
|
||||
|
||||
type GetItem struct{ ID string }
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch req := q.(type) {
|
||||
case GetItem:
|
||||
s.mu.RLock()
|
||||
val, ok := s.items[req.ID]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, true, core.E("inventory.GetItem", "not found", nil)
|
||||
}
|
||||
return val, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// -- Task: add an item --
|
||||
|
||||
type AddItem struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type ItemAdded struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch task := t.(type) {
|
||||
case AddItem:
|
||||
s.mu.Lock()
|
||||
s.items[task.ID] = task.Name
|
||||
s.mu.Unlock()
|
||||
|
||||
_ = c.ACTION(ItemAdded{ID: task.ID, Name: task.Name})
|
||||
return task.ID, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// -- Wiring it up --
|
||||
|
||||
func main() {
|
||||
c, err := core.New(
|
||||
core.WithName("inventory", NewService(Options{MaxItems: 500})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start lifecycle
|
||||
_ = c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
// Use the bus
|
||||
_, _, _ = c.PERFORM(AddItem{ID: "item-1", Name: "Widget"})
|
||||
result, _, _ := c.QUERY(GetItem{ID: "item-1"})
|
||||
// result == "Widget"
|
||||
|
||||
// Shutdown
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `core.go` | `New()`, options (`WithService`, `WithName`, `WithApp`, `WithAssets`, `WithServiceLock`), `ServiceFor[T]`, `MustServiceFor[T]`, lifecycle dispatch, global instance, bus method delegation |
|
||||
| `interfaces.go` | `Core` struct definition, `Option`, `Message`, `Query`, `Task`, `QueryHandler`, `TaskHandler`, `Startable`, `Stoppable`, `Contract`, `Features`, well-known service interfaces (`Config`, `Display`, `Workspace`, `Crypt`), built-in action message types |
|
||||
| `message_bus.go` | `messageBus` struct, `action()`, `query()`, `queryAll()`, `perform()`, handler registration |
|
||||
| `service_manager.go` | `serviceManager` struct, service registry, `Startable`/`Stoppable` tracking, service lock mechanism |
|
||||
| `runtime_pkg.go` | `ServiceRuntime[T]` generic helper, `Runtime` struct (Wails integration), `NewRuntime()`, `NewWithFactories()` |
|
||||
| `e.go` | `Error` struct, `E()` constructor, `Unwrap()` for error chain compatibility |
|
||||
This repository exposes one root package. The main areas are:
|
||||
|
||||
## Constructors and Accessors
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `New` | Create a `*Core` |
|
||||
| `NewRuntime` | Create an empty runtime wrapper |
|
||||
| `NewWithFactories` | Create a runtime wrapper from named service factories |
|
||||
| `Options`, `App`, `Data`, `Drive`, `Fs`, `Config`, `Error`, `Log`, `Cli`, `IPC`, `I18n`, `Context` | Access the built-in subsystems |
|
||||
|
||||
## Core Primitives
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Option`, `Options` | Input configuration and metadata |
|
||||
| `Result` | Shared output shape |
|
||||
| `Service` | Lifecycle DTO |
|
||||
| `Command` | Command tree node |
|
||||
| `Message`, `Query`, `Task` | Message bus payload types |
|
||||
|
||||
## Service and Runtime APIs
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Service` | Register or read a named service |
|
||||
| `Services` | List registered service names |
|
||||
| `Startables`, `Stoppables` | Snapshot lifecycle-capable services |
|
||||
| `LockEnable`, `LockApply` | Activate the service registry lock |
|
||||
| `ServiceRuntime[T]` | Helper for package authors |
|
||||
|
||||
## Command and CLI APIs
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Command` | Register or read a command by path |
|
||||
| `Commands` | List command paths |
|
||||
| `Cli().Run` | Resolve arguments to a command and execute it |
|
||||
| `Cli().PrintHelp` | Show executable commands |
|
||||
|
||||
## Messaging APIs
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `ACTION`, `Action` | Broadcast a message |
|
||||
| `QUERY`, `Query` | Return the first successful query result |
|
||||
| `QUERYALL`, `QueryAll` | Collect all successful query results |
|
||||
| `PERFORM`, `Perform` | Run the first task handler that accepts the task |
|
||||
| `PerformAsync` | Run a task in the background |
|
||||
| `Progress` | Broadcast async task progress |
|
||||
| `RegisterAction`, `RegisterActions`, `RegisterQuery`, `RegisterTask` | Register bus handlers |
|
||||
|
||||
## Subsystems
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Config` | Runtime settings and feature flags |
|
||||
| `Data` | Embedded filesystem mounts |
|
||||
| `Drive` | Named transport handles |
|
||||
| `Fs` | Local filesystem operations |
|
||||
| `I18n` | Locale collection and translation delegation |
|
||||
| `App`, `Find` | Application identity and executable lookup |
|
||||
|
||||
## Errors and Logging
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `E`, `Wrap`, `WrapCode`, `NewCode` | Structured error creation |
|
||||
| `Operation`, `ErrorCode`, `ErrorMessage`, `Root`, `StackTrace`, `FormatStackTrace` | Error inspection |
|
||||
| `NewLog`, `Default`, `SetDefault`, `SetLevel`, `SetRedactKeys` | Logger creation and defaults |
|
||||
| `LogErr`, `LogPanic`, `ErrorLog`, `ErrorPanic` | Error-aware logging and panic recovery |
|
||||
|
||||
Use the top-level docs in `docs/` for task-oriented guidance, then use this page as a compact reference.
|
||||
|
|
|
|||
112
docs/pkg/log.md
112
docs/pkg/log.md
|
|
@ -1,55 +1,83 @@
|
|||
# Log Retention Policy
|
||||
# Logging Reference
|
||||
|
||||
The `log` package provides structured logging with automatic log rotation and retention management.
|
||||
Logging lives in the root `core` package in this repository. There is no separate `pkg/log` import path here.
|
||||
|
||||
## Retention Policy
|
||||
|
||||
By default, the following log retention policy is applied when log rotation is enabled:
|
||||
|
||||
- **Max Size**: 100 MB per log file.
|
||||
- **Max Backups**: 5 old log files are retained.
|
||||
- **Max Age**: 28 days. Old log files beyond this age are automatically deleted. (Set to -1 to disable age-based retention).
|
||||
- **Compression**: Rotated log files can be compressed (future feature).
|
||||
|
||||
## Configuration
|
||||
|
||||
Logging can be configured using the `log.Options` struct. To enable log rotation to a file, provide a `RotationOptions` struct. If both `Output` and `Rotation` are provided, `Rotation` takes precedence and `Output` is ignored.
|
||||
|
||||
### Standalone Usage
|
||||
## Create a Logger
|
||||
|
||||
```go
|
||||
logger := log.New(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
Rotation: &log.RotationOptions{
|
||||
Filename: "app.log",
|
||||
MaxSize: 100, // MB
|
||||
MaxBackups: 5,
|
||||
MaxAge: 28, // days
|
||||
},
|
||||
logger := core.NewLog(core.LogOptions{
|
||||
Level: core.LevelInfo,
|
||||
})
|
||||
|
||||
logger.Info("application started")
|
||||
```
|
||||
|
||||
### Framework Integration
|
||||
## Levels
|
||||
|
||||
When using the Core framework, logging is usually configured during application initialization:
|
||||
| Level | Meaning |
|
||||
|-------|---------|
|
||||
| `LevelQuiet` | no output |
|
||||
| `LevelError` | errors and security events |
|
||||
| `LevelWarn` | warnings, errors, security events |
|
||||
| `LevelInfo` | informational, warnings, errors, security events |
|
||||
| `LevelDebug` | everything |
|
||||
|
||||
## Log Methods
|
||||
|
||||
```go
|
||||
app, _ := core.New(
|
||||
core.WithName("log", log.NewService(log.Options{
|
||||
Level: log.LevelDebug,
|
||||
Rotation: &log.RotationOptions{
|
||||
Filename: "/var/log/my-app.log",
|
||||
},
|
||||
})),
|
||||
)
|
||||
logger.Debug("workspace discovered", "path", "/srv/workspaces")
|
||||
logger.Info("service started", "service", "audit")
|
||||
logger.Warn("retrying fetch", "attempt", 2)
|
||||
logger.Error("fetch failed", "err", err)
|
||||
logger.Security("sandbox escape detected", "path", attemptedPath)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## Default Logger
|
||||
|
||||
1. **Rotation**: When the current log file exceeds `MaxSize`, it is rotated. The current file is renamed to `filename.1`, `filename.1` is renamed to `filename.2`, and so on.
|
||||
2. **Retention**:
|
||||
- Files beyond `MaxBackups` are automatically deleted during rotation.
|
||||
- Files older than `MaxAge` days are automatically deleted during the cleanup process.
|
||||
3. **Appends**: When an application restarts, it appends to the existing log file instead of truncating it.
|
||||
The package owns a default logger.
|
||||
|
||||
```go
|
||||
core.SetLevel(core.LevelDebug)
|
||||
core.SetRedactKeys("token", "password")
|
||||
|
||||
core.Info("service started", "service", "audit")
|
||||
```
|
||||
|
||||
## Redaction
|
||||
|
||||
Values for keys listed in `RedactKeys` are replaced with `[REDACTED]`.
|
||||
|
||||
```go
|
||||
logger.SetRedactKeys("token")
|
||||
logger.Info("login", "user", "cladius", "token", "secret-value")
|
||||
```
|
||||
|
||||
## Output and Rotation
|
||||
|
||||
```go
|
||||
logger := core.NewLog(core.LogOptions{
|
||||
Level: core.LevelInfo,
|
||||
Output: os.Stderr,
|
||||
})
|
||||
```
|
||||
|
||||
If you provide `Rotation` and set `RotationWriterFactory`, the logger writes to the rotating writer instead of the plain output stream.
|
||||
|
||||
## Error-Aware Logging
|
||||
|
||||
`LogErr` extracts structured error context before logging:
|
||||
|
||||
```go
|
||||
le := core.NewLogErr(logger)
|
||||
le.Log(err)
|
||||
```
|
||||
|
||||
`ErrorLog` is the log-and-return wrapper exposed through `c.Log()`.
|
||||
|
||||
## Panic-Aware Logging
|
||||
|
||||
`LogPanic` is the lightweight panic logger:
|
||||
|
||||
```go
|
||||
defer core.NewLogPanic(logger).Recover()
|
||||
```
|
||||
|
||||
It logs the recovered panic but does not manage crash files. For crash reports, use `c.Error().Recover()`.
|
||||
|
|
|
|||
176
docs/primitives.md
Normal file
176
docs/primitives.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
---
|
||||
title: Core Primitives
|
||||
description: The repeated shapes that make CoreGO easy to navigate.
|
||||
---
|
||||
|
||||
# Core Primitives
|
||||
|
||||
CoreGO is built from a small vocabulary repeated everywhere.
|
||||
|
||||
## Primitive Map
|
||||
|
||||
| Type | Used For |
|
||||
|------|----------|
|
||||
| `Option` / `Options` | Input values and metadata |
|
||||
| `Result` | Output values and success state |
|
||||
| `Service` | Lifecycle-managed components |
|
||||
| `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.NewOptions(
|
||||
core.Option{Key: "name", Value: "brain"},
|
||||
core.Option{Key: "path", Value: "prompts"},
|
||||
core.Option{Key: "debug", Value: true},
|
||||
)
|
||||
|
||||
name := opts.String("name")
|
||||
debug := opts.Bool("debug")
|
||||
raw := opts.Get("name") // Result{Value, OK}
|
||||
opts.Has("path") // true
|
||||
opts.Len() // 3
|
||||
```
|
||||
|
||||
## `Result`
|
||||
|
||||
Universal return shape. Every Core operation returns Result.
|
||||
|
||||
```go
|
||||
type Result struct {
|
||||
Value any
|
||||
OK bool
|
||||
}
|
||||
|
||||
r := c.Config().Get("host")
|
||||
if r.OK {
|
||||
host := r.Value.(string)
|
||||
}
|
||||
```
|
||||
|
||||
The `Result()` method adapts Go `(value, error)` pairs:
|
||||
|
||||
```go
|
||||
r := core.Result{}.Result(file, err)
|
||||
```
|
||||
|
||||
## `Service`
|
||||
|
||||
Managed lifecycle component stored in the `ServiceRegistry`.
|
||||
|
||||
```go
|
||||
core.Service{
|
||||
OnStart: func() core.Result { return core.Result{OK: true} },
|
||||
OnStop: func() core.Result { return core.Result{OK: true} },
|
||||
}
|
||||
```
|
||||
|
||||
Or via `Startable`/`Stoppable` interfaces (preferred for named services):
|
||||
|
||||
```go
|
||||
type Startable interface { OnStartup(ctx context.Context) Result }
|
||||
type Stoppable interface { OnShutdown(ctx context.Context) Result }
|
||||
```
|
||||
|
||||
## `Action`
|
||||
|
||||
Named callable — the atomic unit of work. Registered by name, invoked by name.
|
||||
|
||||
```go
|
||||
type ActionHandler func(context.Context, Options) Result
|
||||
|
||||
type Action struct {
|
||||
Name string
|
||||
Handler ActionHandler
|
||||
Description string
|
||||
Schema Options
|
||||
}
|
||||
```
|
||||
|
||||
`Action.Run()` includes panic recovery and entitlement checking.
|
||||
|
||||
## `Task`
|
||||
|
||||
Composed sequence of Actions:
|
||||
|
||||
```go
|
||||
type Task struct {
|
||||
Name string
|
||||
Description string
|
||||
Steps []Step
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
Action string
|
||||
With Options
|
||||
Async bool
|
||||
Input string // "previous" = output of last step
|
||||
}
|
||||
```
|
||||
|
||||
## `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]`
|
||||
|
||||
Composition helper for services that need Core access and typed options:
|
||||
|
||||
```go
|
||||
type MyService struct {
|
||||
*core.ServiceRuntime[MyOptions]
|
||||
}
|
||||
|
||||
runtime := core.NewServiceRuntime(c, MyOptions{BufferSize: 1024})
|
||||
runtime.Core() // *Core
|
||||
runtime.Options() // MyOptions
|
||||
runtime.Config() // shortcut to Core().Config()
|
||||
```
|
||||
317
docs/services.md
317
docs/services.md
|
|
@ -1,215 +1,152 @@
|
|||
---
|
||||
title: Services
|
||||
description: Service registration, retrieval, ServiceRuntime, and factory patterns.
|
||||
description: Register, inspect, and lock CoreGO services.
|
||||
---
|
||||
|
||||
# Services
|
||||
|
||||
Services are the building blocks of a Core application. They are plain Go structs registered into a named registry and retrieved by name with optional type assertions.
|
||||
In CoreGO, a service is a named lifecycle entry stored in the Core registry.
|
||||
|
||||
## Registration
|
||||
|
||||
### Factory Functions
|
||||
|
||||
The primary way to register a service is via a **factory function** -- a function with the signature `func(*Core) (any, error)`. The factory receives the `Core` instance so it can access other services or register message handlers during construction.
|
||||
## Register a Service
|
||||
|
||||
```go
|
||||
func NewMyService(c *core.Core) (any, error) {
|
||||
return &MyService{}, nil
|
||||
}
|
||||
```
|
||||
c := core.New()
|
||||
|
||||
### WithService (auto-named)
|
||||
|
||||
`WithService` registers a service and automatically discovers its name from the Go package path. The last segment of the package path becomes the service name, lowercased.
|
||||
|
||||
```go
|
||||
// If MyService lives in package "myapp/services/calculator",
|
||||
// it is registered as "calculator".
|
||||
c, err := core.New(
|
||||
core.WithService(calculator.NewService),
|
||||
)
|
||||
```
|
||||
|
||||
`WithService` also performs **IPC handler discovery**: if the returned service has a method named `HandleIPCEvents` with the signature `func(*Core, Message) error`, it is automatically registered as an action handler.
|
||||
|
||||
```go
|
||||
type Service struct{}
|
||||
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
// Handle messages
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### WithName (explicitly named)
|
||||
|
||||
When you need to control the service name (or the factory is an anonymous function), use `WithName`:
|
||||
|
||||
```go
|
||||
c, err := core.New(
|
||||
core.WithName("my-service", func(c *core.Core) (any, error) {
|
||||
return &MyService{}, nil
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Unlike `WithService`, `WithName` does **not** auto-discover IPC handlers. Register them manually if needed.
|
||||
|
||||
### Direct Registration
|
||||
|
||||
You can also register a service directly on an existing `Core` instance:
|
||||
|
||||
```go
|
||||
err := c.RegisterService("my-service", &MyService{})
|
||||
```
|
||||
|
||||
This is useful for tests or when constructing services outside the `New()` options flow.
|
||||
|
||||
### Registration Rules
|
||||
|
||||
- Service names **must not be empty**.
|
||||
- **Duplicate names** are rejected with an error.
|
||||
- If `WithServiceLock()` was passed to `New()`, registration after initialisation is rejected.
|
||||
|
||||
## Retrieval
|
||||
|
||||
### By Name (untyped)
|
||||
|
||||
```go
|
||||
svc := c.Service("calculator")
|
||||
if svc == nil {
|
||||
// not found
|
||||
}
|
||||
```
|
||||
|
||||
Returns `nil` if no service is registered under that name.
|
||||
|
||||
### Type-Safe Retrieval
|
||||
|
||||
`ServiceFor[T]` retrieves and type-asserts in one step:
|
||||
|
||||
```go
|
||||
calc, err := core.ServiceFor[*calculator.Service](c, "calculator")
|
||||
if err != nil {
|
||||
// "service 'calculator' not found"
|
||||
// or "service 'calculator' is of type *Foo, but expected *calculator.Service"
|
||||
}
|
||||
```
|
||||
|
||||
### Panicking Retrieval
|
||||
|
||||
For init-time wiring where a missing service is a fatal programming error:
|
||||
|
||||
```go
|
||||
calc := core.MustServiceFor[*calculator.Service](c, "calculator")
|
||||
// panics if not found or wrong type
|
||||
```
|
||||
|
||||
## ServiceRuntime
|
||||
|
||||
`ServiceRuntime[T]` is a generic helper you embed in your service struct. It provides typed access to the `Core` instance and your service's options struct.
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
Precision int
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
}
|
||||
|
||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ServiceRuntime` provides these methods:
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `Core()` | `*Core` | The central Core instance |
|
||||
| `Opts()` | `T` | The service's typed options |
|
||||
| `Config()` | `Config` | Convenience shortcut for `Core().Config()` |
|
||||
|
||||
### Real-World Example: The Log Service
|
||||
|
||||
The `pkg/log` package in this repository is the reference implementation of a Core service:
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
*Logger
|
||||
}
|
||||
|
||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
logger := New(opts)
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
Logger: logger,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns to note:
|
||||
|
||||
1. The factory is a **closure** -- `NewService` takes options and returns a factory function.
|
||||
2. `ServiceRuntime` is embedded, giving access to `Core()` and `Opts()`.
|
||||
3. The service implements `Startable` to register its query/task handlers at startup.
|
||||
|
||||
## Runtime and NewWithFactories
|
||||
|
||||
For applications that wire services from a map of named factories, `NewWithFactories` offers a bulk registration path:
|
||||
|
||||
```go
|
||||
type ServiceFactory func() (any, error)
|
||||
|
||||
rt, err := core.NewWithFactories(app, map[string]core.ServiceFactory{
|
||||
"config": configFactory,
|
||||
"database": dbFactory,
|
||||
"cache": cacheFactory,
|
||||
r := c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("audit started")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("audit stopped")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Factories are called in sorted key order. The resulting `Runtime` wraps a `Core` and exposes `ServiceStartup`/`ServiceShutdown` for GUI runtime integration.
|
||||
Registration succeeds when:
|
||||
|
||||
For the simplest case with no custom services:
|
||||
- the name is not empty
|
||||
- the registry is not locked
|
||||
- the name is not already in use
|
||||
|
||||
## Read a Service Back
|
||||
|
||||
```go
|
||||
rt, err := core.NewRuntime(app)
|
||||
r := c.Service("audit")
|
||||
if r.OK {
|
||||
svc := r.Value.(*core.Service)
|
||||
_ = svc
|
||||
}
|
||||
```
|
||||
|
||||
## Well-Known Services
|
||||
The returned value is `*core.Service`.
|
||||
|
||||
Core provides convenience methods for commonly needed services. These use `MustServiceFor` internally and will panic if the service is not registered:
|
||||
## List Registered Services
|
||||
|
||||
| Method | Expected Name | Expected Interface |
|
||||
|--------|--------------|-------------------|
|
||||
| `c.Config()` | `"config"` | `Config` |
|
||||
| `c.Display()` | `"display"` | `Display` |
|
||||
| `c.Workspace()` | `"workspace"` | `Workspace` |
|
||||
| `c.Crypt()` | `"crypt"` | `Crypt` |
|
||||
```go
|
||||
names := c.Services()
|
||||
```
|
||||
|
||||
These are optional -- only call them if you have registered the corresponding service.
|
||||
### Important Detail
|
||||
|
||||
## Thread Safety
|
||||
The current registry is map-backed. `Services()`, `Startables()`, and `Stoppables()` do not promise a stable order.
|
||||
|
||||
The service registry is protected by `sync.RWMutex`. Registration, retrieval, and lifecycle operations are safe to call from multiple goroutines.
|
||||
## Lifecycle Snapshots
|
||||
|
||||
## Related Pages
|
||||
Use these helpers when you want the current set of startable or stoppable services:
|
||||
|
||||
- [Lifecycle](lifecycle.md) -- `Startable` and `Stoppable` interfaces
|
||||
- [Messaging](messaging.md) -- how services communicate
|
||||
- [Configuration](configuration.md) -- all `With*` options
|
||||
```go
|
||||
startables := c.Startables()
|
||||
stoppables := c.Stoppables()
|
||||
```
|
||||
|
||||
They return `[]*core.Service` inside `Result.Value`.
|
||||
|
||||
## Lock the Registry
|
||||
|
||||
CoreGO has a service-lock mechanism, but it is explicit.
|
||||
|
||||
```go
|
||||
c := core.New()
|
||||
|
||||
c.LockEnable()
|
||||
c.Service("audit", core.Service{})
|
||||
c.Service("cache", core.Service{})
|
||||
c.LockApply()
|
||||
```
|
||||
|
||||
After `LockApply`, new registrations fail:
|
||||
|
||||
```go
|
||||
r := c.Service("late", core.Service{})
|
||||
fmt.Println(r.OK) // false
|
||||
```
|
||||
|
||||
The default lock name is `"srv"`. You can pass a different name if you need a custom lock namespace.
|
||||
|
||||
For the service registry itself, use the default `"srv"` lock path. That is the path used by `Core.Service(...)`.
|
||||
|
||||
## `NewWithFactories`
|
||||
|
||||
For GUI runtimes or factory-driven setup, CoreGO provides `NewWithFactories`.
|
||||
|
||||
```go
|
||||
r := core.NewWithFactories(nil, map[string]core.ServiceFactory{
|
||||
"audit": func() core.Result {
|
||||
return core.Result{Value: core.Service{
|
||||
OnStart: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
}, OK: true}
|
||||
},
|
||||
"cache": func() core.Result {
|
||||
return core.Result{Value: core.Service{}, OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- each factory must return a `core.Service` in `Result.Value`
|
||||
- factories are executed in sorted key order
|
||||
- nil factories are skipped
|
||||
- the return value is `*core.Runtime`
|
||||
|
||||
## `Runtime`
|
||||
|
||||
`Runtime` is a small wrapper used for external runtimes such as GUI bindings.
|
||||
|
||||
```go
|
||||
r := core.NewRuntime(nil)
|
||||
rt := r.Value.(*core.Runtime)
|
||||
|
||||
_ = rt.ServiceStartup(context.Background(), nil)
|
||||
_ = rt.ServiceShutdown(context.Background())
|
||||
```
|
||||
|
||||
`Runtime.ServiceName()` returns `"Core"`.
|
||||
|
||||
## `ServiceRuntime[T]` for Package Authors
|
||||
|
||||
If you are writing a package on top of CoreGO, use `ServiceRuntime[T]` to keep a typed options struct and the parent `Core` together.
|
||||
|
||||
```go
|
||||
type repositoryServiceOptions struct {
|
||||
BaseDirectory string
|
||||
}
|
||||
|
||||
type repositoryService struct {
|
||||
*core.ServiceRuntime[repositoryServiceOptions]
|
||||
}
|
||||
|
||||
func newRepositoryService(c *core.Core) *repositoryService {
|
||||
return &repositoryService{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, repositoryServiceOptions{
|
||||
BaseDirectory: "/srv/repos",
|
||||
}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a package-authoring helper. It does not replace the `core.Service` registry entry.
|
||||
|
|
|
|||
158
docs/subsystems.md
Normal file
158
docs/subsystems.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
title: Subsystems
|
||||
description: Built-in accessors for app metadata, embedded data, filesystem, transport handles, i18n, and CLI.
|
||||
---
|
||||
|
||||
# Subsystems
|
||||
|
||||
`Core` gives you a set of built-in subsystems so small applications do not need extra plumbing before they can do useful work.
|
||||
|
||||
## Accessor Map
|
||||
|
||||
| Accessor | Purpose |
|
||||
|----------|---------|
|
||||
| `App()` | Application identity and external runtime |
|
||||
| `Data()` | Named embedded filesystem mounts |
|
||||
| `Drive()` | Named transport handles |
|
||||
| `Fs()` | Local filesystem access |
|
||||
| `I18n()` | Locale collection and translation delegation |
|
||||
| `Cli()` | Command-line surface over the command tree |
|
||||
|
||||
## `App`
|
||||
|
||||
`App` stores process identity and optional GUI runtime state.
|
||||
|
||||
```go
|
||||
app := c.App()
|
||||
app.Name = "agent-workbench"
|
||||
app.Version = "0.25.0"
|
||||
app.Description = "workspace runner"
|
||||
app.Runtime = myRuntime
|
||||
```
|
||||
|
||||
`Find` resolves an executable on `PATH` and returns an `*App`.
|
||||
|
||||
```go
|
||||
r := core.Find("go", "Go toolchain")
|
||||
```
|
||||
|
||||
## `Data`
|
||||
|
||||
`Data` mounts named embedded filesystems and makes them addressable through paths like `mount-name/path/to/file`.
|
||||
|
||||
```go
|
||||
c.Data().New(core.Options{
|
||||
{Key: "name", Value: "app"},
|
||||
{Key: "source", Value: appFS},
|
||||
{Key: "path", Value: "templates"},
|
||||
})
|
||||
```
|
||||
|
||||
Read content:
|
||||
|
||||
```go
|
||||
text := c.Data().ReadString("app/agent.md")
|
||||
bytes := c.Data().ReadFile("app/agent.md")
|
||||
list := c.Data().List("app")
|
||||
names := c.Data().ListNames("app")
|
||||
```
|
||||
|
||||
Extract a mounted directory:
|
||||
|
||||
```go
|
||||
r := c.Data().Extract("app/workspace", "/tmp/workspace", nil)
|
||||
```
|
||||
|
||||
### Path Rule
|
||||
|
||||
The first path segment is always the mount name.
|
||||
|
||||
## `Drive`
|
||||
|
||||
`Drive` is a registry for named transport handles.
|
||||
|
||||
```go
|
||||
c.Drive().New(core.Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
})
|
||||
|
||||
c.Drive().New(core.Options{
|
||||
{Key: "name", Value: "mcp"},
|
||||
{Key: "transport", Value: "mcp://mcp.lthn.sh"},
|
||||
})
|
||||
```
|
||||
|
||||
Read them back:
|
||||
|
||||
```go
|
||||
handle := c.Drive().Get("api")
|
||||
hasMCP := c.Drive().Has("mcp")
|
||||
names := c.Drive().Names()
|
||||
```
|
||||
|
||||
## `Fs`
|
||||
|
||||
`Fs` wraps local filesystem operations with a consistent `Result` shape.
|
||||
|
||||
```go
|
||||
c.Fs().Write("/tmp/core-go/example.txt", "hello")
|
||||
r := c.Fs().Read("/tmp/core-go/example.txt")
|
||||
```
|
||||
|
||||
Other helpers:
|
||||
|
||||
```go
|
||||
c.Fs().EnsureDir("/tmp/core-go/cache")
|
||||
c.Fs().List("/tmp/core-go")
|
||||
c.Fs().Stat("/tmp/core-go/example.txt")
|
||||
c.Fs().Rename("/tmp/core-go/example.txt", "/tmp/core-go/example-2.txt")
|
||||
c.Fs().Delete("/tmp/core-go/example-2.txt")
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- the default `Core` starts with `Fs{root:"/"}`
|
||||
- relative paths resolve from the current working directory
|
||||
- `Delete` and `DeleteAll` refuse to remove `/` and `$HOME`
|
||||
|
||||
## `I18n`
|
||||
|
||||
`I18n` collects locale mounts and forwards translation work to a translator implementation when one is registered.
|
||||
|
||||
```go
|
||||
c.I18n().SetLanguage("en-GB")
|
||||
```
|
||||
|
||||
Without a translator, `Translate` returns the message key itself:
|
||||
|
||||
```go
|
||||
r := c.I18n().Translate("cmd.deploy.description")
|
||||
```
|
||||
|
||||
With a translator:
|
||||
|
||||
```go
|
||||
c.I18n().SetTranslator(myTranslator)
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```go
|
||||
langs := c.I18n().AvailableLanguages()
|
||||
current := c.I18n().Language()
|
||||
```
|
||||
|
||||
## `Cli`
|
||||
|
||||
`Cli` exposes the command registry through a terminal-facing API.
|
||||
|
||||
```go
|
||||
c.Cli().SetBanner(func(_ *core.Cli) string {
|
||||
return "Agent Workbench"
|
||||
})
|
||||
|
||||
r := c.Cli().Run("workspace", "create", "--name=alpha")
|
||||
```
|
||||
|
||||
Use [commands.md](commands.md) for the full command and flag model.
|
||||
356
docs/testing.md
356
docs/testing.md
|
|
@ -1,340 +1,116 @@
|
|||
---
|
||||
title: Testing
|
||||
description: Test naming conventions, test helpers, and patterns for Core applications.
|
||||
description: Test naming and testing patterns used by CoreGO.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Core uses `github.com/stretchr/testify` for assertions and follows a structured test naming convention. This page covers the patterns used in the framework itself and recommended for services built on it.
|
||||
The repository uses `github.com/stretchr/testify/assert` and a simple AX-friendly naming pattern.
|
||||
|
||||
## Naming Convention
|
||||
## Test Names
|
||||
|
||||
Tests use a `_Good`, `_Bad`, `_Ugly` suffix pattern:
|
||||
Use:
|
||||
|
||||
| Suffix | Purpose | Example |
|
||||
|--------|---------|---------|
|
||||
| `_Good` | Happy path -- expected behaviour | `TestCore_New_Good` |
|
||||
| `_Bad` | Expected error conditions | `TestCore_WithService_Bad` |
|
||||
| `_Ugly` | Panics, edge cases, degenerate input | `TestCore_MustServiceFor_Ugly` |
|
||||
- `_Good` for expected success
|
||||
- `_Bad` for expected failure
|
||||
- `_Ugly` for panics, degenerate input, and edge behavior
|
||||
|
||||
The format is `Test{Component}_{Method}_{Suffix}`:
|
||||
Examples from this repository:
|
||||
|
||||
```go
|
||||
func TestCore_New_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestCore_WithService_Bad(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err := New(WithService(factory))
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestCore_MustServiceFor_Ugly(t *testing.T) {
|
||||
c, _ := New()
|
||||
assert.Panics(t, func() {
|
||||
MustServiceFor[*MockService](c, "nonexistent")
|
||||
})
|
||||
}
|
||||
func TestNew_Good(t *testing.T) {}
|
||||
func TestService_Register_Duplicate_Bad(t *testing.T) {}
|
||||
func TestCore_Must_Ugly(t *testing.T) {}
|
||||
```
|
||||
|
||||
## Creating a Test Core
|
||||
|
||||
For unit tests, create a minimal Core with only the services needed:
|
||||
## Start with a Small Core
|
||||
|
||||
```go
|
||||
func TestMyFeature(t *testing.T) {
|
||||
c, err := core.New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Register only what the test needs
|
||||
err = c.RegisterService("my-service", &MyService{})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "test-core"},
|
||||
})
|
||||
```
|
||||
|
||||
## Mock Services
|
||||
Then register only the pieces your test needs.
|
||||
|
||||
Define mock services as test-local structs. Core's interface-based design makes this straightforward:
|
||||
## Test a Service
|
||||
|
||||
```go
|
||||
// Mock a Startable service
|
||||
type MockStartable struct {
|
||||
started bool
|
||||
err error
|
||||
}
|
||||
started := false
|
||||
|
||||
func (m *MockStartable) OnStartup(ctx context.Context) error {
|
||||
m.started = true
|
||||
return m.err
|
||||
}
|
||||
c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
started = true
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
// Mock a Stoppable service
|
||||
type MockStoppable struct {
|
||||
stopped bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockStoppable) OnShutdown(ctx context.Context) error {
|
||||
m.stopped = true
|
||||
return m.err
|
||||
}
|
||||
r := c.ServiceStartup(context.Background(), nil)
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, started)
|
||||
```
|
||||
|
||||
For services implementing both lifecycle interfaces:
|
||||
## Test a Command
|
||||
|
||||
```go
|
||||
type MockLifecycle struct {
|
||||
MockStartable
|
||||
MockStoppable
|
||||
}
|
||||
c.Command("greet", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return core.Result{Value: "hello " + opts.String("name"), OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Cli().Run("greet", "--name=world")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello world", r.Value)
|
||||
```
|
||||
|
||||
## Testing Lifecycle
|
||||
|
||||
Verify that startup and shutdown are called in the correct order:
|
||||
## Test a Query or Task
|
||||
|
||||
```go
|
||||
func TestLifecycleOrder(t *testing.T) {
|
||||
c, _ := core.New()
|
||||
var callOrder []string
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
if q == "ping" {
|
||||
return core.Result{Value: "pong", OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
s1 := &OrderTracker{id: "1", log: &callOrder}
|
||||
s2 := &OrderTracker{id: "2", log: &callOrder}
|
||||
|
||||
_ = c.RegisterService("s1", s1)
|
||||
_ = c.RegisterService("s2", s2)
|
||||
|
||||
_ = c.ServiceStartup(context.Background(), nil)
|
||||
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)
|
||||
|
||||
callOrder = nil
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder) // reverse order
|
||||
}
|
||||
assert.Equal(t, "pong", c.QUERY("ping").Value)
|
||||
```
|
||||
|
||||
## Testing Message Handlers
|
||||
|
||||
### Actions
|
||||
|
||||
Register an action handler and verify it receives the expected message:
|
||||
|
||||
```go
|
||||
func TestAction(t *testing.T) {
|
||||
c, _ := core.New()
|
||||
var received core.Message
|
||||
c.Action("compute", func(_ context.Context, _ core.Options) core.Result {
|
||||
return core.Result{Value: 42, OK: true}
|
||||
})
|
||||
|
||||
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
received = msg
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = c.ACTION(MyEvent{Data: "test"})
|
||||
event, ok := received.(MyEvent)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", event.Data)
|
||||
}
|
||||
r := c.Action("compute").Run(context.Background(), core.NewOptions())
|
||||
assert.Equal(t, 42, r.Value)
|
||||
```
|
||||
|
||||
### Queries
|
||||
## Test Async Work
|
||||
|
||||
For `PerformAsync`, observe completion through the action bus.
|
||||
|
||||
```go
|
||||
func TestQuery(t *testing.T) {
|
||||
c, _ := core.New()
|
||||
completed := make(chan core.ActionTaskCompleted, 1)
|
||||
|
||||
c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) {
|
||||
if _, ok := q.(GetStatus); ok {
|
||||
return "healthy", true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(GetStatus{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "healthy", result)
|
||||
}
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
if event, ok := msg.(core.ActionTaskCompleted); ok {
|
||||
completed <- event
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
```
|
||||
|
||||
### Tasks
|
||||
Then wait with normal Go test tools such as channels, timers, or `assert.Eventually`.
|
||||
|
||||
```go
|
||||
func TestTask(t *testing.T) {
|
||||
c, _ := core.New()
|
||||
## Use Real Temporary Paths
|
||||
|
||||
c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) {
|
||||
if m, ok := t.(ProcessItem); ok {
|
||||
return "processed-" + m.ID, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
When testing `Fs`, `Data.Extract`, or other I/O helpers, use `t.TempDir()` and create realistic paths instead of mocking the filesystem by default.
|
||||
|
||||
result, handled, err := c.PERFORM(ProcessItem{ID: "42"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "processed-42", result)
|
||||
}
|
||||
```
|
||||
|
||||
### Async Tasks
|
||||
|
||||
Use `assert.Eventually` to wait for background task completion:
|
||||
|
||||
```go
|
||||
func TestAsyncTask(t *testing.T) {
|
||||
c, _ := core.New()
|
||||
|
||||
var completed atomic.Bool
|
||||
var resultReceived any
|
||||
|
||||
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
||||
if tc, ok := msg.(core.ActionTaskCompleted); ok {
|
||||
resultReceived = tc.Result
|
||||
completed.Store(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
c.RegisterTask(func(c *core.Core, task core.Task) (any, bool, error) {
|
||||
return "async-result", true, nil
|
||||
})
|
||||
|
||||
taskID := c.PerformAsync(MyTask{})
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
return completed.Load()
|
||||
}, 1*time.Second, 10*time.Millisecond)
|
||||
|
||||
assert.Equal(t, "async-result", resultReceived)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing with Context Cancellation
|
||||
|
||||
Verify that lifecycle methods respect context cancellation:
|
||||
|
||||
```go
|
||||
func TestStartupCancellation(t *testing.T) {
|
||||
c, _ := core.New()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
s := &MockStartable{}
|
||||
_ = c.RegisterService("s1", s)
|
||||
|
||||
err := c.ServiceStartup(ctx, nil)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.False(t, s.started)
|
||||
}
|
||||
```
|
||||
|
||||
## Global Instance in Tests
|
||||
|
||||
If your code under test uses `core.App()` or `core.GetInstance()`, save and restore the global instance:
|
||||
|
||||
```go
|
||||
func TestWithGlobalInstance(t *testing.T) {
|
||||
original := core.GetInstance()
|
||||
defer core.SetInstance(original)
|
||||
|
||||
c, _ := core.New(core.WithApp(&mockApp{}))
|
||||
core.SetInstance(c)
|
||||
|
||||
// Test code that calls core.App()
|
||||
assert.NotNil(t, core.App())
|
||||
}
|
||||
```
|
||||
|
||||
Or use `ClearInstance()` to ensure a clean state:
|
||||
|
||||
```go
|
||||
func TestAppPanicsWhenNotSet(t *testing.T) {
|
||||
original := core.GetInstance()
|
||||
core.ClearInstance()
|
||||
defer core.SetInstance(original)
|
||||
|
||||
assert.Panics(t, func() {
|
||||
core.App()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Fuzz Testing
|
||||
|
||||
Core includes fuzz tests for critical paths. The pattern is to exercise constructors and registries with arbitrary input:
|
||||
|
||||
```go
|
||||
func FuzzE(f *testing.F) {
|
||||
f.Add("svc.Method", "something broke", true)
|
||||
f.Add("", "", false)
|
||||
|
||||
f.Fuzz(func(t *testing.T, op, msg string, withErr bool) {
|
||||
var underlying error
|
||||
if withErr {
|
||||
underlying = errors.New("wrapped")
|
||||
}
|
||||
e := core.E(op, msg, underlying)
|
||||
if e == nil {
|
||||
t.Fatal("E() returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Run fuzz tests with:
|
||||
## Repository Commands
|
||||
|
||||
```bash
|
||||
core go test --run Fuzz --fuzz FuzzE
|
||||
```
|
||||
|
||||
Or directly with `go test`:
|
||||
|
||||
```bash
|
||||
go test -fuzz FuzzE ./pkg/core/
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Core includes benchmarks for the message bus. Run them with:
|
||||
|
||||
```bash
|
||||
go test -bench . ./pkg/core/
|
||||
```
|
||||
|
||||
Available benchmarks:
|
||||
|
||||
- `BenchmarkMessageBus_Action` -- ACTION dispatch throughput
|
||||
- `BenchmarkMessageBus_Query` -- QUERY dispatch throughput
|
||||
- `BenchmarkMessageBus_Perform` -- PERFORM dispatch throughput
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
core go test
|
||||
|
||||
# Single test
|
||||
core go test --run TestCore_New_Good
|
||||
|
||||
# With race detector
|
||||
go test -race ./pkg/core/
|
||||
|
||||
# Coverage
|
||||
core go cov
|
||||
core go cov --open # opens HTML report in browser
|
||||
core go test --run TestPerformAsync_Good
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Services](services.md) -- what you are testing
|
||||
- [Lifecycle](lifecycle.md) -- startup/shutdown behaviour
|
||||
- [Messaging](messaging.md) -- ACTION/QUERY/PERFORM
|
||||
- [Errors](errors.md) -- the `E()` helper used in tests
|
||||
|
|
|
|||
59
drive.go
Normal file
59
drive.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Drive is the resource handle registry for transport connections.
|
||||
// Packages register their transport handles (API, MCP, SSH, VPN)
|
||||
// and other packages access them by name.
|
||||
//
|
||||
// Register a transport:
|
||||
//
|
||||
// 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
|
||||
|
||||
// DriveHandle holds a named transport resource.
|
||||
type DriveHandle struct {
|
||||
Name string
|
||||
Transport string
|
||||
Options Options
|
||||
}
|
||||
|
||||
// Drive manages named transport handles. Embeds Registry[*DriveHandle].
|
||||
type Drive struct {
|
||||
*Registry[*DriveHandle]
|
||||
}
|
||||
|
||||
// New registers a transport handle.
|
||||
//
|
||||
// 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{}
|
||||
}
|
||||
|
||||
handle := &DriveHandle{
|
||||
Name: name,
|
||||
Transport: opts.String("transport"),
|
||||
Options: opts,
|
||||
}
|
||||
|
||||
d.Set(name, handle)
|
||||
return Result{handle, true}
|
||||
}
|
||||
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
|
||||
}
|
||||
80
drive_test.go
Normal file
80
drive_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Drive (Transport Handles) ---
|
||||
|
||||
func TestDrive_New_Good(t *testing.T) {
|
||||
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()
|
||||
// Missing name
|
||||
r := c.Drive().New(NewOptions(
|
||||
Option{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestDrive_Get_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Drive().New(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)
|
||||
assert.Equal(t, "ssh://claude@10.69.69.165", handle.Transport)
|
||||
}
|
||||
|
||||
func TestDrive_Get_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Drive().Get("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestDrive_Has_Good(t *testing.T) {
|
||||
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()
|
||||
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")
|
||||
assert.Contains(t, names, "ssh")
|
||||
assert.Contains(t, names, "mcp")
|
||||
}
|
||||
|
||||
func TestDrive_OptionsPreserved_Good(t *testing.T) {
|
||||
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)
|
||||
assert.Equal(t, 30, handle.Options.Int("timeout"))
|
||||
}
|
||||
668
embed.go
Normal file
668
embed.go
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Embedded assets for the Core framework.
|
||||
//
|
||||
// Embed provides scoped filesystem access for go:embed and any fs.FS.
|
||||
// Also includes build-time asset packing (AST scanner + compressor)
|
||||
// and template-based directory extraction.
|
||||
//
|
||||
// Usage (mount):
|
||||
//
|
||||
// sub, _ := core.Mount(myFS, "lib/persona")
|
||||
// content, _ := sub.ReadString("secops/developer.md")
|
||||
//
|
||||
// Usage (extract):
|
||||
//
|
||||
// core.Extract(fsys, "/tmp/workspace", data)
|
||||
//
|
||||
// Usage (pack):
|
||||
//
|
||||
// refs, _ := core.ScanAssets([]string{"main.go"})
|
||||
// source, _ := core.GeneratePack(refs)
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// --- Runtime: Asset Registry ---
|
||||
|
||||
// AssetGroup holds a named collection of packed assets.
|
||||
type AssetGroup struct {
|
||||
assets map[string]string // name → compressed data
|
||||
}
|
||||
|
||||
var (
|
||||
assetGroups = make(map[string]*AssetGroup)
|
||||
assetGroupsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// AddAsset registers a packed asset at runtime (called from generated init()).
|
||||
func AddAsset(group, name, data string) {
|
||||
assetGroupsMu.Lock()
|
||||
defer assetGroupsMu.Unlock()
|
||||
|
||||
g, ok := assetGroups[group]
|
||||
if !ok {
|
||||
g = &AssetGroup{assets: make(map[string]string)}
|
||||
assetGroups[group] = g
|
||||
}
|
||||
g.assets[name] = data
|
||||
}
|
||||
|
||||
// GetAsset retrieves and decompresses a packed asset.
|
||||
//
|
||||
// r := core.GetAsset("mygroup", "greeting")
|
||||
// if r.OK { content := r.Value.(string) }
|
||||
func GetAsset(group, name string) Result {
|
||||
assetGroupsMu.RLock()
|
||||
g, ok := assetGroups[group]
|
||||
if !ok {
|
||||
assetGroupsMu.RUnlock()
|
||||
return Result{}
|
||||
}
|
||||
data, ok := g.assets[name]
|
||||
assetGroupsMu.RUnlock()
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
s, err := decompress(data)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{s, true}
|
||||
}
|
||||
|
||||
// GetAssetBytes retrieves a packed asset as bytes.
|
||||
//
|
||||
// r := core.GetAssetBytes("mygroup", "file")
|
||||
// if r.OK { data := r.Value.([]byte) }
|
||||
func GetAssetBytes(group, name string) Result {
|
||||
r := GetAsset(group, name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{[]byte(r.Value.(string)), true}
|
||||
}
|
||||
|
||||
// --- Build-time: AST Scanner ---
|
||||
|
||||
// AssetRef is a reference to an asset found in source code.
|
||||
type AssetRef struct {
|
||||
Name string
|
||||
Path string
|
||||
Group string
|
||||
FullPath string
|
||||
}
|
||||
|
||||
// ScannedPackage holds all asset references from a set of source files.
|
||||
type ScannedPackage struct {
|
||||
PackageName string
|
||||
BaseDirectory string
|
||||
Groups []string
|
||||
Assets []AssetRef
|
||||
}
|
||||
|
||||
// ScanAssets parses Go source files and finds asset references.
|
||||
// Looks for calls to: core.GetAsset("group", "name"), core.AddAsset, etc.
|
||||
func ScanAssets(filenames []string) Result {
|
||||
packageMap := make(map[string]*ScannedPackage)
|
||||
var scanErr error
|
||||
|
||||
for _, filename := range filenames {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(filename)
|
||||
pkg, ok := packageMap[baseDir]
|
||||
if !ok {
|
||||
pkg = &ScannedPackage{BaseDirectory: baseDir}
|
||||
packageMap[baseDir] = pkg
|
||||
}
|
||||
pkg.PackageName = node.Name.Name
|
||||
|
||||
ast.Inspect(node, func(n ast.Node) bool {
|
||||
if scanErr != nil {
|
||||
return false
|
||||
}
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
ident, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Look for core.GetAsset or mewn.String patterns
|
||||
if ident.Name == "core" || ident.Name == "mewn" {
|
||||
switch sel.Sel.Name {
|
||||
case "GetAsset", "GetAssetBytes", "String", "MustString", "Bytes", "MustBytes":
|
||||
if len(call.Args) >= 1 {
|
||||
if lit, ok := call.Args[len(call.Args)-1].(*ast.BasicLit); ok {
|
||||
path := TrimPrefix(TrimSuffix(lit.Value, "\""), "\"")
|
||||
group := "."
|
||||
if len(call.Args) >= 2 {
|
||||
if glit, ok := call.Args[0].(*ast.BasicLit); ok {
|
||||
group = TrimPrefix(TrimSuffix(glit.Value, "\""), "\"")
|
||||
}
|
||||
}
|
||||
fullPath, err := filepath.Abs(filepath.Join(baseDir, group, path))
|
||||
if err != nil {
|
||||
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for asset", path, "in group", group))
|
||||
return false
|
||||
}
|
||||
pkg.Assets = append(pkg.Assets, AssetRef{
|
||||
Name: path,
|
||||
|
||||
Group: group,
|
||||
FullPath: fullPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
case "Group":
|
||||
// Variable assignment: g := core.Group("./assets")
|
||||
if len(call.Args) == 1 {
|
||||
if lit, ok := call.Args[0].(*ast.BasicLit); ok {
|
||||
path := TrimPrefix(TrimSuffix(lit.Value, "\""), "\"")
|
||||
fullPath, err := filepath.Abs(filepath.Join(baseDir, path))
|
||||
if err != nil {
|
||||
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for group", path))
|
||||
return false
|
||||
}
|
||||
pkg.Groups = append(pkg.Groups, fullPath)
|
||||
// Track for variable resolution
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
if scanErr != nil {
|
||||
return Result{scanErr, false}
|
||||
}
|
||||
}
|
||||
|
||||
var result []ScannedPackage
|
||||
for _, pkg := range packageMap {
|
||||
result = append(result, *pkg)
|
||||
}
|
||||
return Result{result, true}
|
||||
}
|
||||
|
||||
// GeneratePack creates Go source code that embeds the scanned assets.
|
||||
func GeneratePack(pkg ScannedPackage) Result {
|
||||
b := NewBuilder()
|
||||
|
||||
b.WriteString(fmt.Sprintf("package %s\n\n", pkg.PackageName))
|
||||
b.WriteString("// Code generated by core pack. DO NOT EDIT.\n\n")
|
||||
|
||||
if len(pkg.Assets) == 0 && len(pkg.Groups) == 0 {
|
||||
return Result{b.String(), true}
|
||||
}
|
||||
|
||||
b.WriteString("import \"dappco.re/go/core\"\n\n")
|
||||
b.WriteString("func init() {\n")
|
||||
|
||||
// Pack groups (entire directories)
|
||||
packed := make(map[string]bool)
|
||||
for _, groupPath := range pkg.Groups {
|
||||
files, err := getAllFiles(groupPath)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
for _, file := range files {
|
||||
if packed[file] {
|
||||
continue
|
||||
}
|
||||
data, err := compressFile(file)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
localPath := TrimPrefix(file, groupPath+"/")
|
||||
relGroup, err := filepath.Rel(pkg.BaseDirectory, groupPath)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data))
|
||||
packed[file] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pack individual assets
|
||||
for _, asset := range pkg.Assets {
|
||||
if packed[asset.FullPath] {
|
||||
continue
|
||||
}
|
||||
data, err := compressFile(asset.FullPath)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data))
|
||||
packed[asset.FullPath] = true
|
||||
}
|
||||
|
||||
b.WriteString("}\n")
|
||||
return Result{b.String(), true}
|
||||
}
|
||||
|
||||
// --- Compression ---
|
||||
|
||||
func compressFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return compress(string(data))
|
||||
}
|
||||
|
||||
func compress(input string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
b64 := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
gz, err := gzip.NewWriterLevel(b64, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := gz.Write([]byte(input)); err != nil {
|
||||
_ = gz.Close()
|
||||
_ = b64.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
_ = b64.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := b64.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func decompress(input string) (string, error) {
|
||||
b64 := base64.NewDecoder(base64.StdEncoding, NewReader(input))
|
||||
gz, err := gzip.NewReader(b64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func getAllFiles(dir string) ([]string, error) {
|
||||
var result []string
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
result = append(result, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// --- Embed: Scoped Filesystem Mount ---
|
||||
|
||||
// Embed wraps an fs.FS with a basedir for scoped access.
|
||||
// All paths are relative to basedir.
|
||||
type Embed struct {
|
||||
basedir string
|
||||
fsys fs.FS
|
||||
embedFS *embed.FS // original embed.FS for type-safe access via EmbedFS()
|
||||
}
|
||||
|
||||
// Mount creates a scoped view of an fs.FS anchored at basedir.
|
||||
//
|
||||
// r := core.Mount(myFS, "lib/prompts")
|
||||
// if r.OK { emb := r.Value.(*Embed) }
|
||||
func Mount(fsys fs.FS, basedir string) Result {
|
||||
s := &Embed{fsys: fsys, basedir: basedir}
|
||||
|
||||
if efs, ok := fsys.(embed.FS); ok {
|
||||
s.embedFS = &efs
|
||||
}
|
||||
|
||||
if r := s.ReadDir("."); !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{s, true}
|
||||
}
|
||||
|
||||
// MountEmbed creates a scoped view of an embed.FS.
|
||||
//
|
||||
// r := core.MountEmbed(myFS, "testdata")
|
||||
func MountEmbed(efs embed.FS, basedir string) Result {
|
||||
return Mount(efs, basedir)
|
||||
}
|
||||
|
||||
func (s *Embed) path(name string) Result {
|
||||
joined := filepath.ToSlash(filepath.Join(s.basedir, name))
|
||||
if HasPrefix(joined, "..") || Contains(joined, "/../") || HasSuffix(joined, "/..") {
|
||||
return Result{E("embed.path", Concat("path traversal rejected: ", name), nil), false}
|
||||
}
|
||||
return Result{joined, true}
|
||||
}
|
||||
|
||||
// Open opens the named file for reading.
|
||||
//
|
||||
// r := emb.Open("test.txt")
|
||||
// if r.OK { file := r.Value.(fs.File) }
|
||||
func (s *Embed) Open(name string) Result {
|
||||
r := s.path(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
f, err := s.fsys.Open(r.Value.(string))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{f, true}
|
||||
}
|
||||
|
||||
// ReadDir reads the named directory.
|
||||
func (s *Embed) ReadDir(name string) Result {
|
||||
r := s.path(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{}.New(fs.ReadDir(s.fsys, r.Value.(string)))
|
||||
}
|
||||
|
||||
// ReadFile reads the named file.
|
||||
//
|
||||
// r := emb.ReadFile("test.txt")
|
||||
// if r.OK { data := r.Value.([]byte) }
|
||||
func (s *Embed) ReadFile(name string) Result {
|
||||
r := s.path(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
data, err := fs.ReadFile(s.fsys, r.Value.(string))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{data, true}
|
||||
}
|
||||
|
||||
// ReadString reads the named file as a string.
|
||||
//
|
||||
// r := emb.ReadString("test.txt")
|
||||
// if r.OK { content := r.Value.(string) }
|
||||
func (s *Embed) ReadString(name string) Result {
|
||||
r := s.ReadFile(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{string(r.Value.([]byte)), true}
|
||||
}
|
||||
|
||||
// Sub returns a new Embed anchored at a subdirectory within this mount.
|
||||
//
|
||||
// r := emb.Sub("testdata")
|
||||
// if r.OK { sub := r.Value.(*Embed) }
|
||||
func (s *Embed) Sub(subDir string) Result {
|
||||
r := s.path(subDir)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
sub, err := fs.Sub(s.fsys, r.Value.(string))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{&Embed{fsys: sub, basedir: "."}, true}
|
||||
}
|
||||
|
||||
// FS returns the underlying fs.FS.
|
||||
func (s *Embed) FS() fs.FS {
|
||||
return s.fsys
|
||||
}
|
||||
|
||||
// EmbedFS returns the underlying embed.FS if mounted from one.
|
||||
// Returns zero embed.FS if mounted from a non-embed source.
|
||||
func (s *Embed) EmbedFS() embed.FS {
|
||||
if s.embedFS != nil {
|
||||
return *s.embedFS
|
||||
}
|
||||
return embed.FS{}
|
||||
}
|
||||
|
||||
// BaseDirectory returns the base directory this Embed is anchored at.
|
||||
func (s *Embed) BaseDirectory() string {
|
||||
return s.basedir
|
||||
}
|
||||
|
||||
// --- Template Extraction ---
|
||||
|
||||
// ExtractOptions configures template extraction.
|
||||
type ExtractOptions struct {
|
||||
// TemplateFilters identifies template files by substring match.
|
||||
// Default: [".tmpl"]
|
||||
TemplateFilters []string
|
||||
|
||||
// IgnoreFiles is a set of filenames to skip during extraction.
|
||||
IgnoreFiles map[string]struct{}
|
||||
|
||||
// RenameFiles maps original filenames to new names.
|
||||
RenameFiles map[string]string
|
||||
}
|
||||
|
||||
// Extract copies a template directory from an fs.FS to targetDir,
|
||||
// processing Go text/template in filenames and file contents.
|
||||
//
|
||||
// Files containing a template filter substring (default: ".tmpl") have
|
||||
// their contents processed through text/template with the given data.
|
||||
// The filter is stripped from the output filename.
|
||||
//
|
||||
// Directory and file names can contain Go template expressions:
|
||||
// {{.Name}}/main.go → myproject/main.go
|
||||
//
|
||||
// Data can be any struct or map[string]string for template substitution.
|
||||
func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) Result {
|
||||
opt := ExtractOptions{
|
||||
TemplateFilters: []string{".tmpl"},
|
||||
IgnoreFiles: make(map[string]struct{}),
|
||||
RenameFiles: make(map[string]string),
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
if len(opts[0].TemplateFilters) > 0 {
|
||||
opt.TemplateFilters = opts[0].TemplateFilters
|
||||
}
|
||||
if opts[0].IgnoreFiles != nil {
|
||||
opt.IgnoreFiles = opts[0].IgnoreFiles
|
||||
}
|
||||
if opts[0].RenameFiles != nil {
|
||||
opt.RenameFiles = opts[0].RenameFiles
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure target directory exists
|
||||
targetDir, err := filepath.Abs(targetDir)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
// Categorise files
|
||||
var dirs []string
|
||||
var templateFiles []string
|
||||
var standardFiles []string
|
||||
|
||||
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
dirs = append(dirs, path)
|
||||
return nil
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if _, ignored := opt.IgnoreFiles[filename]; ignored {
|
||||
return nil
|
||||
}
|
||||
if isTemplate(filename, opt.TemplateFilters) {
|
||||
templateFiles = append(templateFiles, path)
|
||||
} else {
|
||||
standardFiles = append(standardFiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
// safePath ensures a rendered path stays under targetDir.
|
||||
safePath := func(rendered string) (string, error) {
|
||||
abs, err := filepath.Abs(rendered)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !HasPrefix(abs, targetDir+string(filepath.Separator)) && abs != targetDir {
|
||||
return "", E("embed.Extract", Concat("path escapes target: ", abs), nil)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// Create directories (names may contain templates)
|
||||
for _, dir := range dirs {
|
||||
target, err := safePath(renderPath(filepath.Join(targetDir, dir), data))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
}
|
||||
|
||||
// Process template files
|
||||
for _, path := range templateFiles {
|
||||
tmpl, err := template.ParseFS(fsys, path)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
targetFile := renderPath(filepath.Join(targetDir, path), data)
|
||||
|
||||
// Strip template filters from filename
|
||||
dir := filepath.Dir(targetFile)
|
||||
name := filepath.Base(targetFile)
|
||||
for _, filter := range opt.TemplateFilters {
|
||||
name = Replace(name, filter, "")
|
||||
}
|
||||
if renamed := opt.RenameFiles[name]; renamed != "" {
|
||||
name = renamed
|
||||
}
|
||||
targetFile, err = safePath(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
f, err := os.Create(targetFile)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := tmpl.Execute(f, data); err != nil {
|
||||
f.Close()
|
||||
return Result{err, false}
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// Copy standard files
|
||||
for _, path := range standardFiles {
|
||||
targetPath := path
|
||||
name := filepath.Base(path)
|
||||
if renamed := opt.RenameFiles[name]; renamed != "" {
|
||||
targetPath = filepath.Join(filepath.Dir(path), renamed)
|
||||
}
|
||||
target, err := safePath(renderPath(filepath.Join(targetDir, targetPath), data))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := copyFile(fsys, path, target); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
}
|
||||
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
func isTemplate(filename string, filters []string) bool {
|
||||
for _, f := range filters {
|
||||
if Contains(filename, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderPath(path string, data any) string {
|
||||
if data == nil {
|
||||
return path
|
||||
}
|
||||
tmpl, err := template.New("path").Parse(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return path
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func copyFile(fsys fs.FS, source, target string) error {
|
||||
s, err := fsys.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
_, err = io.Copy(d, s)
|
||||
return err
|
||||
}
|
||||
265
embed_test.go
Normal file
265
embed_test.go
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Mount ---
|
||||
|
||||
func mustMountTestFS(t *testing.T, basedir string) *Embed {
|
||||
t.Helper()
|
||||
|
||||
r := Mount(testFS, basedir)
|
||||
assert.True(t, r.OK)
|
||||
return r.Value.(*Embed)
|
||||
}
|
||||
|
||||
func TestEmbed_Mount_Good(t *testing.T) {
|
||||
r := Mount(testFS, "testdata")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_Mount_Bad(t *testing.T) {
|
||||
r := Mount(testFS, "nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Embed methods ---
|
||||
|
||||
func TestEmbed_ReadFile_Good(t *testing.T) {
|
||||
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 := 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 := mustMountTestFS(t, "testdata")
|
||||
r := emb.Open("test.txt")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_ReadDir_Good(t *testing.T) {
|
||||
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 := mustMountTestFS(t, ".")
|
||||
r := emb.Sub("testdata")
|
||||
assert.True(t, r.OK)
|
||||
sub := r.Value.(*Embed)
|
||||
r2 := sub.ReadFile("test.txt")
|
||||
assert.True(t, r2.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_BaseDir_Good(t *testing.T) {
|
||||
emb := mustMountTestFS(t, "testdata")
|
||||
assert.Equal(t, "testdata", emb.BaseDirectory())
|
||||
}
|
||||
|
||||
func TestEmbed_FS_Good(t *testing.T) {
|
||||
emb := mustMountTestFS(t, "testdata")
|
||||
assert.NotNil(t, emb.FS())
|
||||
}
|
||||
|
||||
func TestEmbed_EmbedFS_Good(t *testing.T) {
|
||||
emb := mustMountTestFS(t, "testdata")
|
||||
efs := emb.EmbedFS()
|
||||
_, err := efs.ReadFile("testdata/test.txt")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- Extract ---
|
||||
|
||||
func TestEmbed_Extract_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := Extract(testFS, dir, nil)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
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 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 TestEmbed_GetAsset_Bad(t *testing.T) {
|
||||
r := GetAsset("missing-group", "missing")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
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 TestEmbed_MountEmbed_Good(t *testing.T) {
|
||||
r := MountEmbed(testFS, "testdata")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
// --- ScanAssets ---
|
||||
|
||||
func TestEmbed_ScanAssets_Good(t *testing.T) {
|
||||
r := ScanAssets([]string{"testdata/scantest/sample.go"})
|
||||
assert.True(t, r.OK)
|
||||
pkgs := r.Value.([]ScannedPackage)
|
||||
assert.Len(t, pkgs, 1)
|
||||
assert.Equal(t, "scantest", pkgs[0].PackageName)
|
||||
}
|
||||
|
||||
func TestEmbed_ScanAssets_Bad(t *testing.T) {
|
||||
r := ScanAssets([]string{"nonexistent.go"})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
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 TestEmbed_GeneratePack_WithFiles_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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 := Path(dir, "test.go")
|
||||
(&Fs{}).New("/").Write(goFile, source)
|
||||
|
||||
sr := ScanAssets([]string{goFile})
|
||||
assert.True(t, sr.OK)
|
||||
pkgs := sr.Value.([]ScannedPackage)
|
||||
|
||||
r := GeneratePack(pkgs[0])
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "core.AddAsset")
|
||||
}
|
||||
|
||||
// --- Extract (template + nested) ---
|
||||
|
||||
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 := DirFS(t.TempDir())
|
||||
|
||||
// Use a real temp dir with files
|
||||
srcDir := t.TempDir()
|
||||
(&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 := DirFS(srcDir)
|
||||
data := map[string]string{"Name": "World"}
|
||||
|
||||
r := Extract(fsys, dir, data)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
f := (&Fs{}).New("/")
|
||||
|
||||
// Plain file copied
|
||||
cr := f.Read(Path(dir, "plain.txt"))
|
||||
assert.True(t, cr.OK)
|
||||
assert.Equal(t, "static content", cr.Value)
|
||||
|
||||
// Template processed and .tmpl stripped
|
||||
gr := f.Read(Path(dir, "greeting"))
|
||||
assert.True(t, gr.OK)
|
||||
assert.Equal(t, "Hello World!", gr.Value)
|
||||
|
||||
// Nested directory preserved
|
||||
nr := f.Read(Path(dir, "sub/nested.txt"))
|
||||
assert.True(t, nr.OK)
|
||||
assert.Equal(t, "nested", nr.Value)
|
||||
}
|
||||
|
||||
func TestEmbed_Extract_BadTargetDir_Ugly(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
(&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 := mustMountTestFS(t, "testdata")
|
||||
r := emb.ReadFile("../../etc/passwd")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_Sub_BaseDir_Good(t *testing.T) {
|
||||
emb := mustMountTestFS(t, "testdata")
|
||||
r := emb.Sub("scantest")
|
||||
assert.True(t, r.OK)
|
||||
sub := r.Value.(*Embed)
|
||||
assert.Equal(t, ".", sub.BaseDirectory())
|
||||
}
|
||||
|
||||
func TestEmbed_Open_Bad(t *testing.T) {
|
||||
emb := mustMountTestFS(t, "testdata")
|
||||
r := emb.Open("nonexistent.txt")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_ReadDir_Bad(t *testing.T) {
|
||||
emb := mustMountTestFS(t, "testdata")
|
||||
r := emb.ReadDir("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_EmbedFS_Original_Good(t *testing.T) {
|
||||
emb := mustMountTestFS(t, "testdata")
|
||||
efs := emb.EmbedFS()
|
||||
_, err := efs.ReadFile("testdata/test.txt")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEmbed_Extract_NilData_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
srcDir := t.TempDir()
|
||||
(&Fs{}).New("/").Write(Path(srcDir, "file.txt"), "no template")
|
||||
|
||||
r := Extract(DirFS(srcDir), dir, nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func mustCompress(input string) string {
|
||||
var buf bytes.Buffer
|
||||
b64 := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
gz, _ := gzip.NewWriterLevel(b64, gzip.BestCompression)
|
||||
gz.Write([]byte(input))
|
||||
gz.Close()
|
||||
b64.Close()
|
||||
return buf.String()
|
||||
}
|
||||
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)
|
||||
}
|
||||
395
error.go
Normal file
395
error.go
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Structured errors, crash recovery, and reporting for the Core framework.
|
||||
// Provides E() for error creation, Wrap()/WrapCode() for chaining,
|
||||
// and Err for panic recovery and crash reporting.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrorSink is the shared interface for error reporting.
|
||||
// Implemented by ErrorLog (structured logging) and ErrorPanic (panic recovery).
|
||||
type ErrorSink interface {
|
||||
Error(msg string, keyvals ...any)
|
||||
Warn(msg string, keyvals ...any)
|
||||
}
|
||||
|
||||
var _ ErrorSink = (*Log)(nil)
|
||||
|
||||
// Err represents a structured error with operational context.
|
||||
// It implements the error interface and supports unwrapping.
|
||||
type Err struct {
|
||||
Operation string // Operation being performed (e.g., "user.Save")
|
||||
Message string // Human-readable message
|
||||
Cause error // Underlying error (optional)
|
||||
Code string // Error code (optional, e.g., "VALIDATION_FAILED")
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *Err) Error() string {
|
||||
var prefix string
|
||||
if e.Operation != "" {
|
||||
prefix = e.Operation + ": "
|
||||
}
|
||||
if e.Cause != nil {
|
||||
if e.Code != "" {
|
||||
return Concat(prefix, e.Message, " [", e.Code, "]: ", e.Cause.Error())
|
||||
}
|
||||
return Concat(prefix, e.Message, ": ", e.Cause.Error())
|
||||
}
|
||||
if e.Code != "" {
|
||||
return Concat(prefix, e.Message, " [", e.Code, "]")
|
||||
}
|
||||
return Concat(prefix, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error for use with errors.Is and errors.As.
|
||||
func (e *Err) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// --- Error Creation Functions ---
|
||||
|
||||
// E creates a new Err with operation context.
|
||||
// The underlying error can be nil for creating errors without a cause.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// return log.E("user.Save", "failed to save user", err)
|
||||
// return log.E("api.Call", "rate limited", nil) // No underlying cause
|
||||
func E(op, msg string, err error) error {
|
||||
return &Err{Operation: op, Message: msg, Cause: err}
|
||||
}
|
||||
|
||||
// Wrap wraps an error with operation context.
|
||||
// Returns nil if err is nil, to support conditional wrapping.
|
||||
// Preserves error Code if the wrapped error is an *Err.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// return log.Wrap(err, "db.Query", "database query failed")
|
||||
func Wrap(err error, op, msg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// Preserve Code from wrapped *Err
|
||||
var logErr *Err
|
||||
if As(err, &logErr) && logErr.Code != "" {
|
||||
return &Err{Operation: op, Message: msg, Cause: err, Code: logErr.Code}
|
||||
}
|
||||
return &Err{Operation: op, Message: msg, Cause: err}
|
||||
}
|
||||
|
||||
// WrapCode wraps an error with operation context and error code.
|
||||
// Returns nil only if both err is nil AND code is empty.
|
||||
// Useful for API errors that need machine-readable codes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email")
|
||||
func WrapCode(err error, code, op, msg string) error {
|
||||
if err == nil && code == "" {
|
||||
return nil
|
||||
}
|
||||
return &Err{Operation: op, Message: msg, Cause: err, Code: code}
|
||||
}
|
||||
|
||||
// NewCode creates an error with just code and message (no underlying error).
|
||||
// Useful for creating sentinel errors with codes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found")
|
||||
func NewCode(code, msg string) error {
|
||||
return &Err{Message: msg, Code: code}
|
||||
}
|
||||
|
||||
// --- Standard Library Wrappers ---
|
||||
|
||||
// Is reports whether any error in err's tree matches target.
|
||||
// Wrapper around errors.Is for convenience.
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// As finds the first error in err's tree that matches target.
|
||||
// Wrapper around errors.As for convenience.
|
||||
func As(err error, target any) bool {
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// NewError creates a simple error with the given text.
|
||||
// Wrapper around errors.New for convenience.
|
||||
func NewError(text string) error {
|
||||
return errors.New(text)
|
||||
}
|
||||
|
||||
// ErrorJoin combines multiple errors into one.
|
||||
//
|
||||
// core.ErrorJoin(err1, err2, err3)
|
||||
func ErrorJoin(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// --- Error Introspection Helpers ---
|
||||
|
||||
// Operation extracts the operation name from an error.
|
||||
// Returns empty string if the error is not an *Err.
|
||||
func Operation(err error) string {
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Operation
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrorCode extracts the error code from an error.
|
||||
// Returns empty string if the error is not an *Err or has no code.
|
||||
func ErrorCode(err error) string {
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Message extracts the message from an error.
|
||||
// Returns the error's Error() string if not an *Err.
|
||||
func ErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// Root returns the root cause of an error chain.
|
||||
// Unwraps until no more wrapped errors are found.
|
||||
func Root(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
unwrapped := errors.Unwrap(err)
|
||||
if unwrapped == nil {
|
||||
return err
|
||||
}
|
||||
err = unwrapped
|
||||
}
|
||||
}
|
||||
|
||||
// AllOperations returns an iterator over all operational contexts in the error chain.
|
||||
// It traverses the error tree using errors.Unwrap.
|
||||
func AllOperations(err error) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for err != nil {
|
||||
if e, ok := err.(*Err); ok {
|
||||
if e.Operation != "" {
|
||||
if !yield(e.Operation) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
err = errors.Unwrap(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StackTrace returns the logical stack trace (chain of operations) from an error.
|
||||
// It returns an empty slice if no operational context is found.
|
||||
func StackTrace(err error) []string {
|
||||
var stack []string
|
||||
for op := range AllOperations(err) {
|
||||
stack = append(stack, op)
|
||||
}
|
||||
return stack
|
||||
}
|
||||
|
||||
// FormatStackTrace returns a pretty-printed logical stack trace.
|
||||
func FormatStackTrace(err error) string {
|
||||
var ops []string
|
||||
for op := range AllOperations(err) {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return ""
|
||||
}
|
||||
return Join(" -> ", ops...)
|
||||
}
|
||||
|
||||
// --- ErrorLog: Log-and-Return Error Helpers ---
|
||||
|
||||
// ErrorLog combines error creation with logging.
|
||||
// Primary action: return an error. Secondary: log it.
|
||||
type ErrorLog struct {
|
||||
log *Log
|
||||
}
|
||||
|
||||
func (el *ErrorLog) logger() *Log {
|
||||
if el.log != nil {
|
||||
return el.log
|
||||
}
|
||||
return Default()
|
||||
}
|
||||
|
||||
// Error logs at Error level and returns a Result with the wrapped error.
|
||||
func (el *ErrorLog) Error(err error, op, msg string) Result {
|
||||
if err == nil {
|
||||
return Result{OK: true}
|
||||
}
|
||||
wrapped := Wrap(err, op, msg)
|
||||
el.logger().Error(msg, "op", op, "err", err)
|
||||
return Result{wrapped, false}
|
||||
}
|
||||
|
||||
// Warn logs at Warn level and returns a Result with the wrapped error.
|
||||
func (el *ErrorLog) Warn(err error, op, msg string) Result {
|
||||
if err == nil {
|
||||
return Result{OK: true}
|
||||
}
|
||||
wrapped := Wrap(err, op, msg)
|
||||
el.logger().Warn(msg, "op", op, "err", err)
|
||||
return Result{wrapped, false}
|
||||
}
|
||||
|
||||
// Must logs and panics if err is not nil.
|
||||
func (el *ErrorLog) Must(err error, op, msg string) {
|
||||
if err != nil {
|
||||
el.logger().Error(msg, "op", op, "err", err)
|
||||
panic(Wrap(err, op, msg))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Crash Recovery & Reporting ---
|
||||
|
||||
// CrashReport represents a single crash event.
|
||||
type CrashReport struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Error string `json:"error"`
|
||||
Stack string `json:"stack"`
|
||||
System CrashSystem `json:"system,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// CrashSystem holds system information at crash time.
|
||||
type CrashSystem struct {
|
||||
OperatingSystem string `json:"operatingsystem"`
|
||||
Architecture string `json:"architecture"`
|
||||
Version string `json:"go_version"`
|
||||
}
|
||||
|
||||
// ErrorPanic manages panic recovery and crash reporting.
|
||||
type ErrorPanic struct {
|
||||
filePath string
|
||||
meta map[string]string
|
||||
onCrash func(CrashReport)
|
||||
}
|
||||
|
||||
// Recover captures a panic and creates a crash report.
|
||||
// Use as: defer c.Error().Recover()
|
||||
func (h *ErrorPanic) Recover() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err, ok := r.(error)
|
||||
if !ok {
|
||||
err = NewError(Sprint("panic: ", r))
|
||||
}
|
||||
|
||||
report := CrashReport{
|
||||
Timestamp: time.Now(),
|
||||
Error: err.Error(),
|
||||
Stack: string(debug.Stack()),
|
||||
System: CrashSystem{
|
||||
OperatingSystem: runtime.GOOS,
|
||||
Architecture: runtime.GOARCH,
|
||||
Version: runtime.Version(),
|
||||
},
|
||||
Meta: maps.Clone(h.meta),
|
||||
}
|
||||
|
||||
if h.onCrash != nil {
|
||||
h.onCrash(report)
|
||||
}
|
||||
|
||||
if h.filePath != "" {
|
||||
h.appendReport(report)
|
||||
}
|
||||
}
|
||||
|
||||
// SafeGo runs a function in a goroutine with panic recovery.
|
||||
func (h *ErrorPanic) SafeGo(fn func()) {
|
||||
go func() {
|
||||
defer h.Recover()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// Reports returns the last n crash reports from the file.
|
||||
func (h *ErrorPanic) Reports(n int) Result {
|
||||
if h.filePath == "" {
|
||||
return Result{}
|
||||
}
|
||||
crashMu.Lock()
|
||||
defer crashMu.Unlock()
|
||||
data, err := os.ReadFile(h.filePath)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
var reports []CrashReport
|
||||
if err := json.Unmarshal(data, &reports); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if n <= 0 || len(reports) <= n {
|
||||
return Result{reports, true}
|
||||
}
|
||||
return Result{reports[len(reports)-n:], true}
|
||||
}
|
||||
|
||||
var crashMu sync.Mutex
|
||||
|
||||
func (h *ErrorPanic) appendReport(report CrashReport) {
|
||||
crashMu.Lock()
|
||||
defer crashMu.Unlock()
|
||||
|
||||
var reports []CrashReport
|
||||
if data, err := os.ReadFile(h.filePath); err == nil {
|
||||
if err := json.Unmarshal(data, &reports); err != nil {
|
||||
reports = nil
|
||||
}
|
||||
}
|
||||
|
||||
reports = append(reports, report)
|
||||
data, err := json.MarshalIndent(reports, "", " ")
|
||||
if err != nil {
|
||||
Default().Error(Concat("crash report marshal failed: ", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(h.filePath), 0755); err != nil {
|
||||
Default().Error(Concat("crash report dir failed: ", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(h.filePath, data, 0600); err != nil {
|
||||
Default().Error(Concat("crash report write failed: ", err.Error()))
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
271
error_test.go
Normal file
271
error_test.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Error Creation ---
|
||||
|
||||
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 TestError_E_WithCause_Good(t *testing.T) {
|
||||
cause := NewError("connection refused")
|
||||
err := E("db.Connect", "database unavailable", cause)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
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 TestError_Wrap_Nil_Good(t *testing.T) {
|
||||
err := Wrap(nil, "api.Call", "request failed")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
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 TestError_NewCode_Good(t *testing.T) {
|
||||
err := NewCode("NOT_FOUND", "resource not found")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "NOT_FOUND", ErrorCode(err))
|
||||
}
|
||||
|
||||
// --- Error Introspection ---
|
||||
|
||||
func TestError_Operation_Good(t *testing.T) {
|
||||
err := E("brain.Recall", "search failed", nil)
|
||||
assert.Equal(t, "brain.Recall", Operation(err))
|
||||
}
|
||||
|
||||
func TestError_Operation_Bad(t *testing.T) {
|
||||
err := NewError("plain error")
|
||||
assert.Equal(t, "", Operation(err))
|
||||
}
|
||||
|
||||
func TestError_ErrorMessage_Good(t *testing.T) {
|
||||
err := E("op", "the message", nil)
|
||||
assert.Equal(t, "the message", ErrorMessage(err))
|
||||
}
|
||||
|
||||
func TestError_ErrorMessage_Plain(t *testing.T) {
|
||||
err := NewError("plain")
|
||||
assert.Equal(t, "plain", ErrorMessage(err))
|
||||
}
|
||||
|
||||
func TestError_ErrorMessage_Nil(t *testing.T) {
|
||||
assert.Equal(t, "", ErrorMessage(nil))
|
||||
}
|
||||
|
||||
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 TestError_Root_Nil(t *testing.T) {
|
||||
assert.Nil(t, Root(nil))
|
||||
}
|
||||
|
||||
func TestError_StackTrace_Good(t *testing.T) {
|
||||
err := Wrap(E("inner", "cause", nil), "outer", "wrapper")
|
||||
stack := StackTrace(err)
|
||||
assert.Len(t, stack, 2)
|
||||
assert.Equal(t, "outer", stack[0])
|
||||
assert.Equal(t, "inner", stack[1])
|
||||
}
|
||||
|
||||
func TestError_FormatStackTrace_Good(t *testing.T) {
|
||||
err := Wrap(E("a", "x", nil), "b", "y")
|
||||
formatted := FormatStackTrace(err)
|
||||
assert.Equal(t, "b -> a", formatted)
|
||||
}
|
||||
|
||||
// --- ErrorLog ---
|
||||
|
||||
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 TestError_ErrorLog_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Log().Error(nil, "test.Operation", "no error")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
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 TestError_ErrorLog_Must_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
assert.Panics(t, func() {
|
||||
c.Log().Must(NewError("fatal"), "test.Operation", "must fail")
|
||||
})
|
||||
}
|
||||
|
||||
func TestError_ErrorLog_Must_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotPanics(t, func() {
|
||||
c.Log().Must(nil, "test.Operation", "no error")
|
||||
})
|
||||
}
|
||||
|
||||
// --- ErrorPanic ---
|
||||
|
||||
func TestError_ErrorPanic_Recover_Good(t *testing.T) {
|
||||
c := New()
|
||||
// Should not panic — Recover catches it
|
||||
assert.NotPanics(t, func() {
|
||||
defer c.Error().Recover()
|
||||
panic("test panic")
|
||||
})
|
||||
}
|
||||
|
||||
func TestError_ErrorPanic_SafeGo_Good(t *testing.T) {
|
||||
c := New()
|
||||
done := make(chan bool, 1)
|
||||
c.Error().SafeGo(func() {
|
||||
done <- true
|
||||
})
|
||||
assert.True(t, <-done)
|
||||
}
|
||||
|
||||
func TestError_ErrorPanic_SafeGo_Panic_Good(t *testing.T) {
|
||||
c := New()
|
||||
done := make(chan bool, 1)
|
||||
c.Error().SafeGo(func() {
|
||||
defer func() { done <- true }()
|
||||
panic("caught by SafeGo")
|
||||
})
|
||||
// SafeGo recovers — goroutine completes without crashing the process
|
||||
<-done
|
||||
}
|
||||
|
||||
// --- Standard Library Wrappers ---
|
||||
|
||||
func TestError_Is_Good(t *testing.T) {
|
||||
target := NewError("target")
|
||||
wrapped := Wrap(target, "op", "msg")
|
||||
assert.True(t, Is(wrapped, target))
|
||||
}
|
||||
|
||||
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 TestError_NewError_Good(t *testing.T) {
|
||||
err := NewError("simple error")
|
||||
assert.Equal(t, "simple error", err.Error())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// --- ErrorPanic Crash Reports ---
|
||||
|
||||
func TestError_ErrorPanic_Reports_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := Path(dir, "crashes.json")
|
||||
|
||||
// Create ErrorPanic with file output
|
||||
c := New()
|
||||
// Access internals via a crash that writes to file
|
||||
// Since ErrorPanic fields are unexported, we test via Recover
|
||||
_ = c
|
||||
_ = path
|
||||
// Crash reporting needs ErrorPanic configured with filePath — tested indirectly
|
||||
}
|
||||
|
||||
// --- ErrorPanic Crash File ---
|
||||
|
||||
func TestError_ErrorPanic_CrashFile_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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()
|
||||
r := c.Error().Reports(5)
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
_ = path
|
||||
}
|
||||
|
||||
// --- Error formatting branches ---
|
||||
|
||||
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 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 TestError_Err_Error_NoOp_Good(t *testing.T) {
|
||||
err := &Err{Message: "bare error"}
|
||||
assert.Equal(t, "bare error", err.Error())
|
||||
}
|
||||
|
||||
func TestError_WrapCode_NilErr_EmptyCode_Good(t *testing.T) {
|
||||
err := WrapCode(nil, "", "op", "msg")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
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 TestError_ErrorLog_Warn_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.LogWarn(nil, "op", "msg")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
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
|
||||
423
fs.go
Normal file
423
fs.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// Sandboxed local filesystem I/O for the Core framework.
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fs is a sandboxed local filesystem backend.
|
||||
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.
|
||||
func (m *Fs) path(p string) string {
|
||||
root := m.root
|
||||
if root == "" {
|
||||
root = "/"
|
||||
}
|
||||
if p == "" {
|
||||
return root
|
||||
}
|
||||
|
||||
// If the path is relative and the medium is rooted at "/",
|
||||
// treat it as relative to the current working directory.
|
||||
// This makes io.Local behave more like the standard 'os' package.
|
||||
if root == "/" && !filepath.IsAbs(p) {
|
||||
cwd, _ := os.Getwd()
|
||||
return filepath.Join(cwd, p)
|
||||
}
|
||||
|
||||
// Use filepath.Clean with a leading slash to resolve all .. and . internally
|
||||
// before joining with the root. This is a standard way to sandbox paths.
|
||||
clean := filepath.Clean("/" + p)
|
||||
|
||||
// If root is "/", allow absolute paths through
|
||||
if root == "/" {
|
||||
return clean
|
||||
}
|
||||
|
||||
// Strip leading "/" so Join works correctly with root
|
||||
return filepath.Join(root, clean[1:])
|
||||
}
|
||||
|
||||
// validatePath ensures the path is within the sandbox, following symlinks if they exist.
|
||||
func (m *Fs) validatePath(p string) Result {
|
||||
root := m.root
|
||||
if root == "" {
|
||||
root = "/"
|
||||
}
|
||||
if root == "/" {
|
||||
return Result{m.path(p), true}
|
||||
}
|
||||
|
||||
// Split the cleaned path into components
|
||||
parts := Split(filepath.Clean("/"+p), string(os.PathSeparator))
|
||||
current := root
|
||||
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
next := filepath.Join(current, part)
|
||||
realNext, err := filepath.EvalSymlinks(next)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Part doesn't exist, we can't follow symlinks anymore.
|
||||
// Since the path is already Cleaned and current is safe,
|
||||
// appending a component to current will not escape.
|
||||
current = next
|
||||
continue
|
||||
}
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
// Verify the resolved part is still within the root
|
||||
rel, err := filepath.Rel(root, realNext)
|
||||
if err != nil || HasPrefix(rel, "..") {
|
||||
// Security event: sandbox escape attempt
|
||||
username := "unknown"
|
||||
if u, err := user.Current(); err == nil {
|
||||
username = u.Username
|
||||
}
|
||||
Print(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s",
|
||||
time.Now().Format(time.RFC3339), root, p, realNext, username)
|
||||
if err == nil {
|
||||
err = E("fs.validatePath", Concat("sandbox escape: ", p, " resolves outside ", m.root), nil)
|
||||
}
|
||||
return Result{err, false}
|
||||
}
|
||||
current = realNext
|
||||
}
|
||||
|
||||
return Result{current, true}
|
||||
}
|
||||
|
||||
// Read returns file contents as string.
|
||||
func (m *Fs) Read(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
data, err := os.ReadFile(vp.Value.(string))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{string(data), true}
|
||||
}
|
||||
|
||||
// Write saves content to file, creating parent directories as needed.
|
||||
// Files are created with mode 0644. For sensitive files (keys, secrets),
|
||||
// use WriteMode with 0600.
|
||||
func (m *Fs) Write(p, content string) Result {
|
||||
return m.WriteMode(p, content, 0644)
|
||||
}
|
||||
|
||||
// WriteMode saves content to file with explicit permissions.
|
||||
// Use 0600 for sensitive files (encryption output, private keys, auth hashes).
|
||||
func (m *Fs) WriteMode(p, content string, mode os.FileMode) 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}
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
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)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
if err := os.MkdirAll(vp.Value.(string), 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// IsDir returns true if path is a directory.
|
||||
func (m *Fs) IsDir(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(vp.Value.(string))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// IsFile returns true if path is a regular file.
|
||||
func (m *Fs) IsFile(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(vp.Value.(string))
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// Exists returns true if path exists.
|
||||
func (m *Fs) Exists(p string) bool {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(vp.Value.(string))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// List returns directory entries.
|
||||
func (m *Fs) List(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return Result{}.New(os.ReadDir(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// Stat returns file info.
|
||||
func (m *Fs) Stat(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return Result{}.New(os.Stat(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// Open opens the named file for reading.
|
||||
func (m *Fs) Open(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return Result{}.New(os.Open(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// Create creates or truncates the named file.
|
||||
func (m *Fs) Create(p 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}
|
||||
}
|
||||
return Result{}.New(os.Create(full))
|
||||
}
|
||||
|
||||
// Append opens the named file for appending, creating it if it doesn't exist.
|
||||
func (m *Fs) Append(p 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}
|
||||
}
|
||||
return Result{}.New(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644))
|
||||
}
|
||||
|
||||
// ReadStream returns a reader for the file content.
|
||||
func (m *Fs) ReadStream(path string) Result {
|
||||
return m.Open(path)
|
||||
}
|
||||
|
||||
// WriteStream returns a writer for the file content.
|
||||
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)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if full == "/" || full == os.Getenv("HOME") {
|
||||
return Result{E("fs.Delete", Concat("refusing to delete protected path: ", full), nil), false}
|
||||
}
|
||||
if err := os.Remove(full); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// DeleteAll removes a file or directory recursively.
|
||||
func (m *Fs) DeleteAll(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if full == "/" || full == os.Getenv("HOME") {
|
||||
return Result{E("fs.DeleteAll", Concat("refusing to delete protected path: ", full), nil), false}
|
||||
}
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Rename moves a file or directory.
|
||||
func (m *Fs) Rename(oldPath, newPath string) Result {
|
||||
oldVp := m.validatePath(oldPath)
|
||||
if !oldVp.OK {
|
||||
return oldVp
|
||||
}
|
||||
newVp := m.validatePath(newPath)
|
||||
if !newVp.OK {
|
||||
return newVp
|
||||
}
|
||||
if err := os.Rename(oldVp.Value.(string), newVp.Value.(string)); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
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
|
||||
}
|
||||
349
fs_test.go
Normal file
349
fs_test.go
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Fs (Sandboxed Filesystem) ---
|
||||
|
||||
func TestFs_WriteRead_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
|
||||
path := Path(dir, "test.txt")
|
||||
assert.True(t, c.Fs().Write(path, "hello core").OK)
|
||||
|
||||
r := c.Fs().Read(path)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello core", r.Value.(string))
|
||||
}
|
||||
|
||||
func TestFs_Read_Bad(t *testing.T) {
|
||||
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()
|
||||
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()
|
||||
dir := t.TempDir()
|
||||
assert.True(t, c.Fs().IsDir(dir))
|
||||
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()
|
||||
path := Path(dir, "test.txt")
|
||||
c.Fs().Write(path, "data")
|
||||
assert.True(t, c.Fs().IsFile(path))
|
||||
assert.False(t, c.Fs().IsFile(dir))
|
||||
assert.False(t, c.Fs().IsFile(""))
|
||||
}
|
||||
|
||||
func TestFs_Exists_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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(Path(dir, "nope")))
|
||||
}
|
||||
|
||||
func TestFs_List_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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)
|
||||
}
|
||||
|
||||
func TestFs_Stat_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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.(fs.FileInfo).Name())
|
||||
}
|
||||
|
||||
func TestFs_Open_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "open.txt")
|
||||
c.Fs().Write(path, "content")
|
||||
r := c.Fs().Open(path)
|
||||
assert.True(t, r.OK)
|
||||
CloseStream(r.Value)
|
||||
}
|
||||
|
||||
func TestFs_Create_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "sub", "created.txt")
|
||||
r := c.Fs().Create(path)
|
||||
assert.True(t, r.OK)
|
||||
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()
|
||||
path := Path(dir, "append.txt")
|
||||
c.Fs().Write(path, "first")
|
||||
r := c.Fs().Append(path)
|
||||
assert.True(t, r.OK)
|
||||
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()
|
||||
path := Path(dir, "stream.txt")
|
||||
c.Fs().Write(path, "streamed")
|
||||
r := c.Fs().ReadStream(path)
|
||||
assert.True(t, r.OK)
|
||||
CloseStream(r.Value)
|
||||
}
|
||||
|
||||
func TestFs_WriteStream_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "sub", "ws.txt")
|
||||
r := c.Fs().WriteStream(path)
|
||||
assert.True(t, r.OK)
|
||||
WriteAll(r.Value, "stream")
|
||||
}
|
||||
|
||||
func TestFs_Delete_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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))
|
||||
}
|
||||
|
||||
func TestFs_DeleteAll_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
sub := Path(dir, "deep", "nested")
|
||||
c.Fs().EnsureDir(sub)
|
||||
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()
|
||||
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))
|
||||
assert.True(t, c.Fs().Exists(nw))
|
||||
}
|
||||
|
||||
func TestFs_WriteMode_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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.(fs.FileInfo).Name())
|
||||
}
|
||||
|
||||
// --- Zero Value ---
|
||||
|
||||
func TestFs_ZeroValue_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zeroFs := &Fs{}
|
||||
|
||||
path := Path(dir, "zero.txt")
|
||||
assert.True(t, zeroFs.Write(path, "zero value works").OK)
|
||||
r := zeroFs.Read(path)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "zero value works", r.Value.(string))
|
||||
assert.True(t, zeroFs.IsFile(path))
|
||||
assert.True(t, zeroFs.Exists(path))
|
||||
assert.True(t, zeroFs.IsDir(dir))
|
||||
}
|
||||
|
||||
func TestFs_ZeroValue_List_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zeroFs := &Fs{}
|
||||
|
||||
(&Fs{}).New("/").Write(Path(dir, "a.txt"), "a")
|
||||
r := zeroFs.List(dir)
|
||||
assert.True(t, r.OK)
|
||||
entries := r.Value.([]fs.DirEntry)
|
||||
assert.Len(t, entries, 1)
|
||||
}
|
||||
|
||||
func TestFs_Exists_NotFound_Bad(t *testing.T) {
|
||||
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()
|
||||
r := c.Fs().Read("")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_Write_EmptyPath_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Fs().Write("", "data")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_Delete_Protected_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Fs().Delete("/")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_DeleteAll_Protected_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Fs().DeleteAll("/")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_ReadStream_WriteStream_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := Path(dir, "stream.txt")
|
||||
c.Fs().Write(path, "streamed")
|
||||
|
||||
r := c.Fs().ReadStream(path)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
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())
|
||||
}
|
||||
11
go.mod
11
go.mod
|
|
@ -1,15 +1,14 @@
|
|||
module forge.lthn.ai/core/go
|
||||
module dappco.re/go/core
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-io v0.1.5
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
require github.com/stretchr/testify v1.11.1
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -1,15 +1,17 @@
|
|||
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
|
||||
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
|
|
|
|||
138
i18n.go
Normal file
138
i18n.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Internationalisation for the Core framework.
|
||||
// I18n collects locale mounts from services and delegates
|
||||
// translation to a registered Translator implementation (e.g., go-i18n).
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Translator defines the interface for translation services.
|
||||
// Implemented by go-i18n's Srv.
|
||||
type Translator interface {
|
||||
// Translate translates a message by its ID with optional arguments.
|
||||
Translate(messageID string, args ...any) Result
|
||||
// SetLanguage sets the active language (BCP47 tag, e.g., "en-GB", "de").
|
||||
SetLanguage(lang string) error
|
||||
// Language returns the current language code.
|
||||
Language() string
|
||||
// AvailableLanguages returns all loaded language codes.
|
||||
AvailableLanguages() []string
|
||||
}
|
||||
|
||||
// LocaleProvider is implemented by services that ship their own translation files.
|
||||
// Core discovers this interface during service registration and collects the
|
||||
// locale mounts. The i18n service loads them during startup.
|
||||
//
|
||||
// Usage in a service package:
|
||||
//
|
||||
// //go:embed locales
|
||||
// var localeFS embed.FS
|
||||
//
|
||||
// func (s *MyService) Locales() *Embed {
|
||||
// m, _ := Mount(localeFS, "locales")
|
||||
// return m
|
||||
// }
|
||||
type LocaleProvider interface {
|
||||
Locales() *Embed
|
||||
}
|
||||
|
||||
// I18n manages locale collection and translation dispatch.
|
||||
type I18n struct {
|
||||
mu sync.RWMutex
|
||||
locales []*Embed // collected from LocaleProvider services
|
||||
locale string
|
||||
translator Translator // registered implementation (nil until set)
|
||||
}
|
||||
|
||||
// AddLocales adds locale mounts (called during service registration).
|
||||
func (i *I18n) AddLocales(mounts ...*Embed) {
|
||||
i.mu.Lock()
|
||||
i.locales = append(i.locales, mounts...)
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// Locales returns all collected locale mounts.
|
||||
func (i *I18n) Locales() Result {
|
||||
i.mu.RLock()
|
||||
out := make([]*Embed, len(i.locales))
|
||||
copy(out, i.locales)
|
||||
i.mu.RUnlock()
|
||||
return Result{out, true}
|
||||
}
|
||||
|
||||
// SetTranslator registers the translation implementation.
|
||||
// Called by go-i18n's Srv during startup.
|
||||
func (i *I18n) SetTranslator(t Translator) {
|
||||
i.mu.Lock()
|
||||
i.translator = t
|
||||
locale := i.locale
|
||||
i.mu.Unlock()
|
||||
if t != nil && locale != "" {
|
||||
_ = t.SetLanguage(locale)
|
||||
}
|
||||
}
|
||||
|
||||
// Translator returns the registered translation implementation, or nil.
|
||||
func (i *I18n) Translator() Result {
|
||||
i.mu.RLock()
|
||||
t := i.translator
|
||||
i.mu.RUnlock()
|
||||
if t == nil {
|
||||
return Result{}
|
||||
}
|
||||
return Result{t, true}
|
||||
}
|
||||
|
||||
// Translate translates a message. Returns the key as-is if no translator is registered.
|
||||
func (i *I18n) Translate(messageID string, args ...any) Result {
|
||||
i.mu.RLock()
|
||||
t := i.translator
|
||||
i.mu.RUnlock()
|
||||
if t != nil {
|
||||
return t.Translate(messageID, args...)
|
||||
}
|
||||
return Result{messageID, true}
|
||||
}
|
||||
|
||||
// SetLanguage sets the active language and forwards to the translator if registered.
|
||||
func (i *I18n) SetLanguage(lang string) Result {
|
||||
if lang == "" {
|
||||
return Result{OK: true}
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.locale = lang
|
||||
t := i.translator
|
||||
i.mu.Unlock()
|
||||
if t != nil {
|
||||
if err := t.SetLanguage(lang); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Language returns the current language code, or "en" if not set.
|
||||
func (i *I18n) Language() string {
|
||||
i.mu.RLock()
|
||||
locale := i.locale
|
||||
i.mu.RUnlock()
|
||||
if locale != "" {
|
||||
return locale
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// AvailableLanguages returns all loaded language codes.
|
||||
func (i *I18n) AvailableLanguages() []string {
|
||||
i.mu.RLock()
|
||||
t := i.translator
|
||||
i.mu.RUnlock()
|
||||
if t != nil {
|
||||
return t.AvailableLanguages()
|
||||
}
|
||||
return []string{"en"}
|
||||
}
|
||||
96
i18n_test.go
Normal file
96
i18n_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- I18n ---
|
||||
|
||||
func TestI18n_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.I18n())
|
||||
}
|
||||
|
||||
func TestI18n_AddLocales_Good(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
r2 := c.I18n().Locales()
|
||||
assert.True(t, r2.OK)
|
||||
assert.Len(t, r2.Value.([]*Embed), 1)
|
||||
}
|
||||
|
||||
func TestI18n_Locales_Empty_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.I18n().Locales()
|
||||
assert.True(t, r.OK)
|
||||
assert.Empty(t, r.Value.([]*Embed))
|
||||
}
|
||||
|
||||
// --- Translator (no translator registered) ---
|
||||
|
||||
func TestI18n_Translate_NoTranslator_Good(t *testing.T) {
|
||||
c := New()
|
||||
// Without a translator, Translate returns the key as-is
|
||||
r := c.I18n().Translate("greeting.hello")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "greeting.hello", r.Value)
|
||||
}
|
||||
|
||||
func TestI18n_SetLanguage_NoTranslator_Good(t *testing.T) {
|
||||
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()
|
||||
assert.Equal(t, "en", c.I18n().Language())
|
||||
}
|
||||
|
||||
func TestI18n_AvailableLanguages_NoTranslator_Good(t *testing.T) {
|
||||
c := New()
|
||||
langs := c.I18n().AvailableLanguages()
|
||||
assert.Equal(t, []string{"en"}, langs)
|
||||
}
|
||||
|
||||
func TestI18n_Translator_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.False(t, c.I18n().Translator().OK)
|
||||
}
|
||||
|
||||
// --- Translator (with mock) ---
|
||||
|
||||
type mockTranslator struct {
|
||||
lang string
|
||||
}
|
||||
|
||||
func (m *mockTranslator) Translate(id string, args ...any) Result {
|
||||
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()
|
||||
tr := &mockTranslator{lang: "en"}
|
||||
c.I18n().SetTranslator(tr)
|
||||
|
||||
assert.Equal(t, tr, c.I18n().Translator().Value)
|
||||
assert.Equal(t, "translated:hello", c.I18n().Translate("hello").Value)
|
||||
assert.Equal(t, "en", c.I18n().Language())
|
||||
assert.Equal(t, []string{"en", "de", "fr"}, c.I18n().AvailableLanguages())
|
||||
|
||||
c.I18n().SetLanguage("de")
|
||||
assert.Equal(t, "de", c.I18n().Language())
|
||||
}
|
||||
134
info.go
Normal file
134
info.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// System information registry for the Core framework.
|
||||
// Read-only key-value store of environment facts, populated once at init.
|
||||
// Env is environment. Config is ours.
|
||||
//
|
||||
// System keys:
|
||||
//
|
||||
// core.Env("OS") // "darwin"
|
||||
// core.Env("ARCH") // "arm64"
|
||||
// core.Env("GO") // "go1.26"
|
||||
// core.Env("DS") // "/" (directory separator)
|
||||
// core.Env("PS") // ":" (path list separator)
|
||||
// core.Env("HOSTNAME") // "cladius"
|
||||
// core.Env("USER") // "snider"
|
||||
// core.Env("PID") // "12345"
|
||||
// core.Env("NUM_CPU") // "10"
|
||||
//
|
||||
// Directory keys:
|
||||
//
|
||||
// core.Env("DIR_HOME") // "/Users/snider"
|
||||
// core.Env("DIR_CONFIG") // "~/Library/Application Support"
|
||||
// core.Env("DIR_CACHE") // "~/Library/Caches"
|
||||
// core.Env("DIR_DATA") // "~/Library" (platform-specific)
|
||||
// core.Env("DIR_TMP") // "/tmp"
|
||||
// core.Env("DIR_CWD") // current working directory
|
||||
// core.Env("DIR_DOWNLOADS") // "~/Downloads"
|
||||
// core.Env("DIR_CODE") // "~/Code"
|
||||
//
|
||||
// Timestamp keys:
|
||||
//
|
||||
// core.Env("CORE_START") // "2026-03-22T14:30:00Z"
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SysInfo holds read-only system information, populated once at init.
|
||||
type SysInfo struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
// systemInfo is declared empty — populated in init() so Path() can be used
|
||||
// without creating an init cycle.
|
||||
var systemInfo = &SysInfo{values: make(map[string]string)}
|
||||
|
||||
func init() {
|
||||
i := systemInfo
|
||||
|
||||
// System
|
||||
i.values["OS"] = runtime.GOOS
|
||||
i.values["ARCH"] = runtime.GOARCH
|
||||
i.values["GO"] = runtime.Version()
|
||||
i.values["DS"] = string(os.PathSeparator)
|
||||
i.values["PS"] = string(os.PathListSeparator)
|
||||
i.values["PID"] = strconv.Itoa(os.Getpid())
|
||||
i.values["NUM_CPU"] = strconv.Itoa(runtime.NumCPU())
|
||||
i.values["USER"] = Username()
|
||||
|
||||
if h, err := os.Hostname(); err == nil {
|
||||
i.values["HOSTNAME"] = h
|
||||
}
|
||||
|
||||
// Directories — DS and DIR_HOME set first so Path() can use them.
|
||||
// CORE_HOME overrides os.UserHomeDir() (e.g., agent workspaces).
|
||||
if d := os.Getenv("CORE_HOME"); d != "" {
|
||||
i.values["DIR_HOME"] = d
|
||||
} else if d, err := os.UserHomeDir(); err == nil {
|
||||
i.values["DIR_HOME"] = d
|
||||
}
|
||||
|
||||
// Derived directories via Path() — single point of responsibility
|
||||
i.values["DIR_DOWNLOADS"] = Path("Downloads")
|
||||
i.values["DIR_CODE"] = Path("Code")
|
||||
if d, err := os.UserConfigDir(); err == nil {
|
||||
i.values["DIR_CONFIG"] = d
|
||||
}
|
||||
if d, err := os.UserCacheDir(); err == nil {
|
||||
i.values["DIR_CACHE"] = d
|
||||
}
|
||||
i.values["DIR_TMP"] = os.TempDir()
|
||||
if d, err := os.Getwd(); err == nil {
|
||||
i.values["DIR_CWD"] = d
|
||||
}
|
||||
|
||||
// Platform-specific data directory
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
i.values["DIR_DATA"] = Path(Env("DIR_HOME"), "Library")
|
||||
case "windows":
|
||||
if d := os.Getenv("LOCALAPPDATA"); d != "" {
|
||||
i.values["DIR_DATA"] = d
|
||||
}
|
||||
default:
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
i.values["DIR_DATA"] = xdg
|
||||
} else if Env("DIR_HOME") != "" {
|
||||
i.values["DIR_DATA"] = Path(Env("DIR_HOME"), ".local", "share")
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
i.values["CORE_START"] = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Env returns a system information value by key.
|
||||
// Core keys (OS, DIR_HOME, DS, etc.) are pre-populated at init.
|
||||
// Unknown keys fall through to os.Getenv — making Env a universal
|
||||
// replacement for os.Getenv.
|
||||
//
|
||||
// core.Env("OS") // "darwin" (pre-populated)
|
||||
// core.Env("DIR_HOME") // "/Users/snider" (pre-populated)
|
||||
// core.Env("FORGE_TOKEN") // falls through to os.Getenv
|
||||
func Env(key string) string {
|
||||
if v := systemInfo.values[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
// EnvKeys returns all available environment keys.
|
||||
//
|
||||
// keys := core.EnvKeys()
|
||||
func EnvKeys() []string {
|
||||
keys := make([]string, 0, len(systemInfo.values))
|
||||
for k := range systemInfo.values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
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
|
||||
}
|
||||
97
info_test.go
Normal file
97
info_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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 TestInfo_Env_ARCH_Good(t *testing.T) {
|
||||
v := core.Env("ARCH")
|
||||
assert.NotEmpty(t, v)
|
||||
assert.Contains(t, []string{"amd64", "arm64", "386"}, v)
|
||||
}
|
||||
|
||||
func TestInfo_Env_GO_Good(t *testing.T) {
|
||||
assert.True(t, core.HasPrefix(core.Env("GO"), "go"))
|
||||
}
|
||||
|
||||
func TestInfo_Env_DS_Good(t *testing.T) {
|
||||
ds := core.Env("DS")
|
||||
assert.Contains(t, []string{"/", "\\"}, ds)
|
||||
}
|
||||
|
||||
func TestInfo_Env_PS_Good(t *testing.T) {
|
||||
ps := core.Env("PS")
|
||||
assert.Contains(t, []string{":", ";"}, ps)
|
||||
}
|
||||
|
||||
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 TestInfo_Env_DIR_TMP_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("DIR_TMP"))
|
||||
}
|
||||
|
||||
func TestInfo_Env_DIR_CONFIG_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("DIR_CONFIG"))
|
||||
}
|
||||
|
||||
func TestInfo_Env_DIR_CACHE_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("DIR_CACHE"))
|
||||
}
|
||||
|
||||
func TestInfo_Env_HOSTNAME_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("HOSTNAME"))
|
||||
}
|
||||
|
||||
func TestInfo_Env_USER_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("USER"))
|
||||
}
|
||||
|
||||
func TestInfo_Env_PID_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("PID"))
|
||||
}
|
||||
|
||||
func TestInfo_Env_NUM_CPU_Good(t *testing.T) {
|
||||
assert.NotEmpty(t, core.Env("NUM_CPU"))
|
||||
}
|
||||
|
||||
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 TestInfo_Env_Bad_Unknown(t *testing.T) {
|
||||
assert.Equal(t, "", core.Env("NOPE"))
|
||||
}
|
||||
|
||||
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 TestInfo_EnvKeys_Good(t *testing.T) {
|
||||
keys := core.EnvKeys()
|
||||
assert.NotEmpty(t, keys)
|
||||
assert.Contains(t, keys, "OS")
|
||||
assert.Contains(t, keys, "DIR_HOME")
|
||||
assert.Contains(t, keys, "CORE_START")
|
||||
}
|
||||
113
ipc.go
Normal file
113
ipc.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Message bus for the Core framework.
|
||||
// Dispatches actions (fire-and-forget), queries (first responder),
|
||||
// and tasks (first executor) between registered handlers.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
queryMu sync.RWMutex
|
||||
queryHandlers []QueryHandler
|
||||
|
||||
actions *Registry[*Action] // named action registry
|
||||
tasks *Registry[*Task] // named task registry
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
c.ipc.queryMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
r := h(c, q)
|
||||
if r.OK {
|
||||
return r
|
||||
}
|
||||
}
|
||||
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)
|
||||
c.ipc.queryMu.RUnlock()
|
||||
|
||||
var results []any
|
||||
for _, h := range handlers {
|
||||
r := h(c, q)
|
||||
if r.OK && r.Value != nil {
|
||||
results = append(results, r.Value)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
142
ipc_test.go
Normal file
142
ipc_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- IPC: Actions ---
|
||||
|
||||
type testMessage struct{ payload string }
|
||||
|
||||
func TestAction_Good(t *testing.T) {
|
||||
c := New()
|
||||
var received Message
|
||||
c.RegisterAction(func(_ *Core, msg Message) Result {
|
||||
received = msg
|
||||
return Result{OK: true}
|
||||
})
|
||||
r := c.ACTION(testMessage{payload: "hello"})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, testMessage{payload: "hello"}, received)
|
||||
}
|
||||
|
||||
func TestAction_Multiple_Good(t *testing.T) {
|
||||
c := New()
|
||||
count := 0
|
||||
handler := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
|
||||
c.RegisterActions(handler, handler, handler)
|
||||
c.ACTION(nil)
|
||||
assert.Equal(t, 3, count)
|
||||
}
|
||||
|
||||
func TestAction_None_Good(t *testing.T) {
|
||||
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 TestIpc_Query_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
if q == "ping" {
|
||||
return Result{Value: "pong", OK: true}
|
||||
}
|
||||
return Result{}
|
||||
})
|
||||
r := c.QUERY("ping")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "pong", r.Value)
|
||||
}
|
||||
|
||||
func TestIpc_Query_Unhandled_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
return Result{}
|
||||
})
|
||||
r := c.QUERY("unknown")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestIpc_QueryAll_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, _ Query) Result {
|
||||
return Result{Value: "a", OK: true}
|
||||
})
|
||||
c.RegisterQuery(func(_ *Core, _ Query) Result {
|
||||
return Result{Value: "b", OK: true}
|
||||
})
|
||||
r := c.QUERYALL("anything")
|
||||
assert.True(t, r.OK)
|
||||
results := r.Value.([]any)
|
||||
assert.Len(t, results, 2)
|
||||
assert.Contains(t, results, "a")
|
||||
assert.Contains(t, results, "b")
|
||||
}
|
||||
|
||||
// --- IPC: Named Action Invocation ---
|
||||
|
||||
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.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/
|
||||
68
lock.go
Normal file
68
lock.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Synchronisation, locking, and lifecycle snapshots for the Core framework.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
r := c.lock.locks.Get(name)
|
||||
if r.OK {
|
||||
return &Lock{Name: name, Mutex: r.Value.(*sync.RWMutex)}
|
||||
}
|
||||
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) {
|
||||
c.services.lockEnabled = true
|
||||
}
|
||||
|
||||
// LockApply activates the service lock if it was enabled.
|
||||
func (c *Core) LockApply(name ...string) {
|
||||
if c.services.lockEnabled {
|
||||
c.services.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
// Startables returns services that have an OnStart function, in registration order.
|
||||
func (c *Core) Startables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
var out []*Service
|
||||
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, in registration order.
|
||||
func (c *Core) Stoppables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
var out []*Service
|
||||
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
|
||||
}
|
||||
55
lock_test.go
Normal file
55
lock_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLock_Good(t *testing.T) {
|
||||
c := New()
|
||||
lock := c.Lock("test")
|
||||
assert.NotNil(t, lock)
|
||||
assert.NotNil(t, lock.Mutex)
|
||||
}
|
||||
|
||||
func TestLock_SameName_Good(t *testing.T) {
|
||||
c := New()
|
||||
l1 := c.Lock("shared")
|
||||
l2 := c.Lock("shared")
|
||||
assert.Equal(t, l1, l2)
|
||||
}
|
||||
|
||||
func TestLock_DifferentName_Good(t *testing.T) {
|
||||
c := New()
|
||||
l1 := c.Lock("a")
|
||||
l2 := c.Lock("b")
|
||||
assert.NotEqual(t, l1, l2)
|
||||
}
|
||||
|
||||
func TestLock_LockEnable_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("early", Service{})
|
||||
c.LockEnable()
|
||||
c.LockApply()
|
||||
|
||||
r := c.Service("late", Service{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
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 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)
|
||||
assert.Len(t, r.Value.([]*Service), 1)
|
||||
}
|
||||
402
log.go
Normal file
402
log.go
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
// Structured logging for the Core framework.
|
||||
//
|
||||
// core.SetLevel(core.LevelDebug)
|
||||
// core.Info("server started", "port", 8080)
|
||||
// core.Error("failed to connect", "err", err)
|
||||
package core
|
||||
|
||||
import (
|
||||
goio "io"
|
||||
"os"
|
||||
"os/user"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level defines logging verbosity.
|
||||
type Level int
|
||||
|
||||
// Logging level constants ordered by increasing verbosity.
|
||||
const (
|
||||
// LevelQuiet suppresses all log output.
|
||||
LevelQuiet Level = iota
|
||||
// LevelError shows only error messages.
|
||||
LevelError
|
||||
// LevelWarn shows warnings and errors.
|
||||
LevelWarn
|
||||
// LevelInfo shows informational messages, warnings, and errors.
|
||||
LevelInfo
|
||||
// LevelDebug shows all messages including debug details.
|
||||
LevelDebug
|
||||
)
|
||||
|
||||
// String returns the level name.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelQuiet:
|
||||
return "quiet"
|
||||
case LevelError:
|
||||
return "error"
|
||||
case LevelWarn:
|
||||
return "warn"
|
||||
case LevelInfo:
|
||||
return "info"
|
||||
case LevelDebug:
|
||||
return "debug"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Log provides structured logging.
|
||||
type Log struct {
|
||||
mu sync.RWMutex
|
||||
level Level
|
||||
output goio.Writer
|
||||
|
||||
// RedactKeys is a list of keys whose values should be masked in logs.
|
||||
redactKeys []string
|
||||
|
||||
// Style functions for formatting (can be overridden)
|
||||
StyleTimestamp func(string) string
|
||||
StyleDebug func(string) string
|
||||
StyleInfo func(string) string
|
||||
StyleWarn func(string) string
|
||||
StyleError func(string) string
|
||||
StyleSecurity func(string) string
|
||||
}
|
||||
|
||||
// RotationLogOptions defines the log rotation and retention policy.
|
||||
type RotationLogOptions struct {
|
||||
// Filename is the log file path. If empty, rotation is disabled.
|
||||
Filename string
|
||||
|
||||
// MaxSize is the maximum size of the log file in megabytes before it gets rotated.
|
||||
// It defaults to 100 megabytes.
|
||||
MaxSize int
|
||||
|
||||
// MaxAge is the maximum number of days to retain old log files based on their
|
||||
// file modification time. It defaults to 28 days.
|
||||
// Note: set to a negative value to disable age-based retention.
|
||||
MaxAge int
|
||||
|
||||
// MaxBackups is the maximum number of old log files to retain.
|
||||
// It defaults to 5 backups.
|
||||
MaxBackups int
|
||||
|
||||
// Compress determines if the rotated log files should be compressed using gzip.
|
||||
// It defaults to true.
|
||||
Compress bool
|
||||
}
|
||||
|
||||
// LogOptions configures a Log.
|
||||
type LogOptions struct {
|
||||
Level Level
|
||||
// Output is the destination for log messages. If Rotation is provided,
|
||||
// Output is ignored and logs are written to the rotating file instead.
|
||||
Output goio.Writer
|
||||
// Rotation enables log rotation to file. If provided, Filename must be set.
|
||||
Rotation *RotationLogOptions
|
||||
// RedactKeys is a list of keys whose values should be masked in logs.
|
||||
RedactKeys []string
|
||||
}
|
||||
|
||||
// RotationWriterFactory creates a rotating writer from options.
|
||||
// Set this to enable log rotation (provided by core/go-io integration).
|
||||
var RotationWriterFactory func(RotationLogOptions) goio.WriteCloser
|
||||
|
||||
// New creates a new Log with the given options.
|
||||
func NewLog(opts LogOptions) *Log {
|
||||
output := opts.Output
|
||||
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
|
||||
output = RotationWriterFactory(*opts.Rotation)
|
||||
}
|
||||
if output == nil {
|
||||
output = os.Stderr
|
||||
}
|
||||
|
||||
return &Log{
|
||||
level: opts.Level,
|
||||
output: output,
|
||||
redactKeys: slices.Clone(opts.RedactKeys),
|
||||
StyleTimestamp: identity,
|
||||
StyleDebug: identity,
|
||||
StyleInfo: identity,
|
||||
StyleWarn: identity,
|
||||
StyleError: identity,
|
||||
StyleSecurity: identity,
|
||||
}
|
||||
}
|
||||
|
||||
func identity(s string) string { return s }
|
||||
|
||||
// SetLevel changes the log level.
|
||||
func (l *Log) SetLevel(level Level) {
|
||||
l.mu.Lock()
|
||||
l.level = level
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Level returns the current log level.
|
||||
func (l *Log) Level() Level {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.level
|
||||
}
|
||||
|
||||
// SetOutput changes the output writer.
|
||||
func (l *Log) SetOutput(w goio.Writer) {
|
||||
l.mu.Lock()
|
||||
l.output = w
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetRedactKeys sets the keys to be redacted.
|
||||
func (l *Log) SetRedactKeys(keys ...string) {
|
||||
l.mu.Lock()
|
||||
l.redactKeys = slices.Clone(keys)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *Log) shouldLog(level Level) bool {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return level <= l.level
|
||||
}
|
||||
|
||||
func (l *Log) log(level Level, prefix, msg string, keyvals ...any) {
|
||||
l.mu.RLock()
|
||||
output := l.output
|
||||
styleTimestamp := l.StyleTimestamp
|
||||
redactKeys := l.redactKeys
|
||||
l.mu.RUnlock()
|
||||
|
||||
timestamp := styleTimestamp(time.Now().Format("15:04:05"))
|
||||
|
||||
// Copy keyvals to avoid mutating the caller's slice
|
||||
keyvals = append([]any(nil), keyvals...)
|
||||
|
||||
// Automatically extract context from error if present in keyvals
|
||||
origLen := len(keyvals)
|
||||
for i := 0; i < origLen; i += 2 {
|
||||
if i+1 < origLen {
|
||||
if err, ok := keyvals[i+1].(error); ok {
|
||||
if op := Operation(err); op != "" {
|
||||
// Check if op is already in keyvals
|
||||
hasOp := false
|
||||
for j := 0; j < len(keyvals); j += 2 {
|
||||
if k, ok := keyvals[j].(string); ok && k == "op" {
|
||||
hasOp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOp {
|
||||
keyvals = append(keyvals, "op", op)
|
||||
}
|
||||
}
|
||||
if stack := FormatStackTrace(err); stack != "" {
|
||||
// Check if stack is already in keyvals
|
||||
hasStack := false
|
||||
for j := 0; j < len(keyvals); j += 2 {
|
||||
if k, ok := keyvals[j].(string); ok && k == "stack" {
|
||||
hasStack = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasStack {
|
||||
keyvals = append(keyvals, "stack", stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format key-value pairs
|
||||
var kvStr string
|
||||
if len(keyvals) > 0 {
|
||||
kvStr = " "
|
||||
for i := 0; i < len(keyvals); i += 2 {
|
||||
if i > 0 {
|
||||
kvStr += " "
|
||||
}
|
||||
key := keyvals[i]
|
||||
var val any
|
||||
if i+1 < len(keyvals) {
|
||||
val = keyvals[i+1]
|
||||
}
|
||||
|
||||
// Redaction logic
|
||||
keyStr := Sprint(key)
|
||||
if slices.Contains(redactKeys, keyStr) {
|
||||
val = "[REDACTED]"
|
||||
}
|
||||
|
||||
// Secure formatting to prevent log injection
|
||||
if s, ok := val.(string); ok {
|
||||
kvStr += Sprintf("%v=%q", key, s)
|
||||
} else {
|
||||
kvStr += Sprintf("%v=%v", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Print(output, "%s %s %s%s", timestamp, prefix, msg, kvStr)
|
||||
}
|
||||
|
||||
// Debug logs a debug message with optional key-value pairs.
|
||||
func (l *Log) Debug(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelDebug) {
|
||||
l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// Info logs an info message with optional key-value pairs.
|
||||
func (l *Log) Info(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelInfo) {
|
||||
l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// Warn logs a warning message with optional key-value pairs.
|
||||
func (l *Log) Warn(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelWarn) {
|
||||
l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// Error logs an error message with optional key-value pairs.
|
||||
func (l *Log) Error(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelError) {
|
||||
l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// Security logs a security event with optional key-value pairs.
|
||||
// It uses LevelError to ensure security events are visible even in restrictive
|
||||
// log configurations.
|
||||
func (l *Log) Security(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelError) {
|
||||
l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// Username returns the current system username.
|
||||
// It uses os/user for reliability and falls back to environment variables.
|
||||
func Username() string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
// Fallback for environments where user lookup might fail
|
||||
if u := os.Getenv("USER"); u != "" {
|
||||
return u
|
||||
}
|
||||
return os.Getenv("USERNAME")
|
||||
}
|
||||
|
||||
// --- Default logger ---
|
||||
|
||||
var defaultLogPtr atomic.Pointer[Log]
|
||||
|
||||
func init() {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
defaultLogPtr.Store(l)
|
||||
}
|
||||
|
||||
// Default returns the default logger.
|
||||
func Default() *Log {
|
||||
return defaultLogPtr.Load()
|
||||
}
|
||||
|
||||
// SetDefault sets the default logger.
|
||||
func SetDefault(l *Log) {
|
||||
defaultLogPtr.Store(l)
|
||||
}
|
||||
|
||||
// SetLevel sets the default logger's level.
|
||||
func SetLevel(level Level) {
|
||||
Default().SetLevel(level)
|
||||
}
|
||||
|
||||
// SetRedactKeys sets the default logger's redaction keys.
|
||||
func SetRedactKeys(keys ...string) {
|
||||
Default().SetRedactKeys(keys...)
|
||||
}
|
||||
|
||||
// Debug logs to the default logger.
|
||||
func Debug(msg string, keyvals ...any) {
|
||||
Default().Debug(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Info logs to the default logger.
|
||||
func Info(msg string, keyvals ...any) {
|
||||
Default().Info(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Warn logs to the default logger.
|
||||
func Warn(msg string, keyvals ...any) {
|
||||
Default().Warn(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Error logs to the default logger.
|
||||
func Error(msg string, keyvals ...any) {
|
||||
Default().Error(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Security logs to the default logger.
|
||||
func Security(msg string, keyvals ...any) {
|
||||
Default().Security(msg, keyvals...)
|
||||
}
|
||||
|
||||
// --- LogErr: Error-Aware Logger ---
|
||||
|
||||
// LogErr logs structured information extracted from errors.
|
||||
// Primary action: log. Secondary: extract error context.
|
||||
type LogErr struct {
|
||||
log *Log
|
||||
}
|
||||
|
||||
// NewLogErr creates a LogErr bound to the given logger.
|
||||
func NewLogErr(log *Log) *LogErr {
|
||||
return &LogErr{log: log}
|
||||
}
|
||||
|
||||
// Log extracts context from an Err and logs it at Error level.
|
||||
func (le *LogErr) Log(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
le.log.Error(ErrorMessage(err), "op", Operation(err), "code", ErrorCode(err), "stack", FormatStackTrace(err))
|
||||
}
|
||||
|
||||
// --- LogPanic: Panic-Aware Logger ---
|
||||
|
||||
// LogPanic logs panic context without crash file management.
|
||||
// Primary action: log. Secondary: recover panics.
|
||||
type LogPanic struct {
|
||||
log *Log
|
||||
}
|
||||
|
||||
// NewLogPanic creates a LogPanic bound to the given logger.
|
||||
func NewLogPanic(log *Log) *LogPanic {
|
||||
return &LogPanic{log: log}
|
||||
}
|
||||
|
||||
// Recover captures a panic and logs it. Does not write crash files.
|
||||
// Use as: defer core.NewLogPanic(logger).Recover()
|
||||
func (lp *LogPanic) Recover() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
err, ok := r.(error)
|
||||
if !ok {
|
||||
err = NewError(Sprint("panic: ", r))
|
||||
}
|
||||
lp.log.Error("panic recovered",
|
||||
"err", err,
|
||||
"op", Operation(err),
|
||||
"stack", FormatStackTrace(err),
|
||||
)
|
||||
}
|
||||
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")
|
||||
}
|
||||
164
log_test.go
Normal file
164
log_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Log ---
|
||||
|
||||
func TestLog_New_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
assert.NotNil(t, l)
|
||||
}
|
||||
|
||||
func TestLog_AllLevels_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelDebug})
|
||||
l.Debug("debug")
|
||||
l.Info("info")
|
||||
l.Warn("warn")
|
||||
l.Error("error")
|
||||
l.Security("security event")
|
||||
}
|
||||
|
||||
func TestLog_LevelFiltering_Good(t *testing.T) {
|
||||
// At Error level, Debug/Info/Warn should be suppressed (no panic)
|
||||
l := NewLog(LogOptions{Level: LevelError})
|
||||
l.Debug("suppressed")
|
||||
l.Info("suppressed")
|
||||
l.Warn("suppressed")
|
||||
l.Error("visible")
|
||||
}
|
||||
|
||||
func TestLog_SetLevel_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
l.SetLevel(LevelDebug)
|
||||
assert.Equal(t, LevelDebug, l.Level())
|
||||
}
|
||||
|
||||
func TestLog_SetRedactKeys_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
l.SetRedactKeys("password", "token")
|
||||
// Redacted keys should mask values in output
|
||||
l.Info("login", "password", "secret123", "user", "admin")
|
||||
}
|
||||
|
||||
func TestLog_LevelString_Good(t *testing.T) {
|
||||
assert.Equal(t, "debug", LevelDebug.String())
|
||||
assert.Equal(t, "info", LevelInfo.String())
|
||||
assert.Equal(t, "warn", LevelWarn.String())
|
||||
assert.Equal(t, "error", LevelError.String())
|
||||
}
|
||||
|
||||
func TestLog_CoreLog_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.Log())
|
||||
}
|
||||
|
||||
func TestLog_ErrorSink_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
var sink ErrorSink = l
|
||||
sink.Error("test")
|
||||
sink.Warn("test")
|
||||
}
|
||||
|
||||
// --- Default Logger ---
|
||||
|
||||
func TestLog_Default_Good(t *testing.T) {
|
||||
d := Default()
|
||||
assert.NotNil(t, d)
|
||||
}
|
||||
|
||||
func TestLog_SetDefault_Good(t *testing.T) {
|
||||
original := Default()
|
||||
defer SetDefault(original)
|
||||
|
||||
custom := NewLog(LogOptions{Level: LevelDebug})
|
||||
SetDefault(custom)
|
||||
assert.Equal(t, custom, Default())
|
||||
}
|
||||
|
||||
func TestLog_PackageLevelFunctions_Good(t *testing.T) {
|
||||
// Package-level log functions use the default logger
|
||||
Debug("debug msg")
|
||||
Info("info msg")
|
||||
Warn("warn msg")
|
||||
Error("error msg")
|
||||
Security("security msg")
|
||||
}
|
||||
|
||||
func TestLog_PackageSetLevel_Good(t *testing.T) {
|
||||
original := Default()
|
||||
defer SetDefault(original)
|
||||
|
||||
SetLevel(LevelDebug)
|
||||
SetRedactKeys("secret")
|
||||
}
|
||||
|
||||
func TestLog_Username_Good(t *testing.T) {
|
||||
u := Username()
|
||||
assert.NotEmpty(t, u)
|
||||
}
|
||||
|
||||
// --- LogErr ---
|
||||
|
||||
func TestLog_LogErr_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
le := NewLogErr(l)
|
||||
assert.NotNil(t, le)
|
||||
|
||||
err := E("test.Operation", "something broke", nil)
|
||||
le.Log(err)
|
||||
}
|
||||
|
||||
func TestLog_LogErr_Nil_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
le := NewLogErr(l)
|
||||
le.Log(nil) // should not panic
|
||||
}
|
||||
|
||||
// --- LogPanic ---
|
||||
|
||||
func TestLog_LogPanic_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
lp := NewLogPanic(l)
|
||||
assert.NotNil(t, lp)
|
||||
}
|
||||
|
||||
func TestLog_LogPanic_Recover_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
lp := NewLogPanic(l)
|
||||
assert.NotPanics(t, func() {
|
||||
defer lp.Recover()
|
||||
panic("caught")
|
||||
})
|
||||
}
|
||||
|
||||
// --- SetOutput ---
|
||||
|
||||
func TestLog_SetOutput_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
l.SetOutput(NewBuilder())
|
||||
l.Info("redirected")
|
||||
}
|
||||
|
||||
// --- Log suppression by level ---
|
||||
|
||||
func TestLog_Quiet_Suppresses_Ugly(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelQuiet})
|
||||
// These should not panic even though nothing is logged
|
||||
l.Debug("suppressed")
|
||||
l.Info("suppressed")
|
||||
l.Warn("suppressed")
|
||||
l.Error("suppressed")
|
||||
}
|
||||
|
||||
func TestLog_ErrorLevel_Suppresses_Ugly(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelError})
|
||||
l.Debug("suppressed") // below threshold
|
||||
l.Info("suppressed") // below threshold
|
||||
l.Warn("suppressed") // below threshold
|
||||
l.Error("visible") // at threshold
|
||||
}
|
||||
197
options.go
Normal file
197
options.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Core primitives: Option, Options, Result.
|
||||
//
|
||||
// Options is the universal input type. Result is the universal output type.
|
||||
// All Core operations accept Options and return Result.
|
||||
//
|
||||
// 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(opts)
|
||||
// if !r.OK { core.Error("failed", "err", r.Error()) }
|
||||
type Result struct {
|
||||
Value any
|
||||
OK bool
|
||||
}
|
||||
|
||||
// Result gets or sets the value. Zero args returns Value. With args, maps
|
||||
// Go (value, error) pairs to Result and returns self.
|
||||
//
|
||||
// r.Result(file, err) // OK = err == nil, Value = file
|
||||
// r.Result(value) // OK = true, Value = value
|
||||
// r.Result() // after set — returns the value
|
||||
func (r Result) Result(args ...any) Result {
|
||||
if len(args) == 0 {
|
||||
return r
|
||||
}
|
||||
return r.New(args...)
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// core.Option{Key: "name", Value: "brain"}
|
||||
// core.Option{Key: "port", Value: 8080}
|
||||
type Option struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
// --- Options: Universal Input ---
|
||||
|
||||
// Options is the universal input type for Core operations.
|
||||
// A structured collection of key-value pairs with typed accessors.
|
||||
//
|
||||
// opts := core.NewOptions(
|
||||
// core.Option{Key: "name", Value: "myapp"},
|
||||
// core.Option{Key: "port", Value: 8080},
|
||||
// )
|
||||
// name := opts.String("name")
|
||||
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.items {
|
||||
if opt.Key == key {
|
||||
return Result{opt.Value, true}
|
||||
}
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Has returns true if a key exists.
|
||||
//
|
||||
// if opts.Has("debug") { ... }
|
||||
func (o Options) Has(key string) bool {
|
||||
return o.Get(key).OK
|
||||
}
|
||||
|
||||
// String retrieves a string value, empty string if missing.
|
||||
//
|
||||
// name := opts.String("name")
|
||||
func (o Options) String(key string) string {
|
||||
r := o.Get(key)
|
||||
if !r.OK {
|
||||
return ""
|
||||
}
|
||||
s, _ := r.Value.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Int retrieves an int value, 0 if missing.
|
||||
//
|
||||
// port := opts.Int("port")
|
||||
func (o Options) Int(key string) int {
|
||||
r := o.Get(key)
|
||||
if !r.OK {
|
||||
return 0
|
||||
}
|
||||
i, _ := r.Value.(int)
|
||||
return i
|
||||
}
|
||||
|
||||
// Bool retrieves a bool value, false if missing.
|
||||
//
|
||||
// debug := opts.Bool("debug")
|
||||
func (o Options) Bool(key string) bool {
|
||||
r := o.Get(key)
|
||||
if !r.OK {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
177
options_test.go
Normal file
177
options_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- 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 := 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 := 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 := 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 := NewOptions(Option{Key: "name", Value: "brain"})
|
||||
assert.Equal(t, "brain", opts.String("name"))
|
||||
}
|
||||
|
||||
func TestOptions_String_Bad(t *testing.T) {
|
||||
opts := NewOptions(Option{Key: "port", Value: 8080})
|
||||
assert.Equal(t, "", opts.String("port"))
|
||||
assert.Equal(t, "", opts.String("missing"))
|
||||
}
|
||||
|
||||
// --- Options.Int ---
|
||||
|
||||
func TestOptions_Int_Good(t *testing.T) {
|
||||
opts := NewOptions(Option{Key: "port", Value: 8080})
|
||||
assert.Equal(t, 8080, opts.Int("port"))
|
||||
}
|
||||
|
||||
func TestOptions_Int_Bad(t *testing.T) {
|
||||
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 := NewOptions(Option{Key: "debug", Value: true})
|
||||
assert.True(t, opts.Bool("debug"))
|
||||
}
|
||||
|
||||
func TestOptions_Bool_Bad(t *testing.T) {
|
||||
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) {
|
||||
type BrainConfig struct {
|
||||
Name string
|
||||
OllamaURL string
|
||||
Collection string
|
||||
}
|
||||
cfg := BrainConfig{Name: "brain", OllamaURL: "http://localhost:11434", Collection: "openbrain"}
|
||||
opts := NewOptions(Option{Key: "config", Value: cfg})
|
||||
|
||||
r := opts.Get("config")
|
||||
assert.True(t, r.OK)
|
||||
bc, ok := r.Value.(BrainConfig)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "brain", bc.Name)
|
||||
assert.Equal(t, "http://localhost:11434", bc.OllamaURL)
|
||||
}
|
||||
|
||||
// --- 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"))
|
||||
}
|
||||
174
path.go
Normal file
174
path.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// OS-aware filesystem path operations for the Core framework.
|
||||
// Uses Env("DS") for the separator and Core string primitives
|
||||
// for path manipulation. filepath imported only for PathGlob.
|
||||
//
|
||||
// Path anchors relative segments to DIR_HOME:
|
||||
//
|
||||
// core.Path("Code", ".core") // "/Users/snider/Code/.core"
|
||||
// core.Path("/tmp", "workspace") // "/tmp/workspace"
|
||||
// core.Path() // "/Users/snider"
|
||||
//
|
||||
// Path component helpers:
|
||||
//
|
||||
// core.PathBase("/Users/snider/Code/core") // "core"
|
||||
// core.PathDir("/Users/snider/Code/core") // "/Users/snider/Code"
|
||||
// core.PathExt("main.go") // ".go"
|
||||
package core
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// Path builds a clean, absolute filesystem path from segments.
|
||||
// Uses Env("DS") for the OS directory separator.
|
||||
// Relative paths are anchored to DIR_HOME. Absolute paths pass through.
|
||||
//
|
||||
// core.Path("Code", ".core") // "/Users/snider/Code/.core"
|
||||
// core.Path("/tmp", "workspace") // "/tmp/workspace"
|
||||
// core.Path() // "/Users/snider"
|
||||
func Path(segments ...string) string {
|
||||
ds := Env("DS")
|
||||
home := Env("DIR_HOME")
|
||||
if home == "" {
|
||||
home = "."
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return home
|
||||
}
|
||||
p := Join(ds, segments...)
|
||||
if PathIsAbs(p) {
|
||||
return CleanPath(p, ds)
|
||||
}
|
||||
return CleanPath(home+ds+p, ds)
|
||||
}
|
||||
|
||||
// PathBase returns the last element of a path.
|
||||
//
|
||||
// core.PathBase("/Users/snider/Code/core") // "core"
|
||||
// core.PathBase("deploy/to/homelab") // "homelab"
|
||||
func PathBase(p string) string {
|
||||
if p == "" {
|
||||
return "."
|
||||
}
|
||||
ds := Env("DS")
|
||||
p = TrimSuffix(p, ds)
|
||||
if p == "" {
|
||||
return ds
|
||||
}
|
||||
parts := Split(p, ds)
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// PathDir returns all but the last element of a path.
|
||||
//
|
||||
// core.PathDir("/Users/snider/Code/core") // "/Users/snider/Code"
|
||||
func PathDir(p string) string {
|
||||
if p == "" {
|
||||
return "."
|
||||
}
|
||||
ds := Env("DS")
|
||||
i := lastIndex(p, ds)
|
||||
if i < 0 {
|
||||
return "."
|
||||
}
|
||||
dir := p[:i]
|
||||
if dir == "" {
|
||||
return ds
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// PathExt returns the file extension including the dot.
|
||||
//
|
||||
// core.PathExt("main.go") // ".go"
|
||||
// core.PathExt("Makefile") // ""
|
||||
func PathExt(p string) string {
|
||||
base := PathBase(p)
|
||||
i := lastIndex(base, ".")
|
||||
if i <= 0 {
|
||||
return ""
|
||||
}
|
||||
return base[i:]
|
||||
}
|
||||
|
||||
// PathIsAbs returns true if the path is absolute.
|
||||
// Handles Unix (starts with /) and Windows (drive letter like C:).
|
||||
//
|
||||
// core.PathIsAbs("/tmp") // true
|
||||
// core.PathIsAbs("C:\\tmp") // true
|
||||
// core.PathIsAbs("relative") // false
|
||||
func PathIsAbs(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
if p[0] == '/' {
|
||||
return true
|
||||
}
|
||||
// Windows: C:\ or C:/
|
||||
if len(p) >= 3 && p[1] == ':' && (p[2] == '/' || p[2] == '\\') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CleanPath removes redundant separators and resolves . and .. elements.
|
||||
//
|
||||
// core.CleanPath("/tmp//file", "/") // "/tmp/file"
|
||||
// core.CleanPath("a/b/../c", "/") // "a/c"
|
||||
func CleanPath(p, ds string) string {
|
||||
if p == "" {
|
||||
return "."
|
||||
}
|
||||
|
||||
rooted := HasPrefix(p, ds)
|
||||
parts := Split(p, ds)
|
||||
var clean []string
|
||||
|
||||
for _, part := range parts {
|
||||
switch part {
|
||||
case "", ".":
|
||||
continue
|
||||
case "..":
|
||||
if len(clean) > 0 && clean[len(clean)-1] != ".." {
|
||||
clean = clean[:len(clean)-1]
|
||||
} else if !rooted {
|
||||
clean = append(clean, "..")
|
||||
}
|
||||
default:
|
||||
clean = append(clean, part)
|
||||
}
|
||||
}
|
||||
|
||||
result := Join(ds, clean...)
|
||||
if rooted {
|
||||
result = ds + result
|
||||
}
|
||||
if result == "" {
|
||||
if rooted {
|
||||
return ds
|
||||
}
|
||||
return "."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// PathGlob returns file paths matching a pattern.
|
||||
//
|
||||
// core.PathGlob("/tmp/agent-*.log")
|
||||
func PathGlob(pattern string) []string {
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
return matches
|
||||
}
|
||||
|
||||
// lastIndex returns the index of the last occurrence of substr in s, or -1.
|
||||
func lastIndex(s, substr string) int {
|
||||
if substr == "" || s == "" {
|
||||
return -1
|
||||
}
|
||||
for i := len(s) - len(substr); i >= 0; i-- {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
110
path_test.go
Normal file
110
path_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPath_Relative(t *testing.T) {
|
||||
home := core.Env("DIR_HOME")
|
||||
|
||||
ds := core.Env("DS")
|
||||
assert.Equal(t, home+ds+"Code"+ds+".core", core.Path("Code", ".core"))
|
||||
}
|
||||
|
||||
func TestPath_Absolute(t *testing.T) {
|
||||
ds := core.Env("DS")
|
||||
assert.Equal(t, "/tmp"+ds+"workspace", core.Path("/tmp", "workspace"))
|
||||
}
|
||||
|
||||
func TestPath_Empty(t *testing.T) {
|
||||
home := core.Env("DIR_HOME")
|
||||
|
||||
assert.Equal(t, home, core.Path())
|
||||
}
|
||||
|
||||
func TestPath_Cleans(t *testing.T) {
|
||||
home := core.Env("DIR_HOME")
|
||||
|
||||
assert.Equal(t, home+core.Env("DS")+"Code", core.Path("Code", "sub", ".."))
|
||||
}
|
||||
|
||||
func TestPath_CleanDoubleSlash(t *testing.T) {
|
||||
ds := core.Env("DS")
|
||||
assert.Equal(t, ds+"tmp"+ds+"file", core.Path("/tmp//file"))
|
||||
}
|
||||
|
||||
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 TestPath_PathBase_Root(t *testing.T) {
|
||||
assert.Equal(t, "/", core.PathBase("/"))
|
||||
}
|
||||
|
||||
func TestPath_PathBase_Empty(t *testing.T) {
|
||||
assert.Equal(t, ".", core.PathBase(""))
|
||||
}
|
||||
|
||||
func TestPath_PathDir(t *testing.T) {
|
||||
assert.Equal(t, "/Users/snider/Code", core.PathDir("/Users/snider/Code/core"))
|
||||
}
|
||||
|
||||
func TestPath_PathDir_Root(t *testing.T) {
|
||||
assert.Equal(t, "/", core.PathDir("/file"))
|
||||
}
|
||||
|
||||
func TestPath_PathDir_NoDir(t *testing.T) {
|
||||
assert.Equal(t, ".", core.PathDir("file.go"))
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
func TestPath_EnvConsistency(t *testing.T) {
|
||||
assert.Equal(t, core.Env("DIR_HOME"), core.Path())
|
||||
}
|
||||
|
||||
func TestPath_PathGlob_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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(core.Path(dir, "*.txt"))
|
||||
assert.Len(t, matches, 2)
|
||||
}
|
||||
|
||||
func TestPath_PathGlob_NoMatch(t *testing.T) {
|
||||
matches := core.PathGlob("/nonexistent/pattern-*.xyz")
|
||||
assert.Empty(t, matches)
|
||||
}
|
||||
|
||||
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 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 TestPath_PathDir_TrailingSlash(t *testing.T) {
|
||||
result := core.PathDir("/Users/snider/Code/")
|
||||
assert.Equal(t, "/Users/snider/Code", result)
|
||||
}
|
||||
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCore_PerformAsync_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var completed atomic.Bool
|
||||
var resultReceived any
|
||||
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if tc, ok := msg.(ActionTaskCompleted); ok {
|
||||
resultReceived = tc.Result
|
||||
completed.Store(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
|
||||
return "async-result", true, nil
|
||||
})
|
||||
|
||||
taskID := c.PerformAsync(TestTask{})
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
// Wait for completion
|
||||
assert.Eventually(t, func() bool {
|
||||
return completed.Load()
|
||||
}, 1*time.Second, 10*time.Millisecond)
|
||||
|
||||
assert.Equal(t, "async-result", resultReceived)
|
||||
}
|
||||
|
||||
func TestCore_PerformAsync_Shutdown(t *testing.T) {
|
||||
c, _ := New()
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
|
||||
taskID := c.PerformAsync(TestTask{})
|
||||
assert.Empty(t, taskID, "PerformAsync should return empty string if already shut down")
|
||||
}
|
||||
|
||||
func TestCore_Progress_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var progressReceived float64
|
||||
var messageReceived string
|
||||
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if tp, ok := msg.(ActionTaskProgress); ok {
|
||||
progressReceived = tp.Progress
|
||||
messageReceived = tp.Message
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
c.Progress("task-1", 0.5, "halfway", TestTask{})
|
||||
|
||||
assert.Equal(t, 0.5, progressReceived)
|
||||
assert.Equal(t, "halfway", messageReceived)
|
||||
}
|
||||
|
||||
func TestCore_WithService_UnnamedType(t *testing.T) {
|
||||
// Primitive types have no package path
|
||||
factory := func(c *Core) (any, error) {
|
||||
s := "primitive"
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
_, err := New(WithService(factory))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "service name could not be discovered")
|
||||
}
|
||||
|
||||
func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) {
|
||||
rt, _ := NewRuntime(nil)
|
||||
|
||||
// Register a service that fails startup
|
||||
errSvc := &MockStartable{err: errors.New("startup failed")}
|
||||
_ = rt.Core.RegisterService("error-svc", errSvc)
|
||||
|
||||
err := rt.ServiceStartup(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "startup failed")
|
||||
}
|
||||
|
||||
func TestCore_ServiceStartup_ContextCancellation(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
s1 := &MockStartable{}
|
||||
_ = c.RegisterService("s1", s1)
|
||||
|
||||
err := c.ServiceStartup(ctx, nil)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.False(t, s1.started, "Service should not have started if context was cancelled before loop")
|
||||
}
|
||||
|
||||
func TestCore_ServiceShutdown_ContextCancellation(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
s1 := &MockStoppable{}
|
||||
_ = c.RegisterService("s1", s1)
|
||||
|
||||
err := c.ServiceShutdown(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.False(t, s1.stopped, "Service should not have stopped if context was cancelled before loop")
|
||||
}
|
||||
|
||||
type TaskWithIDImpl struct {
|
||||
id string
|
||||
}
|
||||
func (t *TaskWithIDImpl) SetTaskID(id string) { t.id = id }
|
||||
func (t *TaskWithIDImpl) GetTaskID() string { return t.id }
|
||||
|
||||
func TestCore_PerformAsync_InjectsID(t *testing.T) {
|
||||
c, _ := New()
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) { return nil, true, nil })
|
||||
|
||||
task := &TaskWithIDImpl{}
|
||||
taskID := c.PerformAsync(task)
|
||||
|
||||
assert.Equal(t, taskID, task.GetTaskID())
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkMessageBus_Action(b *testing.B) {
|
||||
c, _ := New()
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
return nil
|
||||
})
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = c.ACTION("test")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMessageBus_Query(b *testing.B) {
|
||||
c, _ := New()
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "result", true, nil
|
||||
})
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = c.QUERY("test")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMessageBus_Perform(b *testing.B) {
|
||||
c, _ := New()
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
return "result", true, nil
|
||||
})
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = c.PERFORM("test")
|
||||
}
|
||||
}
|
||||
402
pkg/core/core.go
402
pkg/core/core.go
|
|
@ -1,402 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
instance *Core
|
||||
instanceMu sync.RWMutex
|
||||
)
|
||||
|
||||
// New initialises a Core instance using the provided options and performs the necessary setup.
|
||||
// It is the primary entry point for creating a new Core application.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// core, err := core.New(
|
||||
// core.WithService(&MyService{}),
|
||||
// core.WithAssets(assets),
|
||||
// )
|
||||
func New(opts ...Option) (*Core, error) {
|
||||
c := &Core{
|
||||
Features: &Features{},
|
||||
svc: newServiceManager(),
|
||||
}
|
||||
c.bus = newMessageBus(c)
|
||||
|
||||
for _, o := range opts {
|
||||
if err := o(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c.svc.applyLock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// WithService creates an Option that registers a service. It automatically discovers
|
||||
// the service name from its package path and registers its IPC handler if it
|
||||
// implements a method named `HandleIPCEvents`.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // In myapp/services/calculator.go
|
||||
// package services
|
||||
//
|
||||
// type Calculator struct{}
|
||||
//
|
||||
// func (s *Calculator) Add(a, b int) int { return a + b }
|
||||
//
|
||||
// // In main.go
|
||||
// import "myapp/services"
|
||||
//
|
||||
// core.New(core.WithService(services.NewCalculator))
|
||||
func WithService(factory func(*Core) (any, error)) Option {
|
||||
return func(c *Core) error {
|
||||
serviceInstance, err := factory(c)
|
||||
|
||||
if err != nil {
|
||||
return E("core.WithService", "failed to create service", err)
|
||||
}
|
||||
if serviceInstance == nil {
|
||||
return E("core.WithService", "service factory returned nil instance", nil)
|
||||
}
|
||||
|
||||
// --- Service Name Discovery ---
|
||||
typeOfService := reflect.TypeOf(serviceInstance)
|
||||
if typeOfService.Kind() == reflect.Ptr {
|
||||
typeOfService = typeOfService.Elem()
|
||||
}
|
||||
pkgPath := typeOfService.PkgPath()
|
||||
parts := strings.Split(pkgPath, "/")
|
||||
name := strings.ToLower(parts[len(parts)-1])
|
||||
if name == "" {
|
||||
return E("core.WithService", fmt.Sprintf("service name could not be discovered for type %T (PkgPath is empty)", serviceInstance), nil)
|
||||
}
|
||||
|
||||
// --- IPC Handler Discovery ---
|
||||
instanceValue := reflect.ValueOf(serviceInstance)
|
||||
handlerMethod := instanceValue.MethodByName("HandleIPCEvents")
|
||||
if handlerMethod.IsValid() {
|
||||
if handler, ok := handlerMethod.Interface().(func(*Core, Message) error); ok {
|
||||
c.RegisterAction(handler)
|
||||
} else {
|
||||
return E("core.WithService", fmt.Sprintf("service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name), nil)
|
||||
}
|
||||
}
|
||||
|
||||
return c.RegisterService(name, serviceInstance)
|
||||
}
|
||||
}
|
||||
|
||||
// WithName creates an option that registers a service with a specific name.
|
||||
// This is useful when the service name cannot be inferred from the package path,
|
||||
// such as when using anonymous functions as factories.
|
||||
// Note: Unlike WithService, this does not automatically discover or register
|
||||
// IPC handlers. If your service needs IPC handling, implement HandleIPCEvents
|
||||
// and register it manually.
|
||||
func WithName(name string, factory func(*Core) (any, error)) Option {
|
||||
return func(c *Core) error {
|
||||
serviceInstance, err := factory(c)
|
||||
if err != nil {
|
||||
return E("core.WithName", fmt.Sprintf("failed to create service %q", name), err)
|
||||
}
|
||||
return c.RegisterService(name, serviceInstance)
|
||||
}
|
||||
}
|
||||
|
||||
// WithApp creates an Option that injects the GUI runtime (e.g., Wails App) into the Core.
|
||||
// This is essential for services that need to interact with the GUI runtime.
|
||||
func WithApp(app any) Option {
|
||||
return func(c *Core) error {
|
||||
c.App = app
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithAssets creates an Option that registers the application's embedded assets.
|
||||
// This is necessary for the application to be able to serve its frontend.
|
||||
func WithAssets(fs embed.FS) Option {
|
||||
return func(c *Core) error {
|
||||
c.assets = fs
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceLock creates an Option that prevents any further services from being
|
||||
// registered after the Core has been initialized. This is a security measure to
|
||||
// prevent late-binding of services that could have unintended consequences.
|
||||
func WithServiceLock() Option {
|
||||
return func(c *Core) error {
|
||||
c.svc.enableLock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Core Methods ---
|
||||
|
||||
// ServiceStartup is the entry point for the Core service's startup lifecycle.
|
||||
// It is called by the GUI runtime when the application starts.
|
||||
func (c *Core) ServiceStartup(ctx context.Context, options any) error {
|
||||
startables := c.svc.getStartables()
|
||||
|
||||
var agg error
|
||||
for _, s := range startables {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errors.Join(agg, err)
|
||||
}
|
||||
if err := s.OnStartup(ctx); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.ACTION(ActionServiceStartup{}); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
|
||||
return agg
|
||||
}
|
||||
|
||||
// ServiceShutdown is the entry point for the Core service's shutdown lifecycle.
|
||||
// It is called by the GUI runtime when the application shuts down.
|
||||
func (c *Core) ServiceShutdown(ctx context.Context) error {
|
||||
c.shutdown.Store(true)
|
||||
|
||||
var agg error
|
||||
if err := c.ACTION(ActionServiceShutdown{}); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
|
||||
stoppables := c.svc.getStoppables()
|
||||
for _, s := range slices.Backward(stoppables) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
break // don't return — must still wait for background tasks below
|
||||
}
|
||||
if err := s.OnShutdown(ctx); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for background tasks (PerformAsync), respecting context deadline.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
agg = errors.Join(agg, ctx.Err())
|
||||
}
|
||||
|
||||
return agg
|
||||
}
|
||||
|
||||
// ACTION dispatches a message to all registered IPC handlers.
|
||||
// This is the primary mechanism for services to communicate with each other.
|
||||
func (c *Core) ACTION(msg Message) error {
|
||||
return c.bus.action(msg)
|
||||
}
|
||||
|
||||
// RegisterAction adds a new IPC handler to the Core.
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) error) {
|
||||
c.bus.registerAction(handler)
|
||||
}
|
||||
|
||||
// RegisterActions adds multiple IPC handlers to the Core.
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) {
|
||||
c.bus.registerActions(handlers...)
|
||||
}
|
||||
|
||||
// QUERY dispatches a query to handlers until one responds.
|
||||
// Returns (result, handled, error). If no handler responds, handled is false.
|
||||
func (c *Core) QUERY(q Query) (any, bool, error) {
|
||||
return c.bus.query(q)
|
||||
}
|
||||
|
||||
// QUERYALL dispatches a query to all handlers and collects all responses.
|
||||
// Returns all results from handlers that responded.
|
||||
func (c *Core) QUERYALL(q Query) ([]any, error) {
|
||||
return c.bus.queryAll(q)
|
||||
}
|
||||
|
||||
// PERFORM dispatches a task to handlers until one executes it.
|
||||
// Returns (result, handled, error). If no handler responds, handled is false.
|
||||
func (c *Core) PERFORM(t Task) (any, bool, error) {
|
||||
return c.bus.perform(t)
|
||||
}
|
||||
|
||||
// PerformAsync dispatches a task to be executed in a background goroutine.
|
||||
// It returns a unique task ID that can be used to track the task's progress.
|
||||
// The result of the task will be broadcasted via an ActionTaskCompleted message.
|
||||
func (c *Core) PerformAsync(t Task) string {
|
||||
if c.shutdown.Load() {
|
||||
return ""
|
||||
}
|
||||
|
||||
taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1))
|
||||
|
||||
// If the task supports it, inject the ID
|
||||
if tid, ok := t.(TaskWithID); ok {
|
||||
tid.SetTaskID(taskID)
|
||||
}
|
||||
|
||||
// Broadcast task started
|
||||
_ = c.ACTION(ActionTaskStarted{
|
||||
TaskID: taskID,
|
||||
Task: t,
|
||||
})
|
||||
|
||||
c.wg.Go(func() {
|
||||
result, handled, err := c.PERFORM(t)
|
||||
if !handled && err == nil {
|
||||
err = E("core.PerformAsync", fmt.Sprintf("no handler found for task type %T", t), nil)
|
||||
}
|
||||
|
||||
// Broadcast task completed
|
||||
_ = c.ACTION(ActionTaskCompleted{
|
||||
TaskID: taskID,
|
||||
Task: t,
|
||||
Result: result,
|
||||
Error: err,
|
||||
})
|
||||
})
|
||||
|
||||
return taskID
|
||||
}
|
||||
|
||||
// Progress broadcasts a progress update for a background task.
|
||||
func (c *Core) Progress(taskID string, progress float64, message string, t Task) {
|
||||
_ = c.ACTION(ActionTaskProgress{
|
||||
TaskID: taskID,
|
||||
Task: t,
|
||||
Progress: progress,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterQuery adds a query handler to the Core.
|
||||
func (c *Core) RegisterQuery(handler QueryHandler) {
|
||||
c.bus.registerQuery(handler)
|
||||
}
|
||||
|
||||
// RegisterTask adds a task handler to the Core.
|
||||
func (c *Core) RegisterTask(handler TaskHandler) {
|
||||
c.bus.registerTask(handler)
|
||||
}
|
||||
|
||||
// RegisterService adds a new service to the Core.
|
||||
// If the service implements LocaleProvider, its locale FS is collected
|
||||
// for the i18n service to load during startup.
|
||||
func (c *Core) RegisterService(name string, api any) error {
|
||||
// Collect locale filesystems from services that provide them
|
||||
if lp, ok := api.(LocaleProvider); ok {
|
||||
c.locales = append(c.locales, lp.Locales())
|
||||
}
|
||||
return c.svc.registerService(name, api)
|
||||
}
|
||||
|
||||
// Service retrieves a registered service by name.
|
||||
// It returns nil if the service is not found.
|
||||
func (c *Core) Service(name string) any {
|
||||
return c.svc.service(name)
|
||||
}
|
||||
|
||||
// ServiceFor retrieves a registered service by name and asserts its type to the given interface T.
|
||||
func ServiceFor[T any](c *Core, name string) (T, error) {
|
||||
var zero T
|
||||
raw := c.Service(name)
|
||||
if raw == nil {
|
||||
return zero, E("core.ServiceFor", fmt.Sprintf("service %q not found", name), nil)
|
||||
}
|
||||
typed, ok := raw.(T)
|
||||
if !ok {
|
||||
return zero, E("core.ServiceFor", fmt.Sprintf("service %q is type %T, expected %T", name, raw, zero), nil)
|
||||
}
|
||||
return typed, nil
|
||||
}
|
||||
|
||||
// MustServiceFor retrieves a registered service by name and asserts its type to the given interface T.
|
||||
// It panics if the service is not found or cannot be cast to T.
|
||||
func MustServiceFor[T any](c *Core, name string) T {
|
||||
svc, err := ServiceFor[T](c, name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// App returns the global application instance.
|
||||
// It panics if the Core has not been initialized via SetInstance.
|
||||
// This is typically used by GUI runtimes that need global access.
|
||||
func App() any {
|
||||
instanceMu.RLock()
|
||||
inst := instance
|
||||
instanceMu.RUnlock()
|
||||
if inst == nil {
|
||||
panic("core.App() called before core.SetInstance()")
|
||||
}
|
||||
return inst.App
|
||||
}
|
||||
|
||||
// SetInstance sets the global Core instance for App() access.
|
||||
// This is typically called by GUI runtimes during initialization.
|
||||
func SetInstance(c *Core) {
|
||||
instanceMu.Lock()
|
||||
instance = c
|
||||
instanceMu.Unlock()
|
||||
}
|
||||
|
||||
// GetInstance returns the global Core instance, or nil if not set.
|
||||
// Use this for non-panicking access to the global instance.
|
||||
func GetInstance() *Core {
|
||||
instanceMu.RLock()
|
||||
inst := instance
|
||||
instanceMu.RUnlock()
|
||||
return inst
|
||||
}
|
||||
|
||||
// ClearInstance resets the global Core instance to nil.
|
||||
// This is primarily useful for testing to ensure a clean state between tests.
|
||||
func ClearInstance() {
|
||||
instanceMu.Lock()
|
||||
instance = nil
|
||||
instanceMu.Unlock()
|
||||
}
|
||||
|
||||
// Config returns the registered Config service.
|
||||
func (c *Core) Config() Config {
|
||||
return MustServiceFor[Config](c, "config")
|
||||
}
|
||||
|
||||
// Display returns the registered Display service.
|
||||
func (c *Core) Display() Display {
|
||||
return MustServiceFor[Display](c, "display")
|
||||
}
|
||||
|
||||
// Workspace returns the registered Workspace service.
|
||||
func (c *Core) Workspace() Workspace {
|
||||
return MustServiceFor[Workspace](c, "workspace")
|
||||
}
|
||||
|
||||
// Crypt returns the registered Crypt service.
|
||||
func (c *Core) Crypt() Crypt {
|
||||
return MustServiceFor[Crypt](c, "crypt")
|
||||
}
|
||||
|
||||
// Core returns self, implementing the CoreProvider interface.
|
||||
func (c *Core) Core() *Core { return c }
|
||||
|
||||
// Assets returns the embedded filesystem containing the application's assets.
|
||||
func (c *Core) Assets() embed.FS {
|
||||
return c.assets
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MockServiceWithIPC struct {
|
||||
MockService
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (m *MockServiceWithIPC) HandleIPCEvents(c *Core, msg Message) error {
|
||||
m.handled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_WithService_IPC(t *testing.T) {
|
||||
svc := &MockServiceWithIPC{MockService: MockService{Name: "ipc-service"}}
|
||||
factory := func(c *Core) (any, error) {
|
||||
return svc, nil
|
||||
}
|
||||
c, err := New(WithService(factory))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Trigger ACTION to verify handler was registered
|
||||
err = c.ACTION(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, svc.handled)
|
||||
}
|
||||
|
||||
func TestCore_ACTION_Bad(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
errHandler := func(c *Core, msg Message) error {
|
||||
return assert.AnError
|
||||
}
|
||||
c.RegisterAction(errHandler)
|
||||
err = c.ACTION(nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), assert.AnError.Error())
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MockStartable struct {
|
||||
started bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockStartable) OnStartup(ctx context.Context) error {
|
||||
m.started = true
|
||||
return m.err
|
||||
}
|
||||
|
||||
type MockStoppable struct {
|
||||
stopped bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockStoppable) OnShutdown(ctx context.Context) error {
|
||||
m.stopped = true
|
||||
return m.err
|
||||
}
|
||||
|
||||
type MockLifecycle struct {
|
||||
MockStartable
|
||||
MockStoppable
|
||||
}
|
||||
|
||||
func TestCore_LifecycleInterfaces(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
startable := &MockStartable{}
|
||||
stoppable := &MockStoppable{}
|
||||
lifecycle := &MockLifecycle{}
|
||||
|
||||
// Register services
|
||||
err = c.RegisterService("startable", startable)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("stoppable", stoppable)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("lifecycle", lifecycle)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, startable.started)
|
||||
assert.True(t, lifecycle.started)
|
||||
assert.False(t, stoppable.stopped)
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, stoppable.stopped)
|
||||
assert.True(t, lifecycle.stopped)
|
||||
}
|
||||
|
||||
type MockLifecycleWithLog struct {
|
||||
id string
|
||||
log *[]string
|
||||
}
|
||||
|
||||
func (m *MockLifecycleWithLog) OnStartup(ctx context.Context) error {
|
||||
*m.log = append(*m.log, "start-"+m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockLifecycleWithLog) OnShutdown(ctx context.Context) error {
|
||||
*m.log = append(*m.log, "stop-"+m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_LifecycleOrder(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
var callOrder []string
|
||||
|
||||
s1 := &MockLifecycleWithLog{id: "1", log: &callOrder}
|
||||
s2 := &MockLifecycleWithLog{id: "2", log: &callOrder}
|
||||
|
||||
err = c.RegisterService("s1", s1)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("s2", s2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)
|
||||
|
||||
// Reset log
|
||||
callOrder = nil
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder)
|
||||
}
|
||||
|
||||
func TestCore_LifecycleErrors(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
s1 := &MockStartable{err: assert.AnError}
|
||||
s2 := &MockStoppable{err: assert.AnError}
|
||||
|
||||
_ = c.RegisterService("s1", s1)
|
||||
_ = c.RegisterService("s2", s2)
|
||||
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestCore_LifecycleErrors_Aggregated(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Register action that fails
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if _, ok := msg.(ActionServiceStartup); ok {
|
||||
return errors.New("startup action error")
|
||||
}
|
||||
if _, ok := msg.(ActionServiceShutdown); ok {
|
||||
return errors.New("shutdown action error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register service that fails
|
||||
s1 := &MockStartable{err: errors.New("startup service error")}
|
||||
s2 := &MockStoppable{err: errors.New("shutdown service error")}
|
||||
|
||||
err = c.RegisterService("s1", s1)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("s2", s2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "startup action error")
|
||||
assert.Contains(t, err.Error(), "startup service error")
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "shutdown action error")
|
||||
assert.Contains(t, err.Error(), "shutdown service error")
|
||||
}
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// mockApp is a simple mock for testing app injection
|
||||
type mockApp struct{}
|
||||
|
||||
func TestCore_New_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
// Mock service for testing
|
||||
type MockService struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (m *MockService) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
|
||||
func TestCore_WithService_Good(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
}
|
||||
c, err := New(WithService(factory))
|
||||
assert.NoError(t, err)
|
||||
svc := c.Service("core")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_WithService_Bad(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err := New(WithService(factory))
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
type MockConfigService struct{}
|
||||
|
||||
func (m *MockConfigService) Get(key string, out any) error { return nil }
|
||||
func (m *MockConfigService) Set(key string, v any) error { return nil }
|
||||
|
||||
type MockDisplayService struct{}
|
||||
|
||||
func (m *MockDisplayService) OpenWindow(opts ...WindowOption) error { return nil }
|
||||
|
||||
func TestCore_Services_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = c.RegisterService("config", &MockConfigService{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = c.RegisterService("display", &MockDisplayService{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
cfg := c.Config()
|
||||
assert.NotNil(t, cfg)
|
||||
|
||||
d := c.Display()
|
||||
assert.NotNil(t, d)
|
||||
}
|
||||
|
||||
func TestCore_Services_Ugly(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Config panics when service not registered
|
||||
assert.Panics(t, func() {
|
||||
c.Config()
|
||||
})
|
||||
|
||||
// Display panics when service not registered
|
||||
assert.Panics(t, func() {
|
||||
c.Display()
|
||||
})
|
||||
}
|
||||
|
||||
func TestCore_App_Good(t *testing.T) {
|
||||
app := &mockApp{}
|
||||
c, err := New(WithApp(app))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// To test the global App() function, we need to set the global instance.
|
||||
originalInstance := GetInstance()
|
||||
SetInstance(c)
|
||||
defer SetInstance(originalInstance)
|
||||
|
||||
assert.Equal(t, app, App())
|
||||
}
|
||||
|
||||
func TestCore_App_Ugly(t *testing.T) {
|
||||
// This test ensures that calling App() before the core is initialized panics.
|
||||
originalInstance := GetInstance()
|
||||
ClearInstance()
|
||||
defer SetInstance(originalInstance)
|
||||
assert.Panics(t, func() {
|
||||
App()
|
||||
})
|
||||
}
|
||||
|
||||
func TestCore_Core_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c, c.Core())
|
||||
}
|
||||
|
||||
func TestFeatures_IsEnabled_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
c.Features.Flags = []string{"feature1", "feature2"}
|
||||
|
||||
assert.True(t, c.Features.IsEnabled("feature1"))
|
||||
assert.True(t, c.Features.IsEnabled("feature2"))
|
||||
assert.False(t, c.Features.IsEnabled("feature3"))
|
||||
assert.False(t, c.Features.IsEnabled(""))
|
||||
}
|
||||
|
||||
func TestFeatures_IsEnabled_Edge(t *testing.T) {
|
||||
c, _ := New()
|
||||
c.Features.Flags = []string{" ", "foo"}
|
||||
assert.True(t, c.Features.IsEnabled(" "))
|
||||
assert.True(t, c.Features.IsEnabled("foo"))
|
||||
assert.False(t, c.Features.IsEnabled("FOO")) // Case sensitive check
|
||||
}
|
||||
|
||||
func TestCore_ServiceLifecycle_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
var messageReceived Message
|
||||
handler := func(c *Core, msg Message) error {
|
||||
messageReceived = msg
|
||||
return nil
|
||||
}
|
||||
c.RegisterAction(handler)
|
||||
|
||||
// Test Startup
|
||||
_ = c.ServiceStartup(context.TODO(), nil)
|
||||
_, ok := messageReceived.(ActionServiceStartup)
|
||||
assert.True(t, ok, "expected ActionServiceStartup message")
|
||||
|
||||
// Test Shutdown
|
||||
_ = c.ServiceShutdown(context.TODO())
|
||||
_, ok = messageReceived.(ActionServiceShutdown)
|
||||
assert.True(t, ok, "expected ActionServiceShutdown message")
|
||||
}
|
||||
|
||||
func TestCore_WithApp_Good(t *testing.T) {
|
||||
app := &mockApp{}
|
||||
c, err := New(WithApp(app))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, app, c.App)
|
||||
}
|
||||
|
||||
//go:embed testdata
|
||||
var testFS embed.FS
|
||||
|
||||
func TestCore_WithAssets_Good(t *testing.T) {
|
||||
c, err := New(WithAssets(testFS))
|
||||
assert.NoError(t, err)
|
||||
assets := c.Assets()
|
||||
file, err := assets.Open("testdata/test.txt")
|
||||
assert.NoError(t, err)
|
||||
defer func() { _ = file.Close() }()
|
||||
content, err := io.ReadAll(file)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello from testdata\n", string(content))
|
||||
}
|
||||
|
||||
func TestCore_WithServiceLock_Good(t *testing.T) {
|
||||
c, err := New(WithServiceLock())
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCore_RegisterService_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{Name: "test"})
|
||||
assert.NoError(t, err)
|
||||
svc := c.Service("test")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_RegisterService_Bad(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{})
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{})
|
||||
assert.Error(t, err)
|
||||
err = c.RegisterService("", &MockService{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCore_ServiceFor_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{Name: "test"})
|
||||
assert.NoError(t, err)
|
||||
svc, err := ServiceFor[*MockService](c, "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test", svc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_ServiceFor_Bad(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
_, err = ServiceFor[*MockService](c, "nonexistent")
|
||||
assert.Error(t, err)
|
||||
err = c.RegisterService("test", "not a service")
|
||||
assert.NoError(t, err)
|
||||
_, err = ServiceFor[*MockService](c, "test")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCore_MustServiceFor_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{Name: "test"})
|
||||
assert.NoError(t, err)
|
||||
svc := MustServiceFor[*MockService](c, "test")
|
||||
assert.Equal(t, "test", svc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_MustServiceFor_Ugly(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// MustServiceFor panics on missing service
|
||||
assert.Panics(t, func() {
|
||||
MustServiceFor[*MockService](c, "nonexistent")
|
||||
})
|
||||
|
||||
err = c.RegisterService("test", "not a service")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// MustServiceFor panics on type mismatch
|
||||
assert.Panics(t, func() {
|
||||
MustServiceFor[*MockService](c, "test")
|
||||
})
|
||||
}
|
||||
|
||||
type MockAction struct {
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (a *MockAction) Handle(c *Core, msg Message) error {
|
||||
a.handled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_ACTION_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
action := &MockAction{}
|
||||
c.RegisterAction(action.Handle)
|
||||
err = c.ACTION(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, action.handled)
|
||||
}
|
||||
|
||||
func TestCore_RegisterActions_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
action1 := &MockAction{}
|
||||
action2 := &MockAction{}
|
||||
c.RegisterActions(action1.Handle, action2.Handle)
|
||||
err = c.ACTION(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, action1.handled)
|
||||
assert.True(t, action2.handled)
|
||||
}
|
||||
|
||||
func TestCore_WithName_Good(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
}
|
||||
c, err := New(WithName("my-service", factory))
|
||||
assert.NoError(t, err)
|
||||
svc := c.Service("my-service")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_WithName_Bad(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err := New(WithName("my-service", factory))
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestCore_GlobalInstance_ThreadSafety_Good(t *testing.T) {
|
||||
// Save original instance
|
||||
original := GetInstance()
|
||||
defer SetInstance(original)
|
||||
|
||||
// Test SetInstance/GetInstance
|
||||
c1, _ := New()
|
||||
SetInstance(c1)
|
||||
assert.Equal(t, c1, GetInstance())
|
||||
|
||||
// Test ClearInstance
|
||||
ClearInstance()
|
||||
assert.Nil(t, GetInstance())
|
||||
|
||||
// Test concurrent access (race detector should catch issues)
|
||||
c2, _ := New(WithApp(&mockApp{}))
|
||||
done := make(chan bool)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
SetInstance(c2)
|
||||
_ = GetInstance()
|
||||
done <- true
|
||||
}()
|
||||
go func() {
|
||||
inst := GetInstance()
|
||||
if inst != nil {
|
||||
_ = inst.App
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 20; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// Package core re-exports the structured error types from go-log.
|
||||
//
|
||||
// All error construction in the framework MUST use E() (or Wrap, WrapCode, etc.)
|
||||
// rather than fmt.Errorf. This ensures every error carries an operation context
|
||||
// for structured logging and tracing.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// return core.E("config.Load", "failed to load config file", err)
|
||||
package core
|
||||
|
||||
import (
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
// Error is the structured error type from go-log.
|
||||
// It carries Op (operation), Msg (human-readable), Err (underlying), and Code fields.
|
||||
type Error = coreerr.Err
|
||||
|
||||
// E creates a new structured error with operation context.
|
||||
// This is the primary way to create errors in the Core framework.
|
||||
//
|
||||
// The 'op' parameter should be in the format of 'package.function' or 'service.method'.
|
||||
// The 'msg' parameter should be a human-readable message.
|
||||
// The 'err' parameter is the underlying error (may be nil).
|
||||
var E = coreerr.E
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestE_Good(t *testing.T) {
|
||||
err := E("test.op", "test message", assert.AnError)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "test.op: test message: assert.AnError general error for testing", err.Error())
|
||||
|
||||
err = E("test.op", "test message", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "test.op: test message", err.Error())
|
||||
}
|
||||
|
||||
func TestE_Unwrap(t *testing.T) {
|
||||
originalErr := errors.New("original error")
|
||||
err := E("test.op", "test message", originalErr)
|
||||
|
||||
assert.True(t, errors.Is(err, originalErr))
|
||||
|
||||
var eErr *Error
|
||||
assert.True(t, errors.As(err, &eErr))
|
||||
assert.Equal(t, "test.op", eErr.Op)
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzE exercises the E() error constructor with arbitrary input.
|
||||
func FuzzE(f *testing.F) {
|
||||
f.Add("svc.Method", "something broke", true)
|
||||
f.Add("", "", false)
|
||||
f.Add("a.b.c.d.e.f", "unicode: \u00e9\u00e8\u00ea", true)
|
||||
|
||||
f.Fuzz(func(t *testing.T, op, msg string, withErr bool) {
|
||||
var underlying error
|
||||
if withErr {
|
||||
underlying = errors.New("wrapped")
|
||||
}
|
||||
|
||||
e := E(op, msg, underlying)
|
||||
if e == nil {
|
||||
t.Fatal("E() returned nil")
|
||||
}
|
||||
|
||||
s := e.Error()
|
||||
if s == "" && (op != "" || msg != "") {
|
||||
t.Fatal("Error() returned empty string for non-empty op/msg")
|
||||
}
|
||||
|
||||
// Round-trip: Unwrap should return the underlying error
|
||||
var coreErr *Error
|
||||
if !errors.As(e, &coreErr) {
|
||||
t.Fatal("errors.As failed for *Error")
|
||||
}
|
||||
if withErr && coreErr.Unwrap() == nil {
|
||||
t.Fatal("Unwrap() returned nil with underlying error")
|
||||
}
|
||||
if !withErr && coreErr.Unwrap() != nil {
|
||||
t.Fatal("Unwrap() returned non-nil without underlying error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzServiceRegistration exercises service name registration with arbitrary names.
|
||||
func FuzzServiceRegistration(f *testing.F) {
|
||||
f.Add("myservice")
|
||||
f.Add("")
|
||||
f.Add("a/b/c")
|
||||
f.Add("service with spaces")
|
||||
f.Add("service\x00null")
|
||||
|
||||
f.Fuzz(func(t *testing.T, name string) {
|
||||
sm := newServiceManager()
|
||||
|
||||
err := sm.registerService(name, struct{}{})
|
||||
if name == "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for name %q: %v", name, err)
|
||||
}
|
||||
|
||||
// Retrieve should return the same service
|
||||
got := sm.service(name)
|
||||
if got == nil {
|
||||
t.Fatalf("service %q not found after registration", name)
|
||||
}
|
||||
|
||||
// Duplicate registration should fail
|
||||
err = sm.registerService(name, struct{}{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected duplicate error for name %q", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzMessageDispatch exercises action dispatch with concurrent registrations.
|
||||
func FuzzMessageDispatch(f *testing.F) {
|
||||
f.Add("hello")
|
||||
f.Add("")
|
||||
f.Add("test\nmultiline")
|
||||
|
||||
f.Fuzz(func(t *testing.T, payload string) {
|
||||
c := &Core{
|
||||
Features: &Features{},
|
||||
svc: newServiceManager(),
|
||||
}
|
||||
c.bus = newMessageBus(c)
|
||||
|
||||
var received string
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error {
|
||||
received = msg.(string)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := c.bus.action(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("action dispatch failed: %v", err)
|
||||
}
|
||||
if received != payload {
|
||||
t.Fatalf("got %q, want %q", received, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
goio "io"
|
||||
"io/fs"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// This file defines the public API contracts (interfaces) for the services
|
||||
// in the Core framework. Services depend on these interfaces, not on
|
||||
// concrete implementations.
|
||||
|
||||
// Contract specifies the operational guarantees that the Core and its services must adhere to.
|
||||
// This is used for configuring panic handling and other resilience features.
|
||||
type Contract struct {
|
||||
// DontPanic, if true, instructs the Core to recover from panics and return an error instead.
|
||||
DontPanic bool
|
||||
// DisableLogging, if true, disables all logging from the Core and its services.
|
||||
DisableLogging bool
|
||||
}
|
||||
|
||||
// Features provides a way to check if a feature is enabled.
|
||||
// This is used for feature flagging and conditional logic.
|
||||
type Features struct {
|
||||
// Flags is a list of enabled feature flags.
|
||||
Flags []string
|
||||
}
|
||||
|
||||
// IsEnabled returns true if the given feature is enabled.
|
||||
func (f *Features) IsEnabled(feature string) bool {
|
||||
return slices.Contains(f.Flags, feature)
|
||||
}
|
||||
|
||||
// Option is a function that configures the Core.
|
||||
// This is used to apply settings and register services during initialization.
|
||||
type Option func(*Core) error
|
||||
|
||||
// Message is the interface for all messages that can be sent through the Core's IPC system.
|
||||
// Any struct can be a message, allowing for structured data to be passed between services.
|
||||
// Used with ACTION for fire-and-forget broadcasts.
|
||||
type Message any
|
||||
|
||||
// Query is the interface for read-only requests that return data.
|
||||
// Used with QUERY (first responder) or QUERYALL (all responders).
|
||||
type Query any
|
||||
|
||||
// Task is the interface for requests that perform side effects.
|
||||
// Used with PERFORM (first responder executes).
|
||||
type Task any
|
||||
|
||||
// TaskWithID is an optional interface for tasks that need to know their assigned ID.
|
||||
// This is useful for tasks that want to report progress back to the frontend.
|
||||
type TaskWithID interface {
|
||||
Task
|
||||
SetTaskID(id string)
|
||||
GetTaskID() string
|
||||
}
|
||||
|
||||
// QueryHandler handles Query requests. Returns (result, handled, error).
|
||||
// If handled is false, the query will be passed to the next handler.
|
||||
type QueryHandler func(*Core, Query) (any, bool, error)
|
||||
|
||||
// TaskHandler handles Task requests. Returns (result, handled, error).
|
||||
// If handled is false, the task will be passed to the next handler.
|
||||
type TaskHandler func(*Core, Task) (any, bool, error)
|
||||
|
||||
// Startable is an interface for services that need to perform initialization.
|
||||
type Startable interface {
|
||||
OnStartup(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Stoppable is an interface for services that need to perform cleanup.
|
||||
type Stoppable interface {
|
||||
OnShutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
// LocaleProvider is implemented by services that ship their own translation files.
|
||||
// Core discovers this interface during service registration and collects the
|
||||
// locale filesystems. The i18n service loads them during startup.
|
||||
//
|
||||
// Usage in a service package:
|
||||
//
|
||||
// //go:embed locales
|
||||
// var localeFS embed.FS
|
||||
//
|
||||
// func (s *MyService) Locales() fs.FS { return localeFS }
|
||||
type LocaleProvider interface {
|
||||
Locales() fs.FS
|
||||
}
|
||||
|
||||
// Core is the central application object that manages services, assets, and communication.
|
||||
type Core struct {
|
||||
App any // GUI runtime (e.g., Wails App) - set by WithApp option
|
||||
assets embed.FS
|
||||
Features *Features
|
||||
svc *serviceManager
|
||||
bus *messageBus
|
||||
locales []fs.FS // collected from LocaleProvider services
|
||||
|
||||
taskIDCounter atomic.Uint64
|
||||
wg sync.WaitGroup
|
||||
shutdown atomic.Bool
|
||||
}
|
||||
|
||||
// Locales returns all locale filesystems collected from registered services.
|
||||
// The i18n service uses this during startup to load translations.
|
||||
func (c *Core) Locales() []fs.FS {
|
||||
return c.locales
|
||||
}
|
||||
|
||||
// Config provides access to application configuration.
|
||||
type Config interface {
|
||||
// Get retrieves a configuration value by key and stores it in the 'out' variable.
|
||||
Get(key string, out any) error
|
||||
// Set stores a configuration value by key.
|
||||
Set(key string, v any) error
|
||||
}
|
||||
|
||||
// WindowOption is an interface for applying configuration options to a window.
|
||||
type WindowOption interface {
|
||||
Apply(any)
|
||||
}
|
||||
|
||||
// Display provides access to windowing and visual elements.
|
||||
type Display interface {
|
||||
// OpenWindow creates a new window with the given options.
|
||||
OpenWindow(opts ...WindowOption) error
|
||||
}
|
||||
|
||||
// Workspace provides management for encrypted user workspaces.
|
||||
type Workspace interface {
|
||||
// CreateWorkspace creates a new encrypted workspace.
|
||||
CreateWorkspace(identifier, password string) (string, error)
|
||||
// SwitchWorkspace changes the active workspace.
|
||||
SwitchWorkspace(name string) error
|
||||
// WorkspaceFileGet retrieves the content of a file from the active workspace.
|
||||
WorkspaceFileGet(filename string) (string, error)
|
||||
// WorkspaceFileSet saves content to a file in the active workspace.
|
||||
WorkspaceFileSet(filename, content string) error
|
||||
}
|
||||
|
||||
// Crypt provides PGP-based encryption, signing, and key management.
|
||||
type Crypt interface {
|
||||
// CreateKeyPair generates a new PGP keypair.
|
||||
CreateKeyPair(name, passphrase string) (string, error)
|
||||
// EncryptPGP encrypts data for a recipient.
|
||||
EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error)
|
||||
// DecryptPGP decrypts a PGP message.
|
||||
DecryptPGP(recipientPath, message, passphrase string, opts ...any) (string, error)
|
||||
}
|
||||
|
||||
// ActionServiceStartup is a message sent when the application's services are starting up.
|
||||
// This provides a hook for services to perform initialization tasks.
|
||||
type ActionServiceStartup struct{}
|
||||
|
||||
// ActionServiceShutdown is a message sent when the application is shutting down.
|
||||
// This allows services to perform cleanup tasks, such as saving state or closing resources.
|
||||
type ActionServiceShutdown struct{}
|
||||
|
||||
// ActionTaskStarted is a message sent when a background task has started.
|
||||
type ActionTaskStarted struct {
|
||||
TaskID string
|
||||
Task Task
|
||||
}
|
||||
|
||||
// ActionTaskProgress is a message sent when a task has progress updates.
|
||||
type ActionTaskProgress struct {
|
||||
TaskID string
|
||||
Task Task
|
||||
Progress float64 // 0.0 to 1.0
|
||||
Message string
|
||||
}
|
||||
|
||||
// ActionTaskCompleted is a message sent when a task has completed.
|
||||
type ActionTaskCompleted struct {
|
||||
TaskID string
|
||||
Task Task
|
||||
Result any
|
||||
Error error
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type IPCTestQuery struct{ Value string }
|
||||
type IPCTestTask struct{ Value string }
|
||||
|
||||
func TestIPC_Query(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
// No handler
|
||||
res, handled, err := c.QUERY(IPCTestQuery{})
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, res)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// With handler
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
if tq, ok := q.(IPCTestQuery); ok {
|
||||
return tq.Value + "-response", true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
res, handled, err = c.QUERY(IPCTestQuery{Value: "test"})
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "test-response", res)
|
||||
}
|
||||
|
||||
func TestIPC_QueryAll(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "h1", true, nil
|
||||
})
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "h2", true, nil
|
||||
})
|
||||
|
||||
results, err := c.QUERYALL(IPCTestQuery{})
|
||||
assert.Nil(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.Contains(t, results, "h1")
|
||||
assert.Contains(t, results, "h2")
|
||||
}
|
||||
|
||||
func TestIPC_Perform(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
|
||||
if tt, ok := task.(IPCTestTask); ok {
|
||||
if tt.Value == "error" {
|
||||
return nil, true, errors.New("task error")
|
||||
}
|
||||
return "done", true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
// Success
|
||||
res, handled, err := c.PERFORM(IPCTestTask{Value: "run"})
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "done", res)
|
||||
|
||||
// Error
|
||||
res, handled, err = c.PERFORM(IPCTestTask{Value: "error"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
||||
func TestIPC_PerformAsync(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
type AsyncResult struct {
|
||||
TaskID string
|
||||
Result any
|
||||
Error error
|
||||
}
|
||||
done := make(chan AsyncResult, 1)
|
||||
|
||||
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
|
||||
if tt, ok := task.(IPCTestTask); ok {
|
||||
return tt.Value + "-done", true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if m, ok := msg.(ActionTaskCompleted); ok {
|
||||
done <- AsyncResult{
|
||||
TaskID: m.TaskID,
|
||||
Result: m.Result,
|
||||
Error: m.Error,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
taskID := c.PerformAsync(IPCTestTask{Value: "async"})
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
assert.Equal(t, taskID, res.TaskID)
|
||||
assert.Equal(t, "async-done", res.Result)
|
||||
assert.Nil(t, res.Error)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for task completion")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// messageBus owns the IPC action, query, and task dispatch.
|
||||
// It is an unexported component used internally by Core.
|
||||
type messageBus struct {
|
||||
core *Core
|
||||
|
||||
ipcMu sync.RWMutex
|
||||
ipcHandlers []func(*Core, Message) error
|
||||
|
||||
queryMu sync.RWMutex
|
||||
queryHandlers []QueryHandler
|
||||
|
||||
taskMu sync.RWMutex
|
||||
taskHandlers []TaskHandler
|
||||
}
|
||||
|
||||
// newMessageBus creates an empty message bus bound to the given Core.
|
||||
func newMessageBus(c *Core) *messageBus {
|
||||
return &messageBus{core: c}
|
||||
}
|
||||
|
||||
// action dispatches a message to all registered IPC handlers.
|
||||
func (b *messageBus) action(msg Message) error {
|
||||
b.ipcMu.RLock()
|
||||
handlers := slices.Clone(b.ipcHandlers)
|
||||
b.ipcMu.RUnlock()
|
||||
|
||||
var agg error
|
||||
for _, h := range handlers {
|
||||
if err := h(b.core, msg); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// registerAction adds a single IPC handler.
|
||||
func (b *messageBus) registerAction(handler func(*Core, Message) error) {
|
||||
b.ipcMu.Lock()
|
||||
b.ipcHandlers = append(b.ipcHandlers, handler)
|
||||
b.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// registerActions adds multiple IPC handlers.
|
||||
func (b *messageBus) registerActions(handlers ...func(*Core, Message) error) {
|
||||
b.ipcMu.Lock()
|
||||
b.ipcHandlers = append(b.ipcHandlers, handlers...)
|
||||
b.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// query dispatches a query to handlers until one responds.
|
||||
func (b *messageBus) query(q Query) (any, bool, error) {
|
||||
b.queryMu.RLock()
|
||||
handlers := slices.Clone(b.queryHandlers)
|
||||
b.queryMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(b.core, q)
|
||||
if handled {
|
||||
return result, true, err
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// queryAll dispatches a query to all handlers and collects all responses.
|
||||
func (b *messageBus) queryAll(q Query) ([]any, error) {
|
||||
b.queryMu.RLock()
|
||||
handlers := slices.Clone(b.queryHandlers)
|
||||
b.queryMu.RUnlock()
|
||||
|
||||
var results []any
|
||||
var agg error
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(b.core, q)
|
||||
if err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
if handled && result != nil {
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
return results, agg
|
||||
}
|
||||
|
||||
// registerQuery adds a query handler.
|
||||
func (b *messageBus) registerQuery(handler QueryHandler) {
|
||||
b.queryMu.Lock()
|
||||
b.queryHandlers = append(b.queryHandlers, handler)
|
||||
b.queryMu.Unlock()
|
||||
}
|
||||
|
||||
// perform dispatches a task to handlers until one executes it.
|
||||
func (b *messageBus) perform(t Task) (any, bool, error) {
|
||||
b.taskMu.RLock()
|
||||
handlers := slices.Clone(b.taskHandlers)
|
||||
b.taskMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(b.core, t)
|
||||
if handled {
|
||||
return result, true, err
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// registerTask adds a task handler.
|
||||
func (b *messageBus) registerTask(handler TaskHandler) {
|
||||
b.taskMu.Lock()
|
||||
b.taskHandlers = append(b.taskHandlers, handler)
|
||||
b.taskMu.Unlock()
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMessageBus_Action_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var received []Message
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error {
|
||||
received = append(received, msg)
|
||||
return nil
|
||||
})
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error {
|
||||
received = append(received, msg)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := c.bus.action("hello")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, received, 2)
|
||||
}
|
||||
|
||||
func TestMessageBus_Action_Bad(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
err1 := errors.New("handler1 failed")
|
||||
err2 := errors.New("handler2 failed")
|
||||
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return err1 })
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return nil })
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return err2 })
|
||||
|
||||
err := c.bus.action("test")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, err1)
|
||||
assert.ErrorIs(t, err, err2)
|
||||
}
|
||||
|
||||
func TestMessageBus_RegisterAction_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var coreRef *Core
|
||||
c.bus.registerAction(func(core *Core, msg Message) error {
|
||||
coreRef = core
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = c.bus.action(nil)
|
||||
assert.Same(t, c, coreRef, "handler should receive the Core reference")
|
||||
}
|
||||
|
||||
func TestMessageBus_Query_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.bus.query(TestQuery{Value: "test"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "first", result)
|
||||
}
|
||||
|
||||
func TestMessageBus_QueryAll_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return "a", true, nil
|
||||
})
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return nil, false, nil // skips
|
||||
})
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return "b", true, nil
|
||||
})
|
||||
|
||||
results, err := c.bus.queryAll(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []any{"a", "b"}, results)
|
||||
}
|
||||
|
||||
func TestMessageBus_Perform_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) {
|
||||
return "done", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.bus.perform(TestTask{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "done", result)
|
||||
}
|
||||
|
||||
func TestMessageBus_ConcurrentAccess_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
|
||||
// Concurrent register + dispatch
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return nil })
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = c.bus.action("ping")
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil })
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = c.bus.queryAll(TestQuery{})
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil })
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _, _ = c.bus.perform(TestTask{})
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestMessageBus_Action_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
// Should not error if no handlers are registered
|
||||
err := c.bus.action("no one listening")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMessageBus_Query_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
result, handled, err := c.bus.query(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestMessageBus_QueryAll_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
results, err := c.bus.queryAll(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestMessageBus_Perform_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
result, handled, err := c.bus.perform(TestTask{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type TestQuery struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
type TestTask struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func TestCore_QUERY_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Register a handler that responds to TestQuery
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
if tq, ok := q.(TestQuery); ok {
|
||||
return "result-" + tq.Value, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(TestQuery{Value: "test"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "result-test", result)
|
||||
}
|
||||
|
||||
func TestCore_QUERY_NotHandled(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// No handlers registered
|
||||
result, handled, err := c.QUERY(TestQuery{Value: "test"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCore_QUERY_FirstResponder(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// First handler responds
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
// Second handler would respond but shouldn't be called
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "first", result)
|
||||
}
|
||||
|
||||
func TestCore_QUERY_SkipsNonHandlers(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// First handler doesn't handle
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
// Second handler responds
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "second", result)
|
||||
}
|
||||
|
||||
func TestCore_QUERYALL_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Multiple handlers respond
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return nil, false, nil // Doesn't handle
|
||||
})
|
||||
|
||||
results, err := c.QUERYALL(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.Contains(t, results, "first")
|
||||
assert.Contains(t, results, "second")
|
||||
}
|
||||
|
||||
func TestCore_QUERYALL_AggregatesErrors(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
err1 := errors.New("error1")
|
||||
err2 := errors.New("error2")
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "result1", true, err1
|
||||
})
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "result2", true, err2
|
||||
})
|
||||
|
||||
results, err := c.QUERYALL(TestQuery{})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, err1)
|
||||
assert.ErrorIs(t, err, err2)
|
||||
assert.Len(t, results, 2)
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
executed := false
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
if tt, ok := t.(TestTask); ok {
|
||||
executed = true
|
||||
return "done-" + tt.Value, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(TestTask{Value: "work"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, executed)
|
||||
assert.Equal(t, "done-work", result)
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_NotHandled(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// No handlers registered
|
||||
result, handled, err := c.PERFORM(TestTask{Value: "work"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_FirstResponder(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
callCount := 0
|
||||
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
callCount++
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
callCount++
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(TestTask{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "first", result)
|
||||
assert.Equal(t, 1, callCount) // Only first handler called
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_WithError(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectedErr := errors.New("task failed")
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
return nil, true, expectedErr
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(TestTask{})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ServiceRuntime is a helper struct embedded in services to provide access to the core application.
|
||||
// It is generic and can be parameterized with a service-specific options struct.
|
||||
type ServiceRuntime[T any] struct {
|
||||
core *Core
|
||||
opts T
|
||||
}
|
||||
|
||||
// NewServiceRuntime creates a new ServiceRuntime instance for a service.
|
||||
// This is typically called by a service's constructor.
|
||||
func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
|
||||
return &ServiceRuntime[T]{
|
||||
core: c,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// Core returns the central core instance, providing access to all registered services.
|
||||
func (r *ServiceRuntime[T]) Core() *Core {
|
||||
return r.core
|
||||
}
|
||||
|
||||
// Opts returns the service-specific options.
|
||||
func (r *ServiceRuntime[T]) Opts() T {
|
||||
return r.opts
|
||||
}
|
||||
|
||||
// Config returns the registered Config service from the core application.
|
||||
// This is a convenience method for accessing the application's configuration.
|
||||
func (r *ServiceRuntime[T]) Config() Config {
|
||||
return r.core.Config()
|
||||
}
|
||||
|
||||
// Runtime is the container that holds all instantiated services.
|
||||
// Its fields are the concrete types, allowing GUI runtimes to bind them directly.
|
||||
// This struct is the primary entry point for the application.
|
||||
type Runtime struct {
|
||||
app any // GUI runtime (e.g., Wails App)
|
||||
Core *Core
|
||||
}
|
||||
|
||||
// ServiceFactory defines a function that creates a service instance.
|
||||
// This is used to decouple the service creation from the runtime initialization.
|
||||
type ServiceFactory func() (any, error)
|
||||
|
||||
// NewWithFactories creates a new Runtime instance using the provided service factories.
|
||||
// This is the most flexible way to create a new Runtime, as it allows for
|
||||
// the registration of any number of services.
|
||||
func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, error) {
|
||||
coreOpts := []Option{
|
||||
WithApp(app),
|
||||
}
|
||||
|
||||
names := slices.Sorted(maps.Keys(factories))
|
||||
|
||||
for _, name := range names {
|
||||
factory := factories[name]
|
||||
if factory == nil {
|
||||
return nil, E("core.NewWithFactories", fmt.Sprintf("factory is nil for service %q", name), nil)
|
||||
}
|
||||
svc, err := factory()
|
||||
if err != nil {
|
||||
return nil, E("core.NewWithFactories", fmt.Sprintf("failed to create service %q", name), err)
|
||||
}
|
||||
svcCopy := svc
|
||||
coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil }))
|
||||
}
|
||||
|
||||
coreInstance, err := New(coreOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Runtime{
|
||||
app: app,
|
||||
Core: coreInstance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewRuntime creates and wires together all application services.
|
||||
// This is the simplest way to create a new Runtime, but it does not allow for
|
||||
// the registration of any custom services.
|
||||
func NewRuntime(app any) (*Runtime, error) {
|
||||
return NewWithFactories(app, map[string]ServiceFactory{})
|
||||
}
|
||||
|
||||
// ServiceName returns the name of the service. This is used by GUI runtimes to identify the service.
|
||||
func (r *Runtime) ServiceName() string {
|
||||
return "Core"
|
||||
}
|
||||
|
||||
// ServiceStartup is called by the GUI runtime at application startup.
|
||||
// This is where the Core's startup lifecycle is initiated.
|
||||
func (r *Runtime) ServiceStartup(ctx context.Context, options any) error {
|
||||
return r.Core.ServiceStartup(ctx, options)
|
||||
}
|
||||
|
||||
// ServiceShutdown is called by the GUI runtime at application shutdown.
|
||||
// This is where the Core's shutdown lifecycle is initiated.
|
||||
func (r *Runtime) ServiceShutdown(ctx context.Context) error {
|
||||
if r.Core != nil {
|
||||
return r.Core.ServiceShutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewWithFactories_EmptyName(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"": func() (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
},
|
||||
}
|
||||
_, err := NewWithFactories(nil, factories)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "service name cannot be empty")
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewRuntime(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
app any
|
||||
factories map[string]ServiceFactory
|
||||
expectErr bool
|
||||
expectErrStr string
|
||||
checkRuntime func(*testing.T, *Runtime)
|
||||
}{
|
||||
{
|
||||
name: "Good path",
|
||||
app: nil,
|
||||
factories: map[string]ServiceFactory{},
|
||||
expectErr: false,
|
||||
checkRuntime: func(t *testing.T, rt *Runtime) {
|
||||
assert.NotNil(t, rt)
|
||||
assert.NotNil(t, rt.Core)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With non-nil app",
|
||||
app: &mockApp{},
|
||||
factories: map[string]ServiceFactory{},
|
||||
expectErr: false,
|
||||
checkRuntime: func(t *testing.T, rt *Runtime) {
|
||||
assert.NotNil(t, rt)
|
||||
assert.NotNil(t, rt.Core)
|
||||
assert.NotNil(t, rt.Core.App)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt, err := NewRuntime(tc.app)
|
||||
|
||||
if tc.expectErr {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.expectErrStr)
|
||||
assert.Nil(t, rt)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tc.checkRuntime != nil {
|
||||
tc.checkRuntime(t, rt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithFactories_Good(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"test": func() (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
},
|
||||
}
|
||||
rt, err := NewWithFactories(nil, factories)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, rt)
|
||||
svc := rt.Core.Service("test")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.Name)
|
||||
}
|
||||
|
||||
func TestNewWithFactories_Bad(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"test": func() (any, error) {
|
||||
return nil, assert.AnError
|
||||
},
|
||||
}
|
||||
_, err := NewWithFactories(nil, factories)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestNewWithFactories_Ugly(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"test": nil,
|
||||
}
|
||||
_, err := NewWithFactories(nil, factories)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "factory is nil")
|
||||
}
|
||||
|
||||
func TestRuntime_Lifecycle_Good(t *testing.T) {
|
||||
rt, err := NewRuntime(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, rt)
|
||||
|
||||
// ServiceName
|
||||
assert.Equal(t, "Core", rt.ServiceName())
|
||||
|
||||
// ServiceStartup & ServiceShutdown
|
||||
// These are simple wrappers around the core methods, which are tested in core_test.go.
|
||||
// We call them here to ensure coverage.
|
||||
rt.ServiceStartup(context.TODO(), nil)
|
||||
rt.ServiceShutdown(context.TODO())
|
||||
|
||||
// Test shutdown with nil core
|
||||
rt.Core = nil
|
||||
rt.ServiceShutdown(context.TODO())
|
||||
}
|
||||
|
||||
func TestNewServiceRuntime_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
sr := NewServiceRuntime(c, "test options")
|
||||
assert.NotNil(t, sr)
|
||||
assert.Equal(t, c, sr.Core())
|
||||
|
||||
// We can't directly test sr.Config() without a registered config service,
|
||||
// as it will panic.
|
||||
assert.Panics(t, func() {
|
||||
sr.Config()
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue