Merge pull request #1 from dAppCore/dev
feat: CoreGO v2 — unified struct, DTO pattern, zero constructors
This commit is contained in:
commit
3ee58576a5
47 changed files with 4714 additions and 1808 deletions
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"enabledPlugins": {
|
||||
|
||||
}
|
||||
}
|
||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"core": {
|
||||
"type": "stdio",
|
||||
"command": "core-agent",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -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
9
go.mod
|
|
@ -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
10
go.sum
|
|
@ -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
52
pkg/core/app.go
Normal 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
96
pkg/core/array.go
Normal 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
200
pkg/core/cli.go
Normal 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
1336
pkg/core/command.go
Normal file
File diff suppressed because it is too large
Load diff
118
pkg/core/config.go
Normal file
118
pkg/core/config.go
Normal 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
198
pkg/core/contract.go
Normal 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
|
||||
}
|
||||
}
|
||||
427
pkg/core/core.go
427
pkg/core/core.go
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
592
pkg/core/embed.go
Normal 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
422
pkg/core/error.go
Normal 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
269
pkg/core/fs.go
Normal 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
125
pkg/core/i18n.go
Normal 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"}
|
||||
}
|
||||
|
|
@ -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
78
pkg/core/ipc.go
Normal 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
74
pkg/core/lock.go
Normal 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
397
pkg/core/log.go
Normal 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),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// messageBus owns the IPC action, query, and task dispatch.
|
||||
// It is an unexported component used internally by Core.
|
||||
type messageBus struct {
|
||||
core *Core
|
||||
|
||||
ipcMu sync.RWMutex
|
||||
ipcHandlers []func(*Core, Message) error
|
||||
|
||||
queryMu sync.RWMutex
|
||||
queryHandlers []QueryHandler
|
||||
|
||||
taskMu sync.RWMutex
|
||||
taskHandlers []TaskHandler
|
||||
}
|
||||
|
||||
// newMessageBus creates an empty message bus bound to the given Core.
|
||||
func newMessageBus(c *Core) *messageBus {
|
||||
return &messageBus{core: c}
|
||||
}
|
||||
|
||||
// action dispatches a message to all registered IPC handlers.
|
||||
func (b *messageBus) action(msg Message) error {
|
||||
b.ipcMu.RLock()
|
||||
handlers := slices.Clone(b.ipcHandlers)
|
||||
b.ipcMu.RUnlock()
|
||||
|
||||
var agg error
|
||||
for _, h := range handlers {
|
||||
if err := h(b.core, msg); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// registerAction adds a single IPC handler.
|
||||
func (b *messageBus) registerAction(handler func(*Core, Message) error) {
|
||||
b.ipcMu.Lock()
|
||||
b.ipcHandlers = append(b.ipcHandlers, handler)
|
||||
b.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// registerActions adds multiple IPC handlers.
|
||||
func (b *messageBus) registerActions(handlers ...func(*Core, Message) error) {
|
||||
b.ipcMu.Lock()
|
||||
b.ipcHandlers = append(b.ipcHandlers, handlers...)
|
||||
b.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// query dispatches a query to handlers until one responds.
|
||||
func (b *messageBus) query(q Query) (any, bool, error) {
|
||||
b.queryMu.RLock()
|
||||
handlers := slices.Clone(b.queryHandlers)
|
||||
b.queryMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(b.core, q)
|
||||
if handled {
|
||||
return result, true, err
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// queryAll dispatches a query to all handlers and collects all responses.
|
||||
func (b *messageBus) queryAll(q Query) ([]any, error) {
|
||||
b.queryMu.RLock()
|
||||
handlers := slices.Clone(b.queryHandlers)
|
||||
b.queryMu.RUnlock()
|
||||
|
||||
var results []any
|
||||
var agg error
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(b.core, q)
|
||||
if err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
if handled && result != nil {
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
return results, agg
|
||||
}
|
||||
|
||||
// registerQuery adds a query handler.
|
||||
func (b *messageBus) registerQuery(handler QueryHandler) {
|
||||
b.queryMu.Lock()
|
||||
b.queryHandlers = append(b.queryHandlers, handler)
|
||||
b.queryMu.Unlock()
|
||||
}
|
||||
|
||||
// perform dispatches a task to handlers until one executes it.
|
||||
func (b *messageBus) perform(t Task) (any, bool, error) {
|
||||
b.taskMu.RLock()
|
||||
handlers := slices.Clone(b.taskHandlers)
|
||||
b.taskMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(b.core, t)
|
||||
if handled {
|
||||
return result, true, err
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// registerTask adds a task handler.
|
||||
func (b *messageBus) registerTask(handler TaskHandler) {
|
||||
b.taskMu.Lock()
|
||||
b.taskHandlers = append(b.taskHandlers, handler)
|
||||
b.taskMu.Unlock()
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMessageBus_Action_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var received []Message
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error {
|
||||
received = append(received, msg)
|
||||
return nil
|
||||
})
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error {
|
||||
received = append(received, msg)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := c.bus.action("hello")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, received, 2)
|
||||
}
|
||||
|
||||
func TestMessageBus_Action_Bad(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
err1 := errors.New("handler1 failed")
|
||||
err2 := errors.New("handler2 failed")
|
||||
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return err1 })
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return nil })
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return err2 })
|
||||
|
||||
err := c.bus.action("test")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, err1)
|
||||
assert.ErrorIs(t, err, err2)
|
||||
}
|
||||
|
||||
func TestMessageBus_RegisterAction_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var coreRef *Core
|
||||
c.bus.registerAction(func(core *Core, msg Message) error {
|
||||
coreRef = core
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = c.bus.action(nil)
|
||||
assert.Same(t, c, coreRef, "handler should receive the Core reference")
|
||||
}
|
||||
|
||||
func TestMessageBus_Query_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.bus.query(TestQuery{Value: "test"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "first", result)
|
||||
}
|
||||
|
||||
func TestMessageBus_QueryAll_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return "a", true, nil
|
||||
})
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return nil, false, nil // skips
|
||||
})
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) {
|
||||
return "b", true, nil
|
||||
})
|
||||
|
||||
results, err := c.bus.queryAll(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []any{"a", "b"}, results)
|
||||
}
|
||||
|
||||
func TestMessageBus_Perform_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) {
|
||||
return "done", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.bus.perform(TestTask{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "done", result)
|
||||
}
|
||||
|
||||
func TestMessageBus_ConcurrentAccess_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
|
||||
// Concurrent register + dispatch
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.bus.registerAction(func(_ *Core, msg Message) error { return nil })
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = c.bus.action("ping")
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil })
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = c.bus.queryAll(TestQuery{})
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil })
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _, _ = c.bus.perform(TestTask{})
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestMessageBus_Action_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
// Should not error if no handlers are registered
|
||||
err := c.bus.action("no one listening")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMessageBus_Query_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
result, handled, err := c.bus.query(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestMessageBus_QueryAll_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
results, err := c.bus.queryAll(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestMessageBus_Perform_NoHandlers(t *testing.T) {
|
||||
c, _ := New()
|
||||
result, handled, err := c.bus.perform(TestTask{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
132
pkg/core/runtime.go
Normal file
132
pkg/core/runtime.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
71
pkg/core/service.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
75
pkg/core/task.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package core
|
||||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package core
|
||||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package core
|
||||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
176
tests/message_bus_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package core
|
||||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package core
|
||||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -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()
|
||||
116
tests/service_manager_test.go
Normal file
116
tests/service_manager_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue