Merge pull request #1 from dAppCore/dev

feat: CoreGO v2 — unified struct, DTO pattern, zero constructors
This commit is contained in:
Snider 2026-03-18 13:35:51 +00:00 committed by GitHub
commit 3ee58576a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 4714 additions and 1808 deletions

5
.claude/settings.json Normal file
View file

@ -0,0 +1,5 @@
{
"enabledPlugins": {
}
}

9
.mcp.json Normal file
View file

@ -0,0 +1,9 @@
{
"mcpServers": {
"core": {
"type": "stdio",
"command": "core-agent",
"args": ["mcp"]
}
}
}

View file

@ -2,6 +2,10 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Session Context
Running on **Claude Max20 plan** with **1M context window** (Opus 4.6). This enables marathon sessions — use the full context for complex multi-repo work, dispatch coordination, and ecosystem-wide operations. Compact when needed, but don't be afraid of long sessions.
## Project Overview
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.
@ -68,7 +72,7 @@ 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.go`)
### ServiceRuntime Generic Helper (`runtime_pkg.go`)
Embed `ServiceRuntime[T]` in services to get access to Core and typed options:
```go
@ -77,11 +81,12 @@ type MyService struct {
}
```
### Error Handling (`e.go`)
### Error Handling (go-log)
Use the `E()` helper for contextual 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
@ -100,6 +105,6 @@ Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern:
## Go Workspace
Uses Go 1.25 workspaces. This module is part of the workspace at `~/Code/go.work`.
Uses Go 1.26 workspaces. This module is part of the workspace at `~/Code/go.work`.
After adding modules: `go work sync`

9
go.mod
View file

@ -2,14 +2,13 @@ module forge.lthn.ai/core/go
go 1.26.0
require (
forge.lthn.ai/core/go-io v0.0.5
forge.lthn.ai/core/go-log v0.0.1
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
View file

@ -1,15 +1,17 @@
forge.lthn.ai/core/go-io v0.0.5 h1:oSyngKTkB1gR5fEWYKXftTg9FxwnpddSiCq2dlwfImE=
forge.lthn.ai/core/go-io v0.0.5/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0=
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
forge.lthn.ai/core/go-log v0.0.1/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=

52
pkg/core/app.go Normal file
View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: EUPL-1.2
// Application identity for the Core framework.
// Based on leaanthony/sail — Name, Filename, Path.
package core
import (
"os/exec"
"path/filepath"
)
// App holds the application identity and optional GUI runtime.
type App struct {
// Name is the human-readable application name (e.g., "Core CLI").
Name string
// Version is the application version string (e.g., "1.2.3").
Version string
// Description is a short description of the application.
Description string
// Filename is the executable filename (e.g., "core").
Filename string
// Path is the absolute path to the executable.
Path string
// Runtime is the GUI runtime (e.g., Wails App).
// Nil for CLI-only applications.
Runtime any
}
// Find locates a program on PATH and returns a App for it.
// Returns nil if not found.
func Find(filename, name string) *App {
path, err := exec.LookPath(filename)
if err != nil {
return nil
}
abs, err := filepath.Abs(path)
if err != nil {
return nil
}
return &App{
Name: name,
Filename: filename,
Path: abs,
}
}

96
pkg/core/array.go Normal file
View file

@ -0,0 +1,96 @@
// 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) *Array[T] {
result := &Array[T]{}
for _, v := range s.items {
if fn(v) {
result.items = append(result.items, v)
}
}
return result
}
// 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 the underlying slice.
func (s *Array[T]) AsSlice() []T {
return s.items
}

200
pkg/core/cli.go Normal file
View file

@ -0,0 +1,200 @@
// SPDX-License-Identifier: EUPL-1.2
// CLI command framework for the Core framework.
// Based on leaanthony/clir — zero-dependency command line interface.
package core
import (
"fmt"
"os"
)
// CliAction represents a function called when a command is invoked.
type CliAction func() error
// CliOpts configures a Cli.
type CliOpts struct {
Version string
Name string
Description string
}
// Cli is the CLI command framework.
type Cli struct {
opts *CliOpts
rootCommand *Command
defaultCommand *Command
preRunCommand func(*Cli) error
postRunCommand func(*Cli) error
bannerFunction func(*Cli) string
errorHandler func(string, error) error
}
// defaultBannerFunction prints a banner for the application.
func defaultBannerFunction(c *Cli) string {
version := ""
if c.opts != nil && c.opts.Version != "" {
version = " " + c.opts.Version
}
name := ""
description := ""
if c.opts != nil {
name = c.opts.Name
description = c.opts.Description
}
if description != "" {
return fmt.Sprintf("%s%s - %s", name, version, description)
}
return fmt.Sprintf("%s%s", name, version)
}
// Command returns the root command.
func (c *Cli) Command() *Command {
return c.rootCommand
}
// Version returns the application version string.
func (c *Cli) Version() string {
if c.opts != nil {
return c.opts.Version
}
return ""
}
// Name returns the application name.
func (c *Cli) Name() string {
if c.opts != nil {
return c.opts.Name
}
return c.rootCommand.name
}
// ShortDescription returns the application short description.
func (c *Cli) ShortDescription() string {
if c.opts != nil {
return c.opts.Description
}
return c.rootCommand.shortdescription
}
// SetBannerFunction sets the function that generates the banner string.
func (c *Cli) SetBannerFunction(fn func(*Cli) string) {
c.bannerFunction = fn
}
// SetErrorFunction sets a custom error handler for undefined flags.
func (c *Cli) SetErrorFunction(fn func(string, error) error) {
c.errorHandler = fn
}
// AddCommand adds a command to the application.
func (c *Cli) AddCommand(command *Command) {
c.rootCommand.AddCommand(command)
}
// PrintBanner prints the application banner.
func (c *Cli) PrintBanner() {
fmt.Println(c.bannerFunction(c))
fmt.Println("")
}
// PrintHelp prints the application help.
func (c *Cli) PrintHelp() {
c.rootCommand.PrintHelp()
}
// Run runs the application with the given arguments.
func (c *Cli) Run(args ...string) error {
if c.preRunCommand != nil {
if err := c.preRunCommand(c); err != nil {
return err
}
}
if len(args) == 0 {
args = os.Args[1:]
}
if err := c.rootCommand.run(args); err != nil {
return err
}
if c.postRunCommand != nil {
if err := c.postRunCommand(c); err != nil {
return err
}
}
return nil
}
// DefaultCommand sets the command to run when no other commands are given.
func (c *Cli) DefaultCommand(defaultCommand *Command) *Cli {
c.defaultCommand = defaultCommand
return c
}
// NewChildCommand creates a new subcommand.
func (c *Cli) NewChildCommand(name string, description ...string) *Command {
return c.rootCommand.NewChildCommand(name, description...)
}
// NewChildCommandInheritFlags creates a new subcommand that inherits parent flags.
func (c *Cli) NewChildCommandInheritFlags(name string, description ...string) *Command {
return c.rootCommand.NewChildCommandInheritFlags(name, description...)
}
// PreRun sets a function to call before running the command.
func (c *Cli) PreRun(callback func(*Cli) error) {
c.preRunCommand = callback
}
// PostRun sets a function to call after running the command.
func (c *Cli) PostRun(callback func(*Cli) error) {
c.postRunCommand = callback
}
// BoolFlag adds a boolean flag to the root command.
func (c *Cli) BoolFlag(name, description string, variable *bool) *Cli {
c.rootCommand.BoolFlag(name, description, variable)
return c
}
// StringFlag adds a string flag to the root command.
func (c *Cli) StringFlag(name, description string, variable *string) *Cli {
c.rootCommand.StringFlag(name, description, variable)
return c
}
// IntFlag adds an int flag to the root command.
func (c *Cli) IntFlag(name, description string, variable *int) *Cli {
c.rootCommand.IntFlag(name, description, variable)
return c
}
// AddFlags adds struct-tagged flags to the root command.
func (c *Cli) AddFlags(flags any) *Cli {
c.rootCommand.AddFlags(flags)
return c
}
// Action defines an action for the root command.
func (c *Cli) Action(callback CliAction) *Cli {
c.rootCommand.Action(callback)
return c
}
// LongDescription sets the long description for the root command.
func (c *Cli) LongDescription(longdescription string) *Cli {
c.rootCommand.LongDescription(longdescription)
return c
}
// OtherArgs returns the non-flag arguments passed to the CLI.
func (c *Cli) OtherArgs() []string {
return c.rootCommand.flags.Args()
}
// NewChildCommandFunction creates a subcommand from a function with struct flags.
func (c *Cli) NewChildCommandFunction(name string, description string, fn any) *Cli {
c.rootCommand.NewChildCommandFunction(name, description, fn)
return c
}

1336
pkg/core/command.go Normal file

File diff suppressed because it is too large Load diff

118
pkg/core/config.go Normal file
View file

@ -0,0 +1,118 @@
// 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
}
func (v *ConfigVar[T]) Get() T { return v.val }
func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true }
func (v *ConfigVar[T]) IsSet() bool { return v.set }
func (v *ConfigVar[T]) Unset() {
v.set = false
var zero T
v.val = zero
}
func NewConfigVar[T any](val T) ConfigVar[T] {
return ConfigVar[T]{val: val, set: true}
}
// ConfigOpts holds configuration data.
type ConfigOpts struct {
Settings map[string]any
Features map[string]bool
}
func (o *ConfigOpts) 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 {
*ConfigOpts
mu sync.RWMutex
}
// Set stores a configuration value by key.
func (e *Config) Set(key string, val any) {
e.mu.Lock()
e.ConfigOpts.init()
e.Settings[key] = val
e.mu.Unlock()
}
// Get retrieves a configuration value by key.
func (e *Config) Get(key string) (any, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ConfigOpts == nil || e.Settings == nil {
return nil, false
}
val, ok := e.Settings[key]
return val, ok
}
func (e *Config) String(key string) string { return ConfigGet[string](e, key) }
func (e *Config) Int(key string) int { return ConfigGet[int](e, key) }
func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) }
// ConfigGet retrieves a typed configuration value.
func ConfigGet[T any](e *Config, key string) T {
val, ok := e.Get(key)
if !ok {
var zero T
return zero
}
typed, _ := val.(T)
return typed
}
// --- Feature Flags ---
func (e *Config) Enable(feature string) {
e.mu.Lock()
e.ConfigOpts.init()
e.Features[feature] = true
e.mu.Unlock()
}
func (e *Config) Disable(feature string) {
e.mu.Lock()
e.ConfigOpts.init()
e.Features[feature] = false
e.mu.Unlock()
}
func (e *Config) Enabled(feature string) bool {
e.mu.RLock()
v := e.Features[feature]
e.mu.RUnlock()
return v
}
func (e *Config) EnabledFeatures() []string {
e.mu.RLock()
defer e.mu.RUnlock()
var result []string
for k, v := range e.Features {
if v {
result = append(result, k)
}
}
return result
}

198
pkg/core/contract.go Normal file
View file

@ -0,0 +1,198 @@
// SPDX-License-Identifier: EUPL-1.2
// Contracts, options, and type definitions for the Core framework.
package core
import (
"context"
"embed"
"fmt"
"reflect"
"strings"
)
// Contract specifies operational guarantees for Core and its services.
type Contract struct {
DontPanic bool
DisableLogging bool
}
// Option is a function that configures the Core.
type Option func(*Core) error
// 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
// Task is the type for IPC requests that perform side effects.
type Task any
// TaskWithID is an optional interface for tasks that need to know their assigned ID.
type TaskWithID interface {
Task
SetTaskID(id string)
GetTaskID() string
}
// QueryHandler handles Query requests. Returns (result, handled, error).
type QueryHandler func(*Core, Query) (any, bool, error)
// TaskHandler handles Task requests. Returns (result, handled, error).
type TaskHandler func(*Core, Task) (any, bool, error)
// Startable is implemented by services that need startup initialisation.
type Startable interface {
OnStartup(ctx context.Context) error
}
// Stoppable is implemented by services that need shutdown cleanup.
type Stoppable interface {
OnShutdown(ctx context.Context) error
}
// ConfigService provides access to application configuration.
type ConfigService interface {
Get(key string, out any) error
Set(key string, v any) error
}
// --- Action Messages ---
type ActionServiceStartup struct{}
type ActionServiceShutdown struct{}
type ActionTaskStarted struct {
TaskID string
Task Task
}
type ActionTaskProgress struct {
TaskID string
Task Task
Progress float64
Message string
}
type ActionTaskCompleted struct {
TaskID string
Task Task
Result any
Error error
}
// --- Constructor ---
// New creates a Core instance with the provided options.
func New(opts ...Option) (*Core, error) {
c := &Core{
app: &App{},
fs: &Fs{root: "/"},
cfg: &Config{ConfigOpts: &ConfigOpts{}},
err: &ErrPan{},
log: &ErrLog{&ErrOpts{Log: defaultLog}},
cli: &Cli{opts: &CliOpts{}},
srv: &Service{},
lock: &Lock{},
ipc: &Ipc{},
i18n: &I18n{},
}
for _, o := range opts {
if err := o(c); err != nil {
return nil, err
}
}
c.LockApply()
return c, nil
}
// --- With* Options ---
// WithService registers a service with auto-discovered name and IPC handler.
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)
}
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)
}
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)
}
}
result := c.Service(name, serviceInstance)
if err, ok := result.(error); ok {
return err
}
return nil
}
}
// WithName registers a service with an explicit name.
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)
}
result := c.Service(name, serviceInstance)
if err, ok := result.(error); ok {
return err
}
return nil
}
}
// WithApp injects the GUI runtime (e.g., Wails App).
func WithApp(runtime any) Option {
return func(c *Core) error {
c.app.Runtime = runtime
return nil
}
}
// WithAssets mounts embedded assets.
func WithAssets(efs embed.FS) Option {
return func(c *Core) error {
sub, err := Mount(efs, ".")
if err != nil {
return E("core.WithAssets", "failed to mount assets", err)
}
c.emb = sub
return nil
}
}
// WithServiceLock prevents service registration after initialisation.
// Order-independent — lock is applied after all options are processed.
func WithServiceLock() Option {
return func(c *Core) error {
c.LockEnable()
return nil
}
}

View file

@ -1,396 +1,71 @@
// 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"
"embed"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"sync"
"sync/atomic"
)
var (
instance *Core
instanceMu sync.RWMutex
)
// --- Core Struct ---
// 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)
// Core is the central application object that manages services, assets, and communication.
type Core struct {
app *App // c.App() — Application identity + optional GUI runtime
emb *Embed // c.Embed() — Mounted embedded assets (read-only)
fs *Fs // c.Fs() — Local filesystem I/O (sandboxable)
cfg *Config // c.Config() — Configuration, settings, feature flags
err *ErrPan // c.Error() — Panic recovery and crash reporting
log *ErrLog // c.Log() — Structured logging + error wrapping
cli *Cli // c.Cli() — CLI command framework
srv *Service // c.Service("name") — Service registry and lifecycle
lock *Lock // c.Lock("name") — Named mutexes
ipc *Ipc // c.IPC() — Message bus for IPC
i18n *I18n // c.I18n() — Internationalisation and locale collection
for _, o := range opts {
if err := o(c); err != nil {
return nil, err
}
}
c.svc.applyLock()
return c, nil
taskIDCounter atomic.Uint64
wg sync.WaitGroup
shutdown atomic.Bool
}
// 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)
// --- Accessors ---
if err != nil {
return fmt.Errorf("core: failed to create service: %w", err)
}
if serviceInstance == nil {
return fmt.Errorf("core: service factory returned nil instance")
}
func (c *Core) App() *App { return c.app }
func (c *Core) Embed() *Embed { return c.emb }
func (c *Core) Fs() *Fs { return c.fs }
func (c *Core) Config() *Config { return c.cfg }
func (c *Core) Error() *ErrPan { return c.err }
func (c *Core) Log() *ErrLog { return c.log }
func (c *Core) Cli() *Cli { return c.cli }
func (c *Core) IPC() *Ipc { return c.ipc }
func (c *Core) I18n() *I18n { return c.i18n }
func (c *Core) Core() *Core { return c }
// --- 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 fmt.Errorf("core: service name could not be discovered for type %T (PkgPath is empty)", serviceInstance)
}
// --- IPC (uppercase aliases) ---
// --- 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 fmt.Errorf("core: service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name)
}
}
func (c *Core) ACTION(msg Message) error { return c.Action(msg) }
func (c *Core) QUERY(q Query) (any, bool, error) { return c.Query(q) }
func (c *Core) QUERYALL(q Query) ([]any, error) { return c.QueryAll(q) }
func (c *Core) PERFORM(t Task) (any, bool, error) { return c.Perform(t) }
return c.RegisterService(name, serviceInstance)
}
// --- Error+Log ---
// LogError logs an error and returns a wrapped error.
func (c *Core) LogError(err error, op, msg string) error {
return c.log.Error(err, op, msg)
}
// 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 fmt.Errorf("core: failed to create service '%s': %w", name, err)
}
return c.RegisterService(name, serviceInstance)
}
// LogWarn logs a warning and returns a wrapped error.
func (c *Core) LogWarn(err error, op, msg string) error {
return c.log.Warn(err, op, msg)
}
// 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
}
// Must logs and panics if err is not nil.
func (c *Core) Must(err error, op, msg string) {
c.log.Must(err, op, msg)
}
// 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 = fmt.Errorf("no handler found for task type %T", t)
}
// 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.
func (c *Core) RegisterService(name string, api any) error {
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, fmt.Errorf("service '%s' not found", name)
}
typed, ok := raw.(T)
if !ok {
return zero, fmt.Errorf("service '%s' is of type %T, but expected %T", name, raw, zero)
}
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
}
// --- Global Instance ---

View file

@ -1,59 +0,0 @@
// Package core provides a standardized error handling mechanism for the Core library.
// It allows for wrapping errors with contextual information, making it easier to
// trace the origin of an error and provide meaningful feedback.
//
// The design of this package is influenced by the need for a simple, yet powerful
// way to handle errors that can occur in different layers of the application,
// from low-level file operations to high-level service interactions.
//
// The key features of this package are:
// - Error wrapping: The Op and an optional Msg field provide context about
// where and why an error occurred.
// - Stack traces: By wrapping errors, we can build a logical stack trace
// that is more informative than a raw stack trace.
// - Consistent error handling: Encourages a uniform approach to error
// handling across the entire codebase.
package core
import (
"fmt"
)
// Error represents a standardized error with operational context.
type Error struct {
// Op is the operation being performed, e.g., "config.Load".
Op string
// Msg is a human-readable message explaining the error.
Msg string
// Err is the underlying error that was wrapped.
Err error
}
// E is a helper function to create a new Error.
// This is the primary way to create errors that will be consumed by the system.
// For example:
//
// return e.E("config.Load", "failed to load config file", err)
//
// The 'op' parameter should be in the format of 'package.function' or 'service.method'.
// The 'msg' parameter should be a human-readable message that can be displayed to the user.
// The 'err' parameter is the underlying error that is being wrapped.
func E(op, msg string, err error) error {
if err == nil {
return &Error{Op: op, Msg: msg}
}
return &Error{Op: op, Msg: msg, Err: err}
}
// Error returns the string representation of the error.
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err)
}
return fmt.Sprintf("%s: %s", e.Op, e.Msg)
}
// Unwrap provides compatibility for Go's errors.Is and errors.As functions.
func (e *Error) Unwrap() error {
return e.Err
}

592
pkg/core/embed.go Normal file
View file

@ -0,0 +1,592 @@
// 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"
"strings"
"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.
func GetAsset(group, name string) (string, error) {
assetGroupsMu.RLock()
g, ok := assetGroups[group]
assetGroupsMu.RUnlock()
if !ok {
return "", E("core.GetAsset", fmt.Sprintf("asset group %q not found", group), nil)
}
data, ok := g.assets[name]
if !ok {
return "", E("core.GetAsset", fmt.Sprintf("asset %q not found in group %q", name, group), nil)
}
return decompress(data)
}
// GetAssetBytes retrieves a packed asset as bytes.
func GetAssetBytes(group, name string) ([]byte, error) {
s, err := GetAsset(group, name)
return []byte(s), err
}
// --- 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
BaseDir 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) ([]ScannedPackage, error) {
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 nil, err
}
baseDir := filepath.Dir(filename)
pkg, ok := packageMap[baseDir]
if !ok {
pkg = &ScannedPackage{BaseDir: 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 := strings.Trim(lit.Value, "\"")
group := "."
if len(call.Args) >= 2 {
if glit, ok := call.Args[0].(*ast.BasicLit); ok {
group = strings.Trim(glit.Value, "\"")
}
}
fullPath, err := filepath.Abs(filepath.Join(baseDir, group, path))
if err != nil {
scanErr = Wrap(err, "core.ScanAssets", fmt.Sprintf("could not determine absolute path for asset %q in group %q", path, 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 := strings.Trim(lit.Value, "\"")
fullPath, err := filepath.Abs(filepath.Join(baseDir, path))
if err != nil {
scanErr = Wrap(err, "core.ScanAssets", fmt.Sprintf("could not determine absolute path for group %q", path))
return false
}
pkg.Groups = append(pkg.Groups, fullPath)
// Track for variable resolution
}
}
}
}
return true
})
if scanErr != nil {
return nil, scanErr
}
}
var result []ScannedPackage
for _, pkg := range packageMap {
result = append(result, *pkg)
}
return result, nil
}
// GeneratePack creates Go source code that embeds the scanned assets.
func GeneratePack(pkg ScannedPackage) (string, error) {
var b strings.Builder
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 b.String(), nil
}
b.WriteString("import \"forge.lthn.ai/core/go/pkg/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 "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to scan asset group %q", groupPath))
}
for _, file := range files {
if packed[file] {
continue
}
data, err := compressFile(file)
if err != nil {
return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q in group %q", file, groupPath))
}
localPath := strings.TrimPrefix(file, groupPath+"/")
relGroup, err := filepath.Rel(pkg.BaseDir, groupPath)
if err != nil {
return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("could not determine relative path for group %q (base %q)", groupPath, pkg.BaseDir))
}
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 "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q", asset.FullPath))
}
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data))
packed[asset.FullPath] = true
}
b.WriteString("}\n")
return b.String(), nil
}
// --- 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, strings.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.
// Works with embed.FS, os.DirFS, or any fs.FS implementation.
func Mount(fsys fs.FS, basedir string) (*Embed, error) {
s := &Embed{fsys: fsys, basedir: basedir}
// If it's an embed.FS, keep a reference for EmbedFS()
if efs, ok := fsys.(embed.FS); ok {
s.embedFS = &efs
}
// Verify the basedir exists
if _, err := s.ReadDir("."); err != nil {
return nil, err
}
return s, nil
}
// MountEmbed creates a scoped view of an embed.FS.
func MountEmbed(efs embed.FS, basedir string) (*Embed, error) {
return Mount(efs, basedir)
}
func (s *Embed) path(name string) string {
return filepath.ToSlash(filepath.Join(s.basedir, name))
}
// Open opens the named file for reading.
func (s *Embed) Open(name string) (fs.File, error) {
return s.fsys.Open(s.path(name))
}
// ReadDir reads the named directory.
func (s *Embed) ReadDir(name string) ([]fs.DirEntry, error) {
return fs.ReadDir(s.fsys, s.path(name))
}
// ReadFile reads the named file.
func (s *Embed) ReadFile(name string) ([]byte, error) {
return fs.ReadFile(s.fsys, s.path(name))
}
// ReadString reads the named file as a string.
func (s *Embed) ReadString(name string) (string, error) {
data, err := s.ReadFile(name)
if err != nil {
return "", err
}
return string(data), nil
}
// Sub returns a new Embed anchored at a subdirectory within this mount.
func (s *Embed) Sub(subDir string) (*Embed, error) {
sub, err := fs.Sub(s.fsys, s.path(subDir))
if err != nil {
return nil, err
}
return &Embed{fsys: sub, basedir: "."}, nil
}
// 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{}
}
// BaseDir returns the basedir this Embed is anchored at.
func (s *Embed) BaseDir() 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) error {
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 err
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
// 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 err
}
// Create directories (names may contain templates)
for _, dir := range dirs {
target := renderPath(filepath.Join(targetDir, dir), data)
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
}
// Process template files
for _, path := range templateFiles {
tmpl, err := template.ParseFS(fsys, path)
if err != nil {
return err
}
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 = strings.ReplaceAll(name, filter, "")
}
if renamed := opt.RenameFiles[name]; renamed != "" {
name = renamed
}
targetFile = filepath.Join(dir, name)
f, err := os.Create(targetFile)
if err != nil {
return err
}
if err := tmpl.Execute(f, data); err != nil {
f.Close()
return err
}
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 := renderPath(filepath.Join(targetDir, targetPath), data)
if err := copyFile(fsys, path, target); err != nil {
return err
}
}
return nil
}
func isTemplate(filename string, filters []string) bool {
for _, f := range filters {
if strings.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
}

422
pkg/core/error.go Normal file
View file

@ -0,0 +1,422 @@
// 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"
"fmt"
"iter"
"maps"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"sync"
"time"
)
// ErrSink is the shared interface for error reporting.
// Implemented by ErrLog (structured logging) and ErrPan (panic recovery).
type ErrSink interface {
Error(msg string, keyvals ...any)
Warn(msg string, keyvals ...any)
}
var _ ErrSink = (*Log)(nil)
// Err represents a structured error with operational context.
// It implements the error interface and supports unwrapping.
type Err struct {
Op string // Operation being performed (e.g., "user.Save")
Msg string // Human-readable message
Err 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.Op != "" {
prefix = e.Op + ": "
}
if e.Err != nil {
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
}
return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
}
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
}
return fmt.Sprintf("%s%s", prefix, e.Msg)
}
// Unwrap returns the underlying error for use with errors.Is and errors.As.
func (e *Err) Unwrap() error {
return e.Err
}
// --- 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{Op: op, Msg: msg, Err: 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{Op: op, Msg: msg, Err: err, Code: logErr.Code}
}
return &Err{Op: op, Msg: msg, Err: 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{Op: op, Msg: msg, Err: 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{Msg: 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)
}
// Join combines multiple errors into one.
// Wrapper around errors.Join for convenience.
func Join(errs ...error) error {
return errors.Join(errs...)
}
// --- Error Introspection Helpers ---
// Op extracts the operation name from an error.
// Returns empty string if the error is not an *Err.
func Op(err error) string {
var e *Err
if As(err, &e) {
return e.Op
}
return ""
}
// ErrCode extracts the error code from an error.
// Returns empty string if the error is not an *Err or has no code.
func ErrCode(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.Msg
}
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
}
}
// AllOps returns an iterator over all operational contexts in the error chain.
// It traverses the error tree using errors.Unwrap.
func AllOps(err error) iter.Seq[string] {
return func(yield func(string) bool) {
for err != nil {
if e, ok := err.(*Err); ok {
if e.Op != "" {
if !yield(e.Op) {
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 AllOps(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 AllOps(err) {
ops = append(ops, op)
}
if len(ops) == 0 {
return ""
}
return strings.Join(ops, " -> ")
}
// --- ErrLog: Log-and-Return Error Helpers ---
// ErrOpts holds shared options for error subsystems.
type ErrOpts struct {
Log *Log
}
// ErrLog combines error creation with logging.
// Primary action: return an error. Secondary: log it.
type ErrLog struct {
*ErrOpts
}
// NewErrLog creates an ErrLog (consumer convenience).
func NewErrLog(opts *ErrOpts) *ErrLog {
return &ErrLog{opts}
}
func (el *ErrLog) log() *Log {
if el.ErrOpts != nil && el.Log != nil {
return el.Log
}
return defaultLog
}
// Error logs at Error level and returns a wrapped error.
func (el *ErrLog) Error(err error, op, msg string) error {
if err == nil {
return nil
}
wrapped := Wrap(err, op, msg)
el.log().Error(msg, "op", op, "err", err)
return wrapped
}
// Warn logs at Warn level and returns a wrapped error.
func (el *ErrLog) Warn(err error, op, msg string) error {
if err == nil {
return nil
}
wrapped := Wrap(err, op, msg)
el.log().Warn(msg, "op", op, "err", err)
return wrapped
}
// Must logs and panics if err is not nil.
func (el *ErrLog) Must(err error, op, msg string) {
if err != nil {
el.log().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 {
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"go_version"`
}
// ErrPan manages panic recovery and crash reporting.
type ErrPan struct {
filePath string
meta map[string]string
onCrash func(CrashReport)
}
// PanOpts configures an ErrPan.
type PanOpts struct {
// FilePath is the crash report JSON output path. Empty disables file output.
FilePath string
// Meta is metadata included in every crash report.
Meta map[string]string
// OnCrash is a callback invoked on every crash.
OnCrash func(CrashReport)
}
// NewErrPan creates an ErrPan (consumer convenience).
func NewErrPan(opts ...PanOpts) *ErrPan {
h := &ErrPan{}
if len(opts) > 0 {
o := opts[0]
h.filePath = o.FilePath
if o.Meta != nil {
h.meta = maps.Clone(o.Meta)
}
h.onCrash = o.OnCrash
}
return h
}
// Recover captures a panic and creates a crash report.
// Use as: defer c.Error().Recover()
func (h *ErrPan) Recover() {
if h == nil {
return
}
r := recover()
if r == nil {
return
}
err, ok := r.(error)
if !ok {
err = fmt.Errorf("%v", r)
}
report := CrashReport{
Timestamp: time.Now(),
Error: err.Error(),
Stack: string(debug.Stack()),
System: CrashSystem{
OS: runtime.GOOS,
Arch: 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 *ErrPan) SafeGo(fn func()) {
go func() {
defer h.Recover()
fn()
}()
}
// Reports returns the last n crash reports from the file.
func (h *ErrPan) Reports(n int) ([]CrashReport, error) {
if h.filePath == "" {
return nil, nil
}
crashMu.Lock()
defer crashMu.Unlock()
data, err := os.ReadFile(h.filePath)
if err != nil {
return nil, err
}
var reports []CrashReport
if err := json.Unmarshal(data, &reports); err != nil {
return nil, err
}
if n <= 0 || len(reports) <= n {
return reports, nil
}
return reports[len(reports)-n:], nil
}
var crashMu sync.Mutex
func (h *ErrPan) 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)
if data, err := json.MarshalIndent(reports, "", " "); err == nil {
_ = os.MkdirAll(filepath.Dir(h.filePath), 0755)
_ = os.WriteFile(h.filePath, data, 0600)
}
}

269
pkg/core/fs.go Normal file
View file

@ -0,0 +1,269 @@
// Sandboxed local filesystem I/O for the Core framework.
package core
import (
"fmt"
"io"
"io/fs"
"os"
"os/user"
"path/filepath"
"strings"
"time"
)
// Fs is a sandboxed local filesystem backend.
type Fs struct {
root string
}
// path sanitises and returns the full path.
// Absolute paths are sandboxed under root (unless root is "/").
func (m *Fs) path(p string) string {
if p == "" {
return m.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 m.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 m.root == "/" {
return clean
}
// Strip leading "/" so Join works correctly with root
return filepath.Join(m.root, clean[1:])
}
// validatePath ensures the path is within the sandbox, following symlinks if they exist.
func (m *Fs) validatePath(p string) (string, error) {
if m.root == "/" {
return m.path(p), nil
}
// Split the cleaned path into components
parts := strings.Split(filepath.Clean("/"+p), string(os.PathSeparator))
current := m.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 "", err
}
// Verify the resolved part is still within the root
rel, err := filepath.Rel(m.root, realNext)
if err != nil || strings.HasPrefix(rel, "..") {
// Security event: sandbox escape attempt
username := "unknown"
if u, err := user.Current(); err == nil {
username = u.Username
}
fmt.Fprintf(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s\n",
time.Now().Format(time.RFC3339), m.root, p, realNext, username)
return "", os.ErrPermission // Path escapes sandbox
}
current = realNext
}
return current, nil
}
// Read returns file contents as string.
func (m *Fs) Read(p string) (string, error) {
full, err := m.validatePath(p)
if err != nil {
return "", err
}
data, err := os.ReadFile(full)
if err != nil {
return "", err
}
return string(data), nil
}
// 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) error {
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) error {
full, err := m.validatePath(p)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return err
}
return os.WriteFile(full, []byte(content), mode)
}
// EnsureDir creates directory if it doesn't exist.
func (m *Fs) EnsureDir(p string) error {
full, err := m.validatePath(p)
if err != nil {
return err
}
return os.MkdirAll(full, 0755)
}
// IsDir returns true if path is a directory.
func (m *Fs) IsDir(p string) bool {
if p == "" {
return false
}
full, err := m.validatePath(p)
if err != nil {
return false
}
info, err := os.Stat(full)
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
}
full, err := m.validatePath(p)
if err != nil {
return false
}
info, err := os.Stat(full)
return err == nil && info.Mode().IsRegular()
}
// Exists returns true if path exists.
func (m *Fs) Exists(p string) bool {
full, err := m.validatePath(p)
if err != nil {
return false
}
_, err = os.Stat(full)
return err == nil
}
// List returns directory entries.
func (m *Fs) List(p string) ([]fs.DirEntry, error) {
full, err := m.validatePath(p)
if err != nil {
return nil, err
}
return os.ReadDir(full)
}
// Stat returns file info.
func (m *Fs) Stat(p string) (fs.FileInfo, error) {
full, err := m.validatePath(p)
if err != nil {
return nil, err
}
return os.Stat(full)
}
// Open opens the named file for reading.
func (m *Fs) Open(p string) (fs.File, error) {
full, err := m.validatePath(p)
if err != nil {
return nil, err
}
return os.Open(full)
}
// Create creates or truncates the named file.
func (m *Fs) Create(p string) (io.WriteCloser, error) {
full, err := m.validatePath(p)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return nil, err
}
return os.Create(full)
}
// Append opens the named file for appending, creating it if it doesn't exist.
func (m *Fs) Append(p string) (io.WriteCloser, error) {
full, err := m.validatePath(p)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return nil, err
}
return 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) (io.ReadCloser, error) {
return m.Open(path)
}
// WriteStream returns a writer for the file content.
func (m *Fs) WriteStream(path string) (io.WriteCloser, error) {
return m.Create(path)
}
// Delete removes a file or empty directory.
func (m *Fs) Delete(p string) error {
full, err := m.validatePath(p)
if err != nil {
return err
}
if full == "/" || full == os.Getenv("HOME") {
return E("core.Delete", "refusing to delete protected path: "+full, nil)
}
return os.Remove(full)
}
// DeleteAll removes a file or directory recursively.
func (m *Fs) DeleteAll(p string) error {
full, err := m.validatePath(p)
if err != nil {
return err
}
if full == "/" || full == os.Getenv("HOME") {
return E("core.DeleteAll", "refusing to delete protected path: "+full, nil)
}
return os.RemoveAll(full)
}
// Rename moves a file or directory.
func (m *Fs) Rename(oldPath, newPath string) error {
oldFull, err := m.validatePath(oldPath)
if err != nil {
return err
}
newFull, err := m.validatePath(newPath)
if err != nil {
return err
}
return os.Rename(oldFull, newFull)
}

125
pkg/core/i18n.go Normal file
View file

@ -0,0 +1,125 @@
// 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 {
// T translates a message by its ID with optional arguments.
T(messageID string, args ...any) string
// 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
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() []*Embed {
i.mu.RLock()
out := make([]*Embed, len(i.locales))
copy(out, i.locales)
i.mu.RUnlock()
return out
}
// 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
i.mu.Unlock()
}
// Translator returns the registered translation implementation, or nil.
func (i *I18n) Translator() Translator {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
return t
}
// T translates a message. Returns the key as-is if no translator is registered.
func (i *I18n) T(messageID string, args ...any) string {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.T(messageID, args...)
}
return messageID
}
// SetLanguage sets the active language. No-op if no translator is registered.
func (i *I18n) SetLanguage(lang string) error {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.SetLanguage(lang)
}
return nil
}
// Language returns the current language code, or "en" if no translator.
func (i *I18n) Language() string {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.Language()
}
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"}
}

View file

@ -1,162 +0,0 @@
package core
import (
"context"
"embed"
goio "io"
"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
}
// 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
taskIDCounter atomic.Uint64
wg sync.WaitGroup
shutdown atomic.Bool
}
// 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
}

78
pkg/core/ipc.go Normal file
View file

@ -0,0 +1,78 @@
// 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 (
"errors"
"slices"
"sync"
)
// Ipc holds IPC dispatch data.
type Ipc struct {
ipcMu sync.RWMutex
ipcHandlers []func(*Core, Message) error
queryMu sync.RWMutex
queryHandlers []QueryHandler
taskMu sync.RWMutex
taskHandlers []TaskHandler
}
func (c *Core) Action(msg Message) error {
c.ipc.ipcMu.RLock()
handlers := slices.Clone(c.ipc.ipcHandlers)
c.ipc.ipcMu.RUnlock()
var agg error
for _, h := range handlers {
if err := h(c, msg); err != nil {
agg = errors.Join(agg, err)
}
}
return agg
}
func (c *Core) Query(q Query) (any, bool, error) {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(c, q)
if handled {
return result, true, err
}
}
return nil, false, nil
}
func (c *Core) QueryAll(q Query) ([]any, error) {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
var results []any
var agg error
for _, h := range handlers {
result, handled, err := h(c, q)
if err != nil {
agg = errors.Join(agg, err)
}
if handled && result != nil {
results = append(results, result)
}
}
return results, agg
}
func (c *Core) RegisterQuery(handler QueryHandler) {
c.ipc.queryMu.Lock()
c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler)
c.ipc.queryMu.Unlock()
}

74
pkg/core/lock.go Normal file
View file

@ -0,0 +1,74 @@
// SPDX-License-Identifier: EUPL-1.2
// Synchronisation, locking, and lifecycle snapshots for the Core framework.
package core
import (
"slices"
"sync"
)
// package-level mutex infrastructure
var (
lockMu sync.Mutex
lockMap = make(map[string]*sync.RWMutex)
)
// Lock is the DTO for a named mutex.
type Lock struct {
Name string
Mu *sync.RWMutex
}
// Lock returns a named Lock, creating the mutex if needed.
func (c *Core) Lock(name string) *Lock {
lockMu.Lock()
m, ok := lockMap[name]
if !ok {
m = &sync.RWMutex{}
lockMap[name] = m
}
lockMu.Unlock()
return &Lock{Name: name, Mu: m}
}
// LockEnable marks that the service lock should be applied after initialisation.
func (c *Core) LockEnable(name ...string) {
n := "srv"
if len(name) > 0 {
n = name[0]
}
c.Lock(n).Mu.Lock()
defer c.Lock(n).Mu.Unlock()
c.srv.lockEnabled = true
}
// LockApply activates the service lock if it was enabled.
func (c *Core) LockApply(name ...string) {
n := "srv"
if len(name) > 0 {
n = name[0]
}
c.Lock(n).Mu.Lock()
defer c.Lock(n).Mu.Unlock()
if c.srv.lockEnabled {
c.srv.locked = true
}
}
// Startables returns a snapshot of services implementing Startable.
func (c *Core) Startables() []Startable {
c.Lock("srv").Mu.RLock()
out := slices.Clone(c.srv.startables)
c.Lock("srv").Mu.RUnlock()
return out
}
// Stoppables returns a snapshot of services implementing Stoppable.
func (c *Core) Stoppables() []Stoppable {
c.Lock("srv").Mu.RLock()
out := slices.Clone(c.srv.stoppables)
c.Lock("srv").Mu.RUnlock()
return out
}

397
pkg/core/log.go Normal file
View file

@ -0,0 +1,397 @@
// 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 (
"fmt"
goio "io"
"os"
"os/user"
"slices"
"sync"
"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
}
// RotationLogOpts defines the log rotation and retention policy.
type RotationLogOpts 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
}
// LogOpts configures a Log.
type LogOpts 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 *RotationLogOpts
// 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(RotationLogOpts) goio.WriteCloser
// New creates a new Log with the given options.
func NewLog(opts LogOpts) *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 := Op(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 := fmt.Sprintf("%v", key)
if slices.Contains(redactKeys, keyStr) {
val = "[REDACTED]"
}
// Secure formatting to prevent log injection
if s, ok := val.(string); ok {
kvStr += fmt.Sprintf("%v=%q", key, s)
} else {
kvStr += fmt.Sprintf("%v=%v", key, val)
}
}
}
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", 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 defaultLog = NewLog(LogOpts{Level: LevelInfo})
// Default returns the default logger.
func Default() *Log {
return defaultLog
}
// SetDefault sets the default logger.
func SetDefault(l *Log) {
defaultLog = l
}
// SetLevel sets the default logger's level.
func SetLevel(level Level) {
defaultLog.SetLevel(level)
}
// SetRedactKeys sets the default logger's redaction keys.
func SetRedactKeys(keys ...string) {
defaultLog.SetRedactKeys(keys...)
}
// Debug logs to the default logger.
func Debug(msg string, keyvals ...any) {
defaultLog.Debug(msg, keyvals...)
}
// Info logs to the default logger.
func Info(msg string, keyvals ...any) {
defaultLog.Info(msg, keyvals...)
}
// Warn logs to the default logger.
func Warn(msg string, keyvals ...any) {
defaultLog.Warn(msg, keyvals...)
}
// Error logs to the default logger.
func Error(msg string, keyvals ...any) {
defaultLog.Error(msg, keyvals...)
}
// Security logs to the default logger.
func Security(msg string, keyvals ...any) {
defaultLog.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", Op(err), "code", ErrCode(err), "stack", FormatStackTrace(err))
}
// --- LogPan: Panic-Aware Logger ---
// LogPan logs panic context without crash file management.
// Primary action: log. Secondary: recover panics.
type LogPan struct {
log *Log
}
// NewLogPan creates a LogPan bound to the given logger.
func NewLogPan(log *Log) *LogPan {
return &LogPan{log: log}
}
// Recover captures a panic and logs it. Does not write crash files.
// Use as: defer core.NewLogPan(logger).Recover()
func (lp *LogPan) Recover() {
r := recover()
if r == nil {
return
}
err, ok := r.(error)
if !ok {
err = fmt.Errorf("%v", r)
}
lp.log.Error("panic recovered",
"err", err,
"op", Op(err),
"stack", FormatStackTrace(err),
)
}

View file

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

View file

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

132
pkg/core/runtime.go Normal file
View file

@ -0,0 +1,132 @@
// SPDX-License-Identifier: EUPL-1.2
// Runtime helpers for the Core framework.
// ServiceRuntime is embedded by consumer services.
// Runtime is the GUI binding container (e.g., Wails).
package core
import (
"context"
"errors"
"fmt"
"maps"
"slices"
)
// --- ServiceRuntime (embedded by consumer services) ---
// ServiceRuntime is embedded in services to provide access to the Core and typed options.
type ServiceRuntime[T any] struct {
core *Core
opts T
}
// NewServiceRuntime creates a ServiceRuntime for a service constructor.
func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
return &ServiceRuntime[T]{core: c, opts: opts}
}
func (r *ServiceRuntime[T]) Core() *Core { return r.core }
func (r *ServiceRuntime[T]) Opts() T { return r.opts }
func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() }
// --- Lifecycle ---
// ServiceStartup runs the startup lifecycle for all registered services.
func (c *Core) ServiceStartup(ctx context.Context, options any) error {
startables := c.Startables()
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 runs the shutdown lifecycle for all registered services.
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.Stoppables()
for _, s := range slices.Backward(stoppables) {
if err := ctx.Err(); err != nil {
agg = errors.Join(agg, err)
break
}
if err := s.OnShutdown(ctx); err != nil {
agg = errors.Join(agg, err)
}
}
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
}
// --- Runtime DTO (GUI binding) ---
// Runtime is the container for GUI runtimes (e.g., Wails).
type Runtime struct {
app any
Core *Core
}
// ServiceFactory defines a function that creates a service instance.
type ServiceFactory func() (any, error)
// NewWithFactories creates a Runtime with the provided service factories.
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 a Runtime with no custom services.
func NewRuntime(app any) (*Runtime, error) {
return NewWithFactories(app, map[string]ServiceFactory{})
}
func (r *Runtime) ServiceName() string { return "Core" }
func (r *Runtime) ServiceStartup(ctx context.Context, options any) error {
return r.Core.ServiceStartup(ctx, options)
}
func (r *Runtime) ServiceShutdown(ctx context.Context) error {
if r.Core != nil {
return r.Core.ServiceShutdown(ctx)
}
return nil
}

View file

@ -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, fmt.Errorf("failed to create service %s: factory is nil", name)
}
svc, err := factory()
if err != nil {
return nil, fmt.Errorf("failed to create service %s: %w", 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
}

71
pkg/core/service.go Normal file
View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: EUPL-1.2
// Service registry, lifecycle tracking, and runtime helpers for the Core framework.
package core
import "fmt"
// --- Service Registry DTO ---
// Service holds service registry data.
type Service struct {
Services map[string]any
startables []Startable
stoppables []Stoppable
lockEnabled bool
locked bool
}
// --- Core service methods ---
// Service gets or registers a service.
//
// c.Service() // returns *Service
// c.Service("auth") // returns the "auth" service
// c.Service("auth", myService) // registers "auth"
func (c *Core) Service(args ...any) any {
switch len(args) {
case 0:
return c.srv
case 1:
name, _ := args[0].(string)
c.Lock("srv").Mu.RLock()
v, ok := c.srv.Services[name]
c.Lock("srv").Mu.RUnlock()
if !ok {
return nil
}
return v
default:
name, _ := args[0].(string)
if name == "" {
return E("core.Service", "service name cannot be empty", nil)
}
c.Lock("srv").Mu.Lock()
defer c.Lock("srv").Mu.Unlock()
if c.srv.locked {
return E("core.Service", fmt.Sprintf("service %q is not permitted by the serviceLock setting", name), nil)
}
if _, exists := c.srv.Services[name]; exists {
return E("core.Service", fmt.Sprintf("service %q already registered", name), nil)
}
svc := args[1]
if c.srv.Services == nil {
c.srv.Services = make(map[string]any)
}
c.srv.Services[name] = svc
if st, ok := svc.(Startable); ok {
c.srv.startables = append(c.srv.startables, st)
}
if st, ok := svc.(Stoppable); ok {
c.srv.stoppables = append(c.srv.stoppables, st)
}
if lp, ok := svc.(LocaleProvider); ok {
c.i18n.AddLocales(lp.Locales())
}
return nil
}
}

View file

@ -1,96 +0,0 @@
package core
import (
"errors"
"fmt"
"slices"
"sync"
)
// serviceManager owns the service registry and lifecycle tracking.
// It is an unexported component used internally by Core.
type serviceManager struct {
mu sync.RWMutex
services map[string]any
startables []Startable
stoppables []Stoppable
lockEnabled bool // WithServiceLock was called
locked bool // lock applied after New() completes
}
// newServiceManager creates an empty service manager.
func newServiceManager() *serviceManager {
return &serviceManager{
services: make(map[string]any),
}
}
// registerService adds a named service to the registry.
// It also appends to startables/stoppables if the service implements those interfaces.
func (m *serviceManager) registerService(name string, svc any) error {
if name == "" {
return errors.New("core: service name cannot be empty")
}
m.mu.Lock()
defer m.mu.Unlock()
if m.locked {
return fmt.Errorf("core: service %q is not permitted by the serviceLock setting", name)
}
if _, exists := m.services[name]; exists {
return fmt.Errorf("core: service %q already registered", name)
}
m.services[name] = svc
if s, ok := svc.(Startable); ok {
m.startables = append(m.startables, s)
}
if s, ok := svc.(Stoppable); ok {
m.stoppables = append(m.stoppables, s)
}
return nil
}
// service retrieves a registered service by name, or nil if not found.
func (m *serviceManager) service(name string) any {
m.mu.RLock()
svc, ok := m.services[name]
m.mu.RUnlock()
if !ok {
return nil
}
return svc
}
// enableLock marks that the lock should be applied after initialisation.
func (m *serviceManager) enableLock() {
m.mu.Lock()
defer m.mu.Unlock()
m.lockEnabled = true
}
// applyLock activates the service lock if it was enabled.
// Called once during New() after all options have been processed.
func (m *serviceManager) applyLock() {
m.mu.Lock()
defer m.mu.Unlock()
if m.lockEnabled {
m.locked = true
}
}
// getStartables returns a snapshot copy of the startables slice.
func (m *serviceManager) getStartables() []Startable {
m.mu.RLock()
out := slices.Clone(m.startables)
m.mu.RUnlock()
return out
}
// getStoppables returns a snapshot copy of the stoppables slice.
func (m *serviceManager) getStoppables() []Stoppable {
m.mu.RLock()
out := slices.Clone(m.stoppables)
m.mu.RUnlock()
return out
}

View file

@ -1,132 +0,0 @@
package core
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestServiceManager_RegisterService_Good(t *testing.T) {
m := newServiceManager()
err := m.registerService("svc1", &MockService{Name: "one"})
assert.NoError(t, err)
got := m.service("svc1")
assert.NotNil(t, got)
assert.Equal(t, "one", got.(*MockService).GetName())
}
func TestServiceManager_RegisterService_Bad(t *testing.T) {
m := newServiceManager()
// Empty name
err := m.registerService("", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot be empty")
// Duplicate
err = m.registerService("dup", &MockService{})
assert.NoError(t, err)
err = m.registerService("dup", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "already registered")
// Locked
m2 := newServiceManager()
m2.enableLock()
m2.applyLock()
err = m2.registerService("late", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "serviceLock")
}
func TestServiceManager_ServiceNotFound_Good(t *testing.T) {
m := newServiceManager()
assert.Nil(t, m.service("nonexistent"))
}
func TestServiceManager_Startables_Good(t *testing.T) {
m := newServiceManager()
s1 := &MockStartable{}
s2 := &MockStartable{}
_ = m.registerService("s1", s1)
_ = m.registerService("s2", s2)
startables := m.getStartables()
assert.Len(t, startables, 2)
// Verify order matches registration order
assert.Same(t, s1, startables[0])
assert.Same(t, s2, startables[1])
// Verify it's a copy — mutating the slice doesn't affect internal state
startables[0] = nil
assert.Len(t, m.getStartables(), 2)
assert.NotNil(t, m.getStartables()[0])
}
func TestServiceManager_Stoppables_Good(t *testing.T) {
m := newServiceManager()
s1 := &MockStoppable{}
s2 := &MockStoppable{}
_ = m.registerService("s1", s1)
_ = m.registerService("s2", s2)
stoppables := m.getStoppables()
assert.Len(t, stoppables, 2)
// Stoppables are returned in registration order; Core.ServiceShutdown reverses them
assert.Same(t, s1, stoppables[0])
assert.Same(t, s2, stoppables[1])
}
func TestServiceManager_Lock_Good(t *testing.T) {
m := newServiceManager()
// Register before lock — should succeed
err := m.registerService("early", &MockService{})
assert.NoError(t, err)
// Enable and apply lock
m.enableLock()
m.applyLock()
// Register after lock — should fail
err = m.registerService("late", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "serviceLock")
// Early service is still accessible
assert.NotNil(t, m.service("early"))
}
func TestServiceManager_LockNotAppliedWithoutEnable_Good(t *testing.T) {
m := newServiceManager()
m.applyLock() // applyLock without enableLock should be a no-op
err := m.registerService("svc", &MockService{})
assert.NoError(t, err)
}
type mockFullLifecycle struct{}
func (m *mockFullLifecycle) OnStartup(_ context.Context) error { return nil }
func (m *mockFullLifecycle) OnShutdown(_ context.Context) error { return nil }
func TestServiceManager_LifecycleBoth_Good(t *testing.T) {
m := newServiceManager()
svc := &mockFullLifecycle{}
err := m.registerService("both", svc)
assert.NoError(t, err)
// Should appear in both startables and stoppables
assert.Len(t, m.getStartables(), 1)
assert.Len(t, m.getStoppables(), 1)
}

75
pkg/core/task.go Normal file
View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: EUPL-1.2
// Background task dispatch for the Core framework.
package core
import (
"fmt"
"slices"
)
// TaskState holds background task state.
type TaskState struct {
ID string
Task Task
Result any
Error error
}
// PerformAsync dispatches a task in a background goroutine.
func (c *Core) PerformAsync(t Task) string {
if c.shutdown.Load() {
return ""
}
taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1))
if tid, ok := t.(TaskWithID); ok {
tid.SetTaskID(taskID)
}
_ = 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)
}
_ = 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})
}
func (c *Core) Perform(t Task) (any, bool, error) {
c.ipc.taskMu.RLock()
handlers := slices.Clone(c.ipc.taskHandlers)
c.ipc.taskMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(c, t)
if handled {
return result, true, err
}
}
return nil, false, nil
}
func (c *Core) RegisterAction(handler func(*Core, Message) error) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
c.ipc.ipcMu.Unlock()
}
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
c.ipc.ipcMu.Unlock()
}
func (c *Core) RegisterTask(handler TaskHandler) {
c.ipc.taskMu.Lock()
c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler)
c.ipc.taskMu.Unlock()
}

View file

@ -1,74 +0,0 @@
// Package log re-exports go-log and provides framework integration (Service)
// and log rotation (RotatingWriter) that depend on core/go internals.
//
// New code should import forge.lthn.ai/core/go-log directly.
package log
import (
"io"
golog "forge.lthn.ai/core/go-log"
)
// Type aliases — all go-log types available as log.X
type (
Level = golog.Level
Logger = golog.Logger
Options = golog.Options
RotationOptions = golog.RotationOptions
Err = golog.Err
)
// Level constants.
const (
LevelQuiet = golog.LevelQuiet
LevelError = golog.LevelError
LevelWarn = golog.LevelWarn
LevelInfo = golog.LevelInfo
LevelDebug = golog.LevelDebug
)
func init() {
// Wire rotation into go-log: when go-log's New() gets RotationOptions,
// it calls this factory to create the RotatingWriter (which needs go-io).
golog.RotationWriterFactory = func(opts RotationOptions) io.WriteCloser {
return NewRotatingWriter(opts, nil)
}
}
// --- Logging functions (re-exported from go-log) ---
var (
New = golog.New
Default = golog.Default
SetDefault = golog.SetDefault
SetLevel = golog.SetLevel
Debug = golog.Debug
Info = golog.Info
Warn = golog.Warn
Error = golog.Error
Security = golog.Security
Username = golog.Username
)
// --- Error functions (re-exported from go-log) ---
var (
E = golog.E
Wrap = golog.Wrap
WrapCode = golog.WrapCode
NewCode = golog.NewCode
Is = golog.Is
As = golog.As
NewError = golog.NewError
Join = golog.Join
Op = golog.Op
ErrCode = golog.ErrCode
Message = golog.Message
Root = golog.Root
StackTrace = golog.StackTrace
FormatStackTrace = golog.FormatStackTrace
LogError = golog.LogError
LogWarn = golog.LogWarn
Must = golog.Must
)

View file

@ -1,170 +0,0 @@
package log
import (
"fmt"
"io"
"sync"
"time"
coreio "forge.lthn.ai/core/go-io"
)
// RotatingWriter implements io.WriteCloser and provides log rotation.
type RotatingWriter struct {
opts RotationOptions
medium coreio.Medium
mu sync.Mutex
file io.WriteCloser
size int64
}
// NewRotatingWriter creates a new RotatingWriter with the given options and medium.
func NewRotatingWriter(opts RotationOptions, m coreio.Medium) *RotatingWriter {
if m == nil {
m = coreio.Local
}
if opts.MaxSize <= 0 {
opts.MaxSize = 100 // 100 MB
}
if opts.MaxBackups <= 0 {
opts.MaxBackups = 5
}
if opts.MaxAge == 0 {
opts.MaxAge = 28 // 28 days
} else if opts.MaxAge < 0 {
opts.MaxAge = 0 // disabled
}
return &RotatingWriter{
opts: opts,
medium: m,
}
}
// Write writes data to the current log file, rotating it if necessary.
func (w *RotatingWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
if err := w.openExistingOrNew(); err != nil {
return 0, err
}
}
if w.size+int64(len(p)) > int64(w.opts.MaxSize)*1024*1024 {
if err := w.rotate(); err != nil {
return 0, err
}
}
n, err = w.file.Write(p)
if err == nil {
w.size += int64(n)
}
return n, err
}
// Close closes the current log file.
func (w *RotatingWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.close()
}
func (w *RotatingWriter) close() error {
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *RotatingWriter) openExistingOrNew() error {
info, err := w.medium.Stat(w.opts.Filename)
if err == nil {
w.size = info.Size()
f, err := w.medium.Append(w.opts.Filename)
if err != nil {
return err
}
w.file = f
return nil
}
f, err := w.medium.Create(w.opts.Filename)
if err != nil {
return err
}
w.file = f
w.size = 0
return nil
}
func (w *RotatingWriter) rotate() error {
if err := w.close(); err != nil {
return err
}
if err := w.rotateFiles(); err != nil {
// Try to reopen current file even if rotation failed
_ = w.openExistingOrNew()
return err
}
if err := w.openExistingOrNew(); err != nil {
return err
}
w.cleanup()
return nil
}
func (w *RotatingWriter) rotateFiles() error {
// Rotate existing backups: log.N -> log.N+1
for i := w.opts.MaxBackups; i >= 1; i-- {
oldPath := w.backupPath(i)
newPath := w.backupPath(i + 1)
if w.medium.Exists(oldPath) {
if i+1 > w.opts.MaxBackups {
_ = w.medium.Delete(oldPath)
} else {
_ = w.medium.Rename(oldPath, newPath)
}
}
}
// log -> log.1
return w.medium.Rename(w.opts.Filename, w.backupPath(1))
}
func (w *RotatingWriter) backupPath(n int) string {
return fmt.Sprintf("%s.%d", w.opts.Filename, n)
}
func (w *RotatingWriter) cleanup() {
// 1. Remove backups beyond MaxBackups
// This is already partially handled by rotateFiles but we can be thorough
for i := w.opts.MaxBackups + 1; ; i++ {
path := w.backupPath(i)
if !w.medium.Exists(path) {
break
}
_ = w.medium.Delete(path)
}
// 2. Remove backups older than MaxAge
if w.opts.MaxAge > 0 {
cutoff := time.Now().AddDate(0, 0, -w.opts.MaxAge)
for i := 1; i <= w.opts.MaxBackups; i++ {
path := w.backupPath(i)
info, err := w.medium.Stat(path)
if err == nil && info.ModTime().Before(cutoff) {
_ = w.medium.Delete(path)
}
}
}
}

View file

@ -1,163 +0,0 @@
package log
import (
"strings"
"testing"
"time"
"forge.lthn.ai/core/go-io"
)
func TestRotatingWriter_Basic(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1, // 1 MB
MaxBackups: 3,
}
w := NewRotatingWriter(opts, m)
defer w.Close()
msg := "test message\n"
_, err := w.Write([]byte(msg))
if err != nil {
t.Fatalf("failed to write: %v", err)
}
w.Close()
content, err := m.Read("test.log")
if err != nil {
t.Fatalf("failed to read from medium: %v", err)
}
if content != msg {
t.Errorf("expected %q, got %q", msg, content)
}
}
func TestRotatingWriter_Rotation(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1, // 1 MB
MaxBackups: 2,
}
w := NewRotatingWriter(opts, m)
defer w.Close()
// 1. Write almost 1MB
largeMsg := strings.Repeat("a", 1024*1024-10)
_, _ = w.Write([]byte(largeMsg))
// 2. Write more to trigger rotation
_, _ = w.Write([]byte("trigger rotation\n"))
w.Close()
// Check if test.log.1 exists and contains the large message
if !m.Exists("test.log.1") {
t.Error("expected test.log.1 to exist")
}
// Check if test.log exists and contains the new message
content, _ := m.Read("test.log")
if !strings.Contains(content, "trigger rotation") {
t.Errorf("expected test.log to contain new message, got %q", content)
}
}
func TestRotatingWriter_Retention(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1,
MaxBackups: 2,
}
w := NewRotatingWriter(opts, m)
defer w.Close()
// Trigger rotation 4 times to test retention of only the latest backups
for i := 1; i <= 4; i++ {
_, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1)))
}
w.Close()
// Should have test.log, test.log.1, test.log.2
// test.log.3 should have been deleted because MaxBackups is 2
if !m.Exists("test.log") {
t.Error("expected test.log to exist")
}
if !m.Exists("test.log.1") {
t.Error("expected test.log.1 to exist")
}
if !m.Exists("test.log.2") {
t.Error("expected test.log.2 to exist")
}
if m.Exists("test.log.3") {
t.Error("expected test.log.3 NOT to exist")
}
}
func TestRotatingWriter_Append(t *testing.T) {
m := io.NewMockMedium()
_ = m.Write("test.log", "existing content\n")
opts := RotationOptions{
Filename: "test.log",
}
w := NewRotatingWriter(opts, m)
_, _ = w.Write([]byte("new content\n"))
_ = w.Close()
content, _ := m.Read("test.log")
expected := "existing content\nnew content\n"
if content != expected {
t.Errorf("expected %q, got %q", expected, content)
}
}
func TestRotatingWriter_AgeRetention(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1,
MaxBackups: 5,
MaxAge: 7, // 7 days
}
w := NewRotatingWriter(opts, m)
// Create some backup files
m.Write("test.log.1", "recent")
m.ModTimes["test.log.1"] = time.Now()
m.Write("test.log.2", "old")
m.ModTimes["test.log.2"] = time.Now().AddDate(0, 0, -10) // 10 days old
// Trigger rotation to run cleanup
_, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1)))
w.Close()
if !m.Exists("test.log.1") {
t.Error("expected test.log.1 (now test.log.2) to exist as it's recent")
}
// Note: test.log.1 becomes test.log.2 after rotation, etc.
// But wait, my cleanup runs AFTER rotation.
// Initial state:
// test.log.1 (now)
// test.log.2 (-10d)
// Write triggers rotation:
// test.log -> test.log.1
// test.log.1 -> test.log.2
// test.log.2 -> test.log.3
// Then cleanup runs:
// test.log.1 (now) - keep
// test.log.2 (now) - keep
// test.log.3 (-10d) - delete (since MaxAge is 7)
if m.Exists("test.log.3") {
t.Error("expected test.log.3 to be deleted as it's too old")
}
}

View file

@ -1,57 +0,0 @@
package log
import (
"context"
"forge.lthn.ai/core/go/pkg/core"
)
// Service wraps Logger for Core framework integration.
type Service struct {
*core.ServiceRuntime[Options]
*Logger
}
// NewService creates a log service factory for Core.
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
}
}
// 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
}
// QueryLevel returns the current log level.
type QueryLevel struct{}
// TaskSetLevel changes the log level.
type TaskSetLevel struct {
Level Level
}
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
}

View file

@ -1,6 +1,7 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"context"
"errors"
"sync/atomic"
@ -13,10 +14,10 @@ import (
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
@ -24,36 +25,36 @@ func TestCore_PerformAsync_Good(t *testing.T) {
}
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
@ -61,9 +62,9 @@ func TestCore_Progress_Good(t *testing.T) {
}
return nil
})
c.Progress("task-1", 0.5, "halfway", TestTask{})
assert.Equal(t, 0.5, progressReceived)
assert.Equal(t, "halfway", messageReceived)
}
@ -74,7 +75,7 @@ func TestCore_WithService_UnnamedType(t *testing.T) {
s := "primitive"
return &s, nil
}
_, err := New(WithService(factory))
require.Error(t, err)
assert.Contains(t, err.Error(), "service name could not be discovered")
@ -82,11 +83,11 @@ func TestCore_WithService_UnnamedType(t *testing.T) {
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")
@ -94,46 +95,47 @@ func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) {
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")
assert.False(t, s1.started, "Srv 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")
assert.False(t, s1.stopped, "Srv 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 (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())
}

View file

@ -1,6 +1,8 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"testing"
)

View file

@ -1,6 +1,8 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"testing"
"github.com/stretchr/testify/assert"

View file

@ -1,6 +1,8 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"context"
"errors"
"testing"

View file

@ -1,6 +1,7 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"context"
"embed"
"io"
@ -33,7 +34,7 @@ func TestCore_WithService_Good(t *testing.T) {
}
c, err := New(WithService(factory))
assert.NoError(t, err)
svc := c.Service("core")
svc := c.Service().Get("core")
assert.NotNil(t, svc)
mockSvc, ok := svc.(*MockService)
assert.True(t, ok)
@ -54,10 +55,6 @@ 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)
@ -65,29 +62,12 @@ func TestCore_Services_Good(t *testing.T) {
err = c.RegisterService("config", &MockConfigService{})
assert.NoError(t, err)
err = c.RegisterService("display", &MockDisplayService{})
assert.NoError(t, err)
svc := c.Service("config")
assert.NotNil(t, svc)
// Cfg() returns Cfg (always available, not a service)
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) {
@ -95,21 +75,21 @@ func TestCore_App_Good(t *testing.T) {
c, err := New(WithApp(app))
assert.NoError(t, err)
// To test the global App() function, we need to set the global instance.
// To test the global CoreGUI() function, we need to set the global instance.
originalInstance := GetInstance()
SetInstance(c)
defer SetInstance(originalInstance)
assert.Equal(t, app, App())
assert.Equal(t, app, CoreGUI())
}
func TestCore_App_Ugly(t *testing.T) {
// This test ensures that calling App() before the core is initialized panics.
// This test ensures that calling CoreGUI() before the core is initialized panics.
originalInstance := GetInstance()
ClearInstance()
defer SetInstance(originalInstance)
assert.Panics(t, func() {
App()
CoreGUI()
})
}
@ -119,24 +99,37 @@ func TestCore_Core_Good(t *testing.T) {
assert.Equal(t, c, c.Core())
}
func TestFeatures_IsEnabled_Good(t *testing.T) {
func TestEtc_Features_Good(t *testing.T) {
c, err := New()
assert.NoError(t, err)
c.Features.Flags = []string{"feature1", "feature2"}
c.Config().Enable("feature1")
c.Config().Enable("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(""))
assert.True(t, c.Config().Enabled("feature1"))
assert.True(t, c.Config().Enabled("feature2"))
assert.False(t, c.Config().Enabled("feature3"))
assert.False(t, c.Config().Enabled(""))
}
func TestFeatures_IsEnabled_Edge(t *testing.T) {
func TestEtc_Settings_Good(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
c.Config().Set("api_url", "https://api.lthn.sh")
c.Config().Set("max_agents", 5)
assert.Equal(t, "https://api.lthn.sh", c.Config().GetString("api_url"))
assert.Equal(t, 5, c.Config().GetInt("max_agents"))
assert.Equal(t, "", c.Config().GetString("missing"))
}
func TestEtc_Features_Edge(t *testing.T) {
c, _ := New()
c.Config().Enable("foo")
assert.True(t, c.Config().Enabled("foo"))
assert.False(t, c.Config().Enabled("FOO")) // Case sensitive
c.Config().Disable("foo")
assert.False(t, c.Config().Enabled("foo"))
}
func TestCore_ServiceLifecycle_Good(t *testing.T) {
@ -165,7 +158,7 @@ func TestCore_WithApp_Good(t *testing.T) {
app := &mockApp{}
c, err := New(WithApp(app))
assert.NoError(t, err)
assert.Equal(t, app, c.App)
assert.Equal(t, app, c.App().Runtime)
}
//go:embed testdata
@ -174,8 +167,7 @@ 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")
file, err := c.Embed().Open("testdata/test.txt")
assert.NoError(t, err)
defer func() { _ = file.Close() }()
content, err := io.ReadAll(file)

View file

@ -1,6 +1,8 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"errors"
"testing"
@ -23,7 +25,7 @@ func TestE_Unwrap(t *testing.T) {
assert.True(t, errors.Is(err, originalErr))
var eErr *Error
var eErr *Err
assert.True(t, errors.As(err, &eErr))
assert.Equal(t, "test.op", eErr.Op)
}

View file

@ -1,6 +1,7 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"errors"
"testing"
)
@ -23,14 +24,14 @@ func FuzzE(f *testing.F) {
}
s := e.Error()
if s == "" {
t.Fatal("Error() returned empty string")
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
var coreErr *Err
if !errors.As(e, &coreErr) {
t.Fatal("errors.As failed for *Error")
t.Fatal("errors.As failed for *Err")
}
if withErr && coreErr.Unwrap() == nil {
t.Fatal("Unwrap() returned nil with underlying error")
@ -41,7 +42,7 @@ func FuzzE(f *testing.F) {
})
}
// FuzzServiceRegistration exercises service name registration with arbitrary names.
// FuzzServiceRegistration exercises service registration with arbitrary names.
func FuzzServiceRegistration(f *testing.F) {
f.Add("myservice")
f.Add("")
@ -50,9 +51,9 @@ func FuzzServiceRegistration(f *testing.F) {
f.Add("service\x00null")
f.Fuzz(func(t *testing.T, name string) {
sm := newServiceManager()
c, _ := New()
err := sm.registerService(name, struct{}{})
err := c.RegisterService(name, struct{}{})
if name == "" {
if err == nil {
t.Fatal("expected error for empty name")
@ -64,13 +65,13 @@ func FuzzServiceRegistration(f *testing.F) {
}
// Retrieve should return the same service
got := sm.service(name)
got := c.Service(name)
if got == nil {
t.Fatalf("service %q not found after registration", name)
}
// Duplicate registration should fail
err = sm.registerService(name, struct{}{})
err = c.RegisterService(name, struct{}{})
if err == nil {
t.Fatalf("expected duplicate error for name %q", name)
}
@ -84,19 +85,15 @@ func FuzzMessageDispatch(f *testing.F) {
f.Add("test\nmultiline")
f.Fuzz(func(t *testing.T, payload string) {
c := &Core{
Features: &Features{},
svc: newServiceManager(),
}
c.bus = newMessageBus(c)
c, _ := New()
var received string
c.bus.registerAction(func(_ *Core, msg Message) error {
c.IPC().RegisterAction(func(_ *Core, msg Message) error {
received = msg.(string)
return nil
})
err := c.bus.action(payload)
err := c.IPC().Action(payload)
if err != nil {
t.Fatalf("action dispatch failed: %v", err)
}

View file

@ -1,6 +1,8 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"errors"
"testing"
"time"

176
tests/message_bus_test.go Normal file
View file

@ -0,0 +1,176 @@
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"errors"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBus_Action_Good(t *testing.T) {
c, _ := New()
var received []Message
c.IPC().RegisterAction(func(_ *Core, msg Message) error {
received = append(received, msg)
return nil
})
c.IPC().RegisterAction(func(_ *Core, msg Message) error {
received = append(received, msg)
return nil
})
err := c.IPC().Action("hello")
assert.NoError(t, err)
assert.Len(t, received, 2)
}
func TestBus_Action_Bad(t *testing.T) {
c, _ := New()
err1 := errors.New("handler1 failed")
err2 := errors.New("handler2 failed")
c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err1 })
c.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil })
c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err2 })
err := c.IPC().Action("test")
assert.Error(t, err)
assert.ErrorIs(t, err, err1)
assert.ErrorIs(t, err, err2)
}
func TestBus_RegisterAction_Good(t *testing.T) {
c, _ := New()
var coreRef *Core
c.IPC().RegisterAction(func(core *Core, msg Message) error {
coreRef = core
return nil
})
_ = c.IPC().Action(nil)
assert.Same(t, c, coreRef, "handler should receive the Core reference")
}
func TestBus_Query_Good(t *testing.T) {
c, _ := New()
c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) {
return "first", true, nil
})
result, handled, err := c.IPC().Query(TestQuery{Value: "test"})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "first", result)
}
func TestBus_QueryAll_Good(t *testing.T) {
c, _ := New()
c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) {
return "a", true, nil
})
c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) {
return nil, false, nil // skips
})
c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) {
return "b", true, nil
})
results, err := c.IPC().QueryAll(TestQuery{})
assert.NoError(t, err)
assert.Equal(t, []any{"a", "b"}, results)
}
func TestBus_Perform_Good(t *testing.T) {
c, _ := New()
c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) {
return "done", true, nil
})
result, handled, err := c.IPC().Perform(TestTask{})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "done", result)
}
func TestBus_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.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil })
}()
go func() {
defer wg.Done()
_ = c.IPC().Action("ping")
}()
}
for i := 0; i < goroutines; i++ {
wg.Add(2)
go func() {
defer wg.Done()
c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil })
}()
go func() {
defer wg.Done()
_, _ = c.IPC().QueryAll(TestQuery{})
}()
}
for i := 0; i < goroutines; i++ {
wg.Add(2)
go func() {
defer wg.Done()
c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil })
}()
go func() {
defer wg.Done()
_, _, _ = c.IPC().Perform(TestTask{})
}()
}
wg.Wait()
}
func TestBus_Action_NoHandlers(t *testing.T) {
c, _ := New()
err := c.IPC().Action("no one listening")
assert.NoError(t, err)
}
func TestBus_Query_NoHandlers(t *testing.T) {
c, _ := New()
result, handled, err := c.IPC().Query(TestQuery{})
assert.NoError(t, err)
assert.False(t, handled)
assert.Nil(t, result)
}
func TestBus_QueryAll_NoHandlers(t *testing.T) {
c, _ := New()
results, err := c.IPC().QueryAll(TestQuery{})
assert.NoError(t, err)
assert.Empty(t, results)
}
func TestBus_Perform_NoHandlers(t *testing.T) {
c, _ := New()
result, handled, err := c.IPC().Perform(TestTask{})
assert.NoError(t, err)
assert.False(t, handled)
assert.Nil(t, result)
}

View file

@ -1,6 +1,8 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"errors"
"testing"

View file

@ -1,6 +1,8 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"testing"
"github.com/stretchr/testify/assert"

View file

@ -1,6 +1,7 @@
package core
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"context"
"testing"
@ -120,7 +121,7 @@ func TestNewServiceRuntime_Good(t *testing.T) {
assert.NotNil(t, sr)
assert.Equal(t, c, sr.Core())
// We can't directly test sr.Config() without a registered config service,
// We can't directly test sr.Cfg() without a registered config service,
// as it will panic.
assert.Panics(t, func() {
sr.Config()

View file

@ -0,0 +1,116 @@
package core_test
import (
. "forge.lthn.ai/core/go/pkg/core"
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestServiceManager_RegisterService_Good(t *testing.T) {
c, _ := New()
err := c.RegisterService("svc1", &MockService{Name: "one"})
assert.NoError(t, err)
got := c.Service("svc1")
assert.NotNil(t, got)
assert.Equal(t, "one", got.(*MockService).GetName())
}
func TestServiceManager_RegisterService_Bad(t *testing.T) {
c, _ := New()
// Empty name
err := c.RegisterService("", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot be empty")
// Duplicate
err = c.RegisterService("dup", &MockService{})
assert.NoError(t, err)
err = c.RegisterService("dup", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "already registered")
// Locked
c2, _ := New(WithServiceLock())
err = c2.RegisterService("late", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "serviceLock")
}
func TestServiceManager_ServiceNotFound_Good(t *testing.T) {
c, _ := New()
assert.Nil(t, c.Service("nonexistent"))
}
func TestServiceManager_Startables_Good(t *testing.T) {
s1 := &MockStartable{}
s2 := &MockStartable{}
c, _ := New(
WithName("s1", func(_ *Core) (any, error) { return s1, nil }),
WithName("s2", func(_ *Core) (any, error) { return s2, nil }),
)
// Startup should call both
err := c.ServiceStartup(context.Background(), nil)
assert.NoError(t, err)
}
func TestServiceManager_Stoppables_Good(t *testing.T) {
s1 := &MockStoppable{}
s2 := &MockStoppable{}
c, _ := New(
WithName("s1", func(_ *Core) (any, error) { return s1, nil }),
WithName("s2", func(_ *Core) (any, error) { return s2, nil }),
)
// Shutdown should call both
err := c.ServiceShutdown(context.Background())
assert.NoError(t, err)
}
func TestServiceManager_Lock_Good(t *testing.T) {
c, _ := New(
WithName("early", func(_ *Core) (any, error) { return &MockService{}, nil }),
WithServiceLock(),
)
// Register after lock — should fail
err := c.RegisterService("late", &MockService{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "serviceLock")
// Early service is still accessible
assert.NotNil(t, c.Service("early"))
}
func TestServiceManager_LockNotAppliedWithoutEnable_Good(t *testing.T) {
// No WithServiceLock — should allow registration after New()
c, _ := New()
err := c.RegisterService("svc", &MockService{})
assert.NoError(t, err)
}
type mockFullLifecycle struct{}
func (m *mockFullLifecycle) OnStartup(_ context.Context) error { return nil }
func (m *mockFullLifecycle) OnShutdown(_ context.Context) error { return nil }
func TestServiceManager_LifecycleBoth_Good(t *testing.T) {
svc := &mockFullLifecycle{}
c, _ := New(
WithName("both", func(_ *Core) (any, error) { return svc, nil }),
)
// Should participate in both startup and shutdown
err := c.ServiceStartup(context.Background(), nil)
assert.NoError(t, err)
err = c.ServiceShutdown(context.Background())
assert.NoError(t, err)
}