Merge pull request #2 from dAppCore/dev
feat: AX audit + Codex review — polish pass
This commit is contained in:
commit
cee07f05dd
56 changed files with 5485 additions and 3761 deletions
|
|
@ -33,20 +33,22 @@ type App struct {
|
|||
}
|
||||
|
||||
|
||||
// Find locates a program on PATH and returns a App for it.
|
||||
// Returns nil if not found.
|
||||
func Find(filename, name string) *App {
|
||||
// Find locates a program on PATH and returns a Result containing the App.
|
||||
//
|
||||
// r := core.Find("node", "Node.js")
|
||||
// if r.OK { app := r.Value.(*App) }
|
||||
func Find(filename, name string) Result {
|
||||
path, err := exec.LookPath(filename)
|
||||
if err != nil {
|
||||
return nil
|
||||
return Result{err, false}
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
return Result{err, false}
|
||||
}
|
||||
return &App{
|
||||
return Result{&App{
|
||||
Name: name,
|
||||
Filename: filename,
|
||||
Path: abs,
|
||||
}
|
||||
}, true}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ func (s *Array[T]) Contains(val T) bool {
|
|||
}
|
||||
|
||||
// Filter returns a new Array with elements matching the predicate.
|
||||
func (s *Array[T]) Filter(fn func(T) bool) *Array[T] {
|
||||
result := &Array[T]{}
|
||||
func (s *Array[T]) Filter(fn func(T) bool) Result {
|
||||
filtered := &Array[T]{}
|
||||
for _, v := range s.items {
|
||||
if fn(v) {
|
||||
result.items = append(result.items, v)
|
||||
filtered.items = append(filtered.items, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return Result{filtered, true}
|
||||
}
|
||||
|
||||
// Each runs a function on every element.
|
||||
|
|
@ -90,7 +90,12 @@ func (s *Array[T]) Clear() {
|
|||
s.items = nil
|
||||
}
|
||||
|
||||
// AsSlice returns the underlying slice.
|
||||
// AsSlice returns a copy of the underlying slice.
|
||||
func (s *Array[T]) AsSlice() []T {
|
||||
return s.items
|
||||
if s.items == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]T, len(s.items))
|
||||
copy(out, s.items)
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
289
pkg/core/cli.go
289
pkg/core/cli.go
|
|
@ -1,200 +1,169 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// CLI command framework for the Core framework.
|
||||
// Based on leaanthony/clir — zero-dependency command line interface.
|
||||
|
||||
// Cli is the CLI surface layer for the Core command tree.
|
||||
// It reads commands from Core's registry and wires them to terminal I/O.
|
||||
//
|
||||
// Run the CLI:
|
||||
//
|
||||
// c := core.New(core.Options{{Key: "name", Value: "myapp"}})
|
||||
// c.Command("deploy", handler)
|
||||
// c.Cli().Run()
|
||||
//
|
||||
// The Cli resolves os.Args to a command path, parses flags,
|
||||
// and calls the command's action with parsed options.
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"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.
|
||||
// Cli is the CLI surface for the Core command tree.
|
||||
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
|
||||
core *Core
|
||||
output io.Writer
|
||||
banner func(*Cli) string
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Print writes to the CLI output (defaults to os.Stdout).
|
||||
//
|
||||
// c.Cli().Print("hello %s", "world")
|
||||
func (cl *Cli) Print(format string, args ...any) {
|
||||
Print(cl.output, format, args...)
|
||||
}
|
||||
|
||||
|
||||
// Command returns the root command.
|
||||
func (c *Cli) Command() *Command {
|
||||
return c.rootCommand
|
||||
// SetOutput sets the CLI output writer.
|
||||
//
|
||||
// c.Cli().SetOutput(os.Stderr)
|
||||
func (cl *Cli) SetOutput(w io.Writer) {
|
||||
cl.output = w
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// Run resolves os.Args to a command path and executes it.
|
||||
//
|
||||
// c.Cli().Run()
|
||||
// c.Cli().Run("deploy", "to", "homelab")
|
||||
func (cl *Cli) Run(args ...string) Result {
|
||||
if len(args) == 0 {
|
||||
args = os.Args[1:]
|
||||
}
|
||||
if err := c.rootCommand.run(args); err != nil {
|
||||
return err
|
||||
|
||||
clean := FilterArgs(args)
|
||||
|
||||
if cl.core == nil || cl.core.commands == nil {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
if c.postRunCommand != nil {
|
||||
if err := c.postRunCommand(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// DefaultCommand sets the command to run when no other commands are given.
|
||||
func (c *Cli) DefaultCommand(defaultCommand *Command) *Cli {
|
||||
c.defaultCommand = defaultCommand
|
||||
return c
|
||||
cl.core.commands.mu.RLock()
|
||||
cmdCount := len(cl.core.commands.commands)
|
||||
cl.core.commands.mu.RUnlock()
|
||||
|
||||
if cmdCount == 0 {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// NewChildCommand creates a new subcommand.
|
||||
func (c *Cli) NewChildCommand(name string, description ...string) *Command {
|
||||
return c.rootCommand.NewChildCommand(name, description...)
|
||||
// Resolve command path from args
|
||||
var cmd *Command
|
||||
var remaining []string
|
||||
|
||||
cl.core.commands.mu.RLock()
|
||||
for i := len(clean); i > 0; i-- {
|
||||
path := JoinPath(clean[:i]...)
|
||||
if c, ok := cl.core.commands.commands[path]; ok {
|
||||
cmd = c
|
||||
remaining = clean[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
cl.core.commands.mu.RUnlock()
|
||||
|
||||
if cmd == nil {
|
||||
if cl.banner != nil {
|
||||
cl.Print(cl.banner(cl))
|
||||
}
|
||||
cl.PrintHelp()
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// NewChildCommandInheritFlags creates a new subcommand that inherits parent flags.
|
||||
func (c *Cli) NewChildCommandInheritFlags(name string, description ...string) *Command {
|
||||
return c.rootCommand.NewChildCommandInheritFlags(name, description...)
|
||||
// Build options from remaining args
|
||||
opts := Options{}
|
||||
for _, arg := range remaining {
|
||||
key, val, valid := ParseFlag(arg)
|
||||
if valid {
|
||||
if Contains(arg, "=") {
|
||||
opts = append(opts, Option{Key: key, Value: val})
|
||||
} else {
|
||||
opts = append(opts, Option{Key: key, Value: true})
|
||||
}
|
||||
} else if !IsFlag(arg) {
|
||||
opts = append(opts, Option{Key: "_arg", Value: arg})
|
||||
}
|
||||
}
|
||||
|
||||
// PreRun sets a function to call before running the command.
|
||||
func (c *Cli) PreRun(callback func(*Cli) error) {
|
||||
c.preRunCommand = callback
|
||||
if cmd.Action != nil {
|
||||
return cmd.Run(opts)
|
||||
}
|
||||
if cmd.Lifecycle != nil {
|
||||
return cmd.Start(opts)
|
||||
}
|
||||
return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
|
||||
}
|
||||
|
||||
// PostRun sets a function to call after running the command.
|
||||
func (c *Cli) PostRun(callback func(*Cli) error) {
|
||||
c.postRunCommand = callback
|
||||
// PrintHelp prints available commands.
|
||||
//
|
||||
// c.Cli().PrintHelp()
|
||||
func (cl *Cli) PrintHelp() {
|
||||
if cl.core == nil || cl.core.commands == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
name := ""
|
||||
if cl.core.app != nil {
|
||||
name = cl.core.app.Name
|
||||
}
|
||||
if name != "" {
|
||||
cl.Print("%s commands:", name)
|
||||
} else {
|
||||
cl.Print("Commands:")
|
||||
}
|
||||
|
||||
// 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
|
||||
cl.core.commands.mu.RLock()
|
||||
defer cl.core.commands.mu.RUnlock()
|
||||
|
||||
for path, cmd := range cl.core.commands.commands {
|
||||
if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) {
|
||||
continue
|
||||
}
|
||||
tr := cl.core.I18n().Translate(cmd.I18nKey())
|
||||
desc, _ := tr.Value.(string)
|
||||
if desc == "" || desc == cmd.I18nKey() {
|
||||
cl.Print(" %s", path)
|
||||
} else {
|
||||
cl.Print(" %-30s %s", path, desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// SetBanner sets the banner function.
|
||||
//
|
||||
// c.Cli().SetBanner(func(_ *core.Cli) string { return "My App v1.0" })
|
||||
func (cl *Cli) SetBanner(fn func(*Cli) string) {
|
||||
cl.banner = fn
|
||||
}
|
||||
|
||||
// AddFlags adds struct-tagged flags to the root command.
|
||||
func (c *Cli) AddFlags(flags any) *Cli {
|
||||
c.rootCommand.AddFlags(flags)
|
||||
return c
|
||||
// Banner returns the banner string.
|
||||
func (cl *Cli) Banner() string {
|
||||
if cl.banner != nil {
|
||||
return cl.banner(cl)
|
||||
}
|
||||
|
||||
// Action defines an action for the root command.
|
||||
func (c *Cli) Action(callback CliAction) *Cli {
|
||||
c.rootCommand.Action(callback)
|
||||
return c
|
||||
if cl.core != nil && cl.core.app != nil && cl.core.app.Name != "" {
|
||||
return cl.core.app.Name
|
||||
}
|
||||
|
||||
// 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
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
1478
pkg/core/command.go
1478
pkg/core/command.go
File diff suppressed because it is too large
Load diff
|
|
@ -27,13 +27,13 @@ func NewConfigVar[T any](val T) ConfigVar[T] {
|
|||
return ConfigVar[T]{val: val, set: true}
|
||||
}
|
||||
|
||||
// ConfigOpts holds configuration data.
|
||||
type ConfigOpts struct {
|
||||
// ConfigOptions holds configuration data.
|
||||
type ConfigOptions struct {
|
||||
Settings map[string]any
|
||||
Features map[string]bool
|
||||
}
|
||||
|
||||
func (o *ConfigOpts) init() {
|
||||
func (o *ConfigOptions) init() {
|
||||
if o.Settings == nil {
|
||||
o.Settings = make(map[string]any)
|
||||
}
|
||||
|
|
@ -44,27 +44,33 @@ func (o *ConfigOpts) init() {
|
|||
|
||||
// Config holds configuration settings and feature flags.
|
||||
type Config struct {
|
||||
*ConfigOpts
|
||||
*ConfigOptions
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Set stores a configuration value by key.
|
||||
func (e *Config) Set(key string, val any) {
|
||||
e.mu.Lock()
|
||||
e.ConfigOpts.init()
|
||||
if e.ConfigOptions == nil {
|
||||
e.ConfigOptions = &ConfigOptions{}
|
||||
}
|
||||
e.ConfigOptions.init()
|
||||
e.Settings[key] = val
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value by key.
|
||||
func (e *Config) Get(key string) (any, bool) {
|
||||
func (e *Config) Get(key string) Result {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
if e.ConfigOpts == nil || e.Settings == nil {
|
||||
return nil, false
|
||||
if e.ConfigOptions == nil || e.Settings == nil {
|
||||
return Result{}
|
||||
}
|
||||
val, ok := e.Settings[key]
|
||||
return val, ok
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
return Result{val, true}
|
||||
}
|
||||
|
||||
func (e *Config) String(key string) string { return ConfigGet[string](e, key) }
|
||||
|
|
@ -73,12 +79,12 @@ 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 {
|
||||
r := e.Get(key)
|
||||
if !r.OK {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
typed, _ := val.(T)
|
||||
typed, _ := r.Value.(T)
|
||||
return typed
|
||||
}
|
||||
|
||||
|
|
@ -86,28 +92,39 @@ func ConfigGet[T any](e *Config, key string) T {
|
|||
|
||||
func (e *Config) Enable(feature string) {
|
||||
e.mu.Lock()
|
||||
e.ConfigOpts.init()
|
||||
if e.ConfigOptions == nil {
|
||||
e.ConfigOptions = &ConfigOptions{}
|
||||
}
|
||||
e.ConfigOptions.init()
|
||||
e.Features[feature] = true
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Config) Disable(feature string) {
|
||||
e.mu.Lock()
|
||||
e.ConfigOpts.init()
|
||||
if e.ConfigOptions == nil {
|
||||
e.ConfigOptions = &ConfigOptions{}
|
||||
}
|
||||
e.ConfigOptions.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
|
||||
defer e.mu.RUnlock()
|
||||
if e.ConfigOptions == nil || e.Features == nil {
|
||||
return false
|
||||
}
|
||||
return e.Features[feature]
|
||||
}
|
||||
|
||||
func (e *Config) EnabledFeatures() []string {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
if e.ConfigOptions == nil || e.Features == nil {
|
||||
return nil
|
||||
}
|
||||
var result []string
|
||||
for k, v := range e.Features {
|
||||
if v {
|
||||
|
|
|
|||
|
|
@ -6,21 +6,8 @@ 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
|
||||
|
||||
|
|
@ -30,18 +17,18 @@ 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 {
|
||||
// TaskWithIdentifier is an optional interface for tasks that need to know their assigned identifier.
|
||||
type TaskWithIdentifier interface {
|
||||
Task
|
||||
SetTaskID(id string)
|
||||
GetTaskID() string
|
||||
SetTaskIdentifier(id string)
|
||||
GetTaskIdentifier() string
|
||||
}
|
||||
|
||||
// QueryHandler handles Query requests. Returns (result, handled, error).
|
||||
type QueryHandler func(*Core, Query) (any, bool, error)
|
||||
// QueryHandler handles Query requests. Returns Result{Value, OK}.
|
||||
type QueryHandler func(*Core, Query) Result
|
||||
|
||||
// TaskHandler handles Task requests. Returns (result, handled, error).
|
||||
type TaskHandler func(*Core, Task) (any, bool, error)
|
||||
// TaskHandler handles Task requests. Returns Result{Value, OK}.
|
||||
type TaskHandler func(*Core, Task) Result
|
||||
|
||||
// Startable is implemented by services that need startup initialisation.
|
||||
type Startable interface {
|
||||
|
|
@ -53,31 +40,25 @@ 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
|
||||
TaskIdentifier string
|
||||
Task Task
|
||||
}
|
||||
|
||||
type ActionTaskProgress struct {
|
||||
TaskID string
|
||||
TaskIdentifier string
|
||||
Task Task
|
||||
Progress float64
|
||||
Message string
|
||||
}
|
||||
|
||||
type ActionTaskCompleted struct {
|
||||
TaskID string
|
||||
TaskIdentifier string
|
||||
Task Task
|
||||
Result any
|
||||
Error error
|
||||
|
|
@ -85,114 +66,40 @@ type ActionTaskCompleted struct {
|
|||
|
||||
// --- Constructor ---
|
||||
|
||||
// New creates a Core instance with the provided options.
|
||||
func New(opts ...Option) (*Core, error) {
|
||||
// New creates a Core instance.
|
||||
//
|
||||
// c := core.New(core.Options{
|
||||
// {Key: "name", Value: "myapp"},
|
||||
// })
|
||||
func New(opts ...Options) *Core {
|
||||
c := &Core{
|
||||
app: &App{},
|
||||
data: &Data{},
|
||||
drive: &Drive{},
|
||||
fs: &Fs{root: "/"},
|
||||
cfg: &Config{ConfigOpts: &ConfigOpts{}},
|
||||
err: &ErrPan{},
|
||||
log: &ErrLog{&ErrOpts{Log: defaultLog}},
|
||||
cli: &Cli{opts: &CliOpts{}},
|
||||
srv: &Service{},
|
||||
config: &Config{ConfigOptions: &ConfigOptions{}},
|
||||
error: &ErrorPanic{},
|
||||
log: &ErrorLog{log: Default()},
|
||||
lock: &Lock{},
|
||||
ipc: &Ipc{},
|
||||
i18n: &I18n{},
|
||||
services: &serviceRegistry{services: make(map[string]*Service)},
|
||||
commands: &commandRegistry{commands: make(map[string]*Command)},
|
||||
}
|
||||
c.context, c.cancel = context.WithCancel(context.Background())
|
||||
|
||||
for _, o := range opts {
|
||||
if err := o(c); err != nil {
|
||||
return nil, err
|
||||
if len(opts) > 0 {
|
||||
cp := make(Options, len(opts[0]))
|
||||
copy(cp, opts[0])
|
||||
c.options = &cp
|
||||
name := cp.String("name")
|
||||
if name != "" {
|
||||
c.app.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
c.LockApply()
|
||||
return c, nil
|
||||
}
|
||||
// Init Cli surface with Core reference
|
||||
c.cli = &Cli{core: c}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
|
@ -14,52 +15,61 @@ import (
|
|||
|
||||
// Core is the central application object that manages services, assets, and communication.
|
||||
type Core struct {
|
||||
options *Options // c.Options() — Input configuration used to create this Core
|
||||
app *App // c.App() — Application identity + optional GUI runtime
|
||||
emb *Embed // c.Embed() — Mounted embedded assets (read-only)
|
||||
data *Data // c.Data() — Embedded/stored content from packages
|
||||
drive *Drive // c.Drive() — Resource handle registry (transports)
|
||||
fs *Fs // c.Fs() — Local filesystem I/O (sandboxable)
|
||||
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
|
||||
config *Config // c.Config() — Configuration, settings, feature flags
|
||||
error *ErrorPanic // c.Error() — Panic recovery and crash reporting
|
||||
log *ErrorLog // c.Log() — Structured logging + error wrapping
|
||||
cli *Cli // c.Cli() — CLI surface layer
|
||||
commands *commandRegistry // c.Command("path") — Command tree
|
||||
services *serviceRegistry // c.Service("name") — Service registry
|
||||
lock *Lock // c.Lock("name") — Named mutexes
|
||||
ipc *Ipc // c.IPC() — Message bus for IPC
|
||||
i18n *I18n // c.I18n() — Internationalisation and locale collection
|
||||
|
||||
context context.Context
|
||||
cancel context.CancelFunc
|
||||
taskIDCounter atomic.Uint64
|
||||
wg sync.WaitGroup
|
||||
waitGroup sync.WaitGroup
|
||||
shutdown atomic.Bool
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
func (c *Core) Options() *Options { return c.options }
|
||||
func (c *Core) App() *App { return c.app }
|
||||
func (c *Core) Embed() *Embed { return c.emb }
|
||||
func (c *Core) Data() *Data { return c.data }
|
||||
func (c *Core) Drive() *Drive { return c.drive }
|
||||
func (c *Core) Embed() Result { return c.data.Get("app") } // legacy — use Data()
|
||||
func (c *Core) Fs() *Fs { return c.fs }
|
||||
func (c *Core) Config() *Config { return c.cfg }
|
||||
func (c *Core) Error() *ErrPan { return c.err }
|
||||
func (c *Core) Log() *ErrLog { return c.log }
|
||||
func (c *Core) Config() *Config { return c.config }
|
||||
func (c *Core) Error() *ErrorPanic { return c.error }
|
||||
func (c *Core) Log() *ErrorLog { return c.log }
|
||||
func (c *Core) Cli() *Cli { return c.cli }
|
||||
func (c *Core) IPC() *Ipc { return c.ipc }
|
||||
func (c *Core) I18n() *I18n { return c.i18n }
|
||||
func (c *Core) Context() context.Context { return c.context }
|
||||
func (c *Core) Core() *Core { return c }
|
||||
|
||||
// --- IPC (uppercase aliases) ---
|
||||
|
||||
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) }
|
||||
func (c *Core) ACTION(msg Message) Result { return c.Action(msg) }
|
||||
func (c *Core) QUERY(q Query) Result { return c.Query(q) }
|
||||
func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) }
|
||||
func (c *Core) PERFORM(t Task) Result { return c.Perform(t) }
|
||||
|
||||
// --- Error+Log ---
|
||||
|
||||
// LogError logs an error and returns a wrapped error.
|
||||
func (c *Core) LogError(err error, op, msg string) error {
|
||||
// LogError logs an error and returns the Result from ErrorLog.
|
||||
func (c *Core) LogError(err error, op, msg string) Result {
|
||||
return c.log.Error(err, op, msg)
|
||||
}
|
||||
|
||||
// LogWarn logs a warning and returns a wrapped error.
|
||||
func (c *Core) LogWarn(err error, op, msg string) error {
|
||||
// LogWarn logs a warning and returns the Result from ErrorLog.
|
||||
func (c *Core) LogWarn(err error, op, msg string) Result {
|
||||
return c.log.Warn(err, op, msg)
|
||||
}
|
||||
|
||||
|
|
|
|||
202
pkg/core/data.go
Normal file
202
pkg/core/data.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Data is the embedded/stored content system for core packages.
|
||||
// Packages mount their embedded content here and other packages
|
||||
// read from it by path.
|
||||
//
|
||||
// Mount a package's assets:
|
||||
//
|
||||
// c.Data().New(core.Options{
|
||||
// {Key: "name", Value: "brain"},
|
||||
// {Key: "source", Value: brainFS},
|
||||
// {Key: "path", Value: "prompts"},
|
||||
// })
|
||||
//
|
||||
// Read from any mounted path:
|
||||
//
|
||||
// content := c.Data().ReadString("brain/coding.md")
|
||||
// entries := c.Data().List("agent/flow")
|
||||
//
|
||||
// Extract a template directory:
|
||||
//
|
||||
// c.Data().Extract("agent/workspace/default", "/tmp/ws", data)
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Data manages mounted embedded filesystems from core packages.
|
||||
type Data struct {
|
||||
mounts map[string]*Embed
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New registers an embedded filesystem under a named prefix.
|
||||
//
|
||||
// c.Data().New(core.Options{
|
||||
// {Key: "name", Value: "brain"},
|
||||
// {Key: "source", Value: brainFS},
|
||||
// {Key: "path", Value: "prompts"},
|
||||
// })
|
||||
func (d *Data) New(opts Options) Result {
|
||||
name := opts.String("name")
|
||||
if name == "" {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
r := opts.Get("source")
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
|
||||
fsys, ok := r.Value.(fs.FS)
|
||||
if !ok {
|
||||
return Result{E("data.New", "source is not fs.FS", nil), false}
|
||||
}
|
||||
|
||||
path := opts.String("path")
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.mounts == nil {
|
||||
d.mounts = make(map[string]*Embed)
|
||||
}
|
||||
|
||||
mr := Mount(fsys, path)
|
||||
if !mr.OK {
|
||||
return mr
|
||||
}
|
||||
|
||||
emb := mr.Value.(*Embed)
|
||||
d.mounts[name] = emb
|
||||
return Result{emb, true}
|
||||
}
|
||||
|
||||
// Get returns the Embed for a named mount point.
|
||||
//
|
||||
// r := c.Data().Get("brain")
|
||||
// if r.OK { emb := r.Value.(*Embed) }
|
||||
func (d *Data) Get(name string) Result {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if d.mounts == nil {
|
||||
return Result{}
|
||||
}
|
||||
emb, ok := d.mounts[name]
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
return Result{emb, true}
|
||||
}
|
||||
|
||||
// resolve splits a path like "brain/coding.md" into mount name + relative path.
|
||||
func (d *Data) resolve(path string) (*Embed, string) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
parts := SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil, ""
|
||||
}
|
||||
if d.mounts == nil {
|
||||
return nil, ""
|
||||
}
|
||||
emb := d.mounts[parts[0]]
|
||||
return emb, parts[1]
|
||||
}
|
||||
|
||||
// ReadFile reads a file by full path.
|
||||
//
|
||||
// r := c.Data().ReadFile("brain/prompts/coding.md")
|
||||
// if r.OK { data := r.Value.([]byte) }
|
||||
func (d *Data) ReadFile(path string) Result {
|
||||
emb, rel := d.resolve(path)
|
||||
if emb == nil {
|
||||
return Result{}
|
||||
}
|
||||
return emb.ReadFile(rel)
|
||||
}
|
||||
|
||||
// ReadString reads a file as a string.
|
||||
//
|
||||
// r := c.Data().ReadString("agent/flow/deploy/to/homelab.yaml")
|
||||
// if r.OK { content := r.Value.(string) }
|
||||
func (d *Data) ReadString(path string) Result {
|
||||
r := d.ReadFile(path)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{string(r.Value.([]byte)), true}
|
||||
}
|
||||
|
||||
// List returns directory entries at a path.
|
||||
//
|
||||
// r := c.Data().List("agent/persona/code")
|
||||
// if r.OK { entries := r.Value.([]fs.DirEntry) }
|
||||
func (d *Data) List(path string) Result {
|
||||
emb, rel := d.resolve(path)
|
||||
if emb == nil {
|
||||
return Result{}
|
||||
}
|
||||
r := emb.ReadDir(rel)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{r.Value, true}
|
||||
}
|
||||
|
||||
// ListNames returns filenames (without extensions) at a path.
|
||||
//
|
||||
// r := c.Data().ListNames("agent/flow")
|
||||
// if r.OK { names := r.Value.([]string) }
|
||||
func (d *Data) ListNames(path string) Result {
|
||||
r := d.List(path)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
entries := r.Value.([]fs.DirEntry)
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.IsDir() {
|
||||
name = TrimSuffix(name, filepath.Ext(name))
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return Result{names, true}
|
||||
}
|
||||
|
||||
// Extract copies a template directory to targetDir.
|
||||
//
|
||||
// r := c.Data().Extract("agent/workspace/default", "/tmp/ws", templateData)
|
||||
func (d *Data) Extract(path, targetDir string, templateData any) Result {
|
||||
emb, rel := d.resolve(path)
|
||||
if emb == nil {
|
||||
return Result{}
|
||||
}
|
||||
r := emb.Sub(rel)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Extract(r.Value.(*Embed).FS(), targetDir, templateData)
|
||||
}
|
||||
|
||||
// Mounts returns the names of all mounted content.
|
||||
//
|
||||
// names := c.Data().Mounts()
|
||||
func (d *Data) Mounts() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
var names []string
|
||||
for k := range d.mounts {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
112
pkg/core/drive.go
Normal file
112
pkg/core/drive.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Drive is the resource handle registry for transport connections.
|
||||
// Packages register their transport handles (API, MCP, SSH, VPN)
|
||||
// and other packages access them by name.
|
||||
//
|
||||
// Register a transport:
|
||||
//
|
||||
// c.Drive().New(core.Options{
|
||||
// {Key: "name", Value: "api"},
|
||||
// {Key: "transport", Value: "https://api.lthn.ai"},
|
||||
// })
|
||||
// c.Drive().New(core.Options{
|
||||
// {Key: "name", Value: "ssh"},
|
||||
// {Key: "transport", Value: "ssh://claude@10.69.69.165"},
|
||||
// })
|
||||
// c.Drive().New(core.Options{
|
||||
// {Key: "name", Value: "mcp"},
|
||||
// {Key: "transport", Value: "mcp://mcp.lthn.sh"},
|
||||
// })
|
||||
//
|
||||
// Retrieve a handle:
|
||||
//
|
||||
// api := c.Drive().Get("api")
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DriveHandle holds a named transport resource.
|
||||
type DriveHandle struct {
|
||||
Name string
|
||||
Transport string
|
||||
Options Options
|
||||
}
|
||||
|
||||
// Drive manages named transport handles.
|
||||
type Drive struct {
|
||||
handles map[string]*DriveHandle
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New registers a transport handle.
|
||||
//
|
||||
// c.Drive().New(core.Options{
|
||||
// {Key: "name", Value: "api"},
|
||||
// {Key: "transport", Value: "https://api.lthn.ai"},
|
||||
// })
|
||||
func (d *Drive) New(opts Options) Result {
|
||||
name := opts.String("name")
|
||||
if name == "" {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
transport := opts.String("transport")
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.handles == nil {
|
||||
d.handles = make(map[string]*DriveHandle)
|
||||
}
|
||||
|
||||
cp := make(Options, len(opts))
|
||||
copy(cp, opts)
|
||||
handle := &DriveHandle{
|
||||
Name: name,
|
||||
Transport: transport,
|
||||
Options: cp,
|
||||
}
|
||||
|
||||
d.handles[name] = handle
|
||||
return Result{handle, true}
|
||||
}
|
||||
|
||||
// Get returns a handle by name.
|
||||
//
|
||||
// r := c.Drive().Get("api")
|
||||
// if r.OK { handle := r.Value.(*DriveHandle) }
|
||||
func (d *Drive) Get(name string) Result {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if d.handles == nil {
|
||||
return Result{}
|
||||
}
|
||||
h, ok := d.handles[name]
|
||||
if !ok {
|
||||
return Result{}
|
||||
}
|
||||
return Result{h, true}
|
||||
}
|
||||
|
||||
// Has returns true if a handle is registered.
|
||||
//
|
||||
// if c.Drive().Has("ssh") { ... }
|
||||
func (d *Drive) Has(name string) bool {
|
||||
return d.Get(name).OK
|
||||
}
|
||||
|
||||
// Names returns all registered handle names.
|
||||
//
|
||||
// names := c.Drive().Names()
|
||||
func (d *Drive) Names() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
var names []string
|
||||
for k := range d.handles {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
|
@ -34,7 +34,6 @@ import (
|
|||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
)
|
||||
|
|
@ -65,24 +64,38 @@ func AddAsset(group, name, data string) {
|
|||
}
|
||||
|
||||
// GetAsset retrieves and decompresses a packed asset.
|
||||
func GetAsset(group, name string) (string, error) {
|
||||
//
|
||||
// r := core.GetAsset("mygroup", "greeting")
|
||||
// if r.OK { content := r.Value.(string) }
|
||||
func GetAsset(group, name string) Result {
|
||||
assetGroupsMu.RLock()
|
||||
g, ok := assetGroups[group]
|
||||
assetGroupsMu.RUnlock()
|
||||
if !ok {
|
||||
return "", E("core.GetAsset", fmt.Sprintf("asset group %q not found", group), nil)
|
||||
assetGroupsMu.RUnlock()
|
||||
return Result{}
|
||||
}
|
||||
data, ok := g.assets[name]
|
||||
assetGroupsMu.RUnlock()
|
||||
if !ok {
|
||||
return "", E("core.GetAsset", fmt.Sprintf("asset %q not found in group %q", name, group), nil)
|
||||
return Result{}
|
||||
}
|
||||
return decompress(data)
|
||||
s, err := decompress(data)
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{s, true}
|
||||
}
|
||||
|
||||
// GetAssetBytes retrieves a packed asset as bytes.
|
||||
func GetAssetBytes(group, name string) ([]byte, error) {
|
||||
s, err := GetAsset(group, name)
|
||||
return []byte(s), err
|
||||
//
|
||||
// r := core.GetAssetBytes("mygroup", "file")
|
||||
// if r.OK { data := r.Value.([]byte) }
|
||||
func GetAssetBytes(group, name string) Result {
|
||||
r := GetAsset(group, name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{[]byte(r.Value.(string)), true}
|
||||
}
|
||||
|
||||
// --- Build-time: AST Scanner ---
|
||||
|
|
@ -98,14 +111,14 @@ type AssetRef struct {
|
|||
// ScannedPackage holds all asset references from a set of source files.
|
||||
type ScannedPackage struct {
|
||||
PackageName string
|
||||
BaseDir string
|
||||
BaseDirectory string
|
||||
Groups []string
|
||||
Assets []AssetRef
|
||||
}
|
||||
|
||||
// ScanAssets parses Go source files and finds asset references.
|
||||
// Looks for calls to: core.GetAsset("group", "name"), core.AddAsset, etc.
|
||||
func ScanAssets(filenames []string) ([]ScannedPackage, error) {
|
||||
func ScanAssets(filenames []string) Result {
|
||||
packageMap := make(map[string]*ScannedPackage)
|
||||
var scanErr error
|
||||
|
||||
|
|
@ -113,13 +126,13 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) {
|
|||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(filename)
|
||||
pkg, ok := packageMap[baseDir]
|
||||
if !ok {
|
||||
pkg = &ScannedPackage{BaseDir: baseDir}
|
||||
pkg = &ScannedPackage{BaseDirectory: baseDir}
|
||||
packageMap[baseDir] = pkg
|
||||
}
|
||||
pkg.PackageName = node.Name.Name
|
||||
|
|
@ -149,16 +162,16 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) {
|
|||
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, "\"")
|
||||
path := TrimPrefix(TrimSuffix(lit.Value, "\""), "\"")
|
||||
group := "."
|
||||
if len(call.Args) >= 2 {
|
||||
if glit, ok := call.Args[0].(*ast.BasicLit); ok {
|
||||
group = strings.Trim(glit.Value, "\"")
|
||||
group = TrimPrefix(TrimSuffix(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))
|
||||
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for asset", path, "in group", group))
|
||||
return false
|
||||
}
|
||||
pkg.Assets = append(pkg.Assets, AssetRef{
|
||||
|
|
@ -173,10 +186,10 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) {
|
|||
// 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, "\"")
|
||||
path := TrimPrefix(TrimSuffix(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))
|
||||
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for group", path))
|
||||
return false
|
||||
}
|
||||
pkg.Groups = append(pkg.Groups, fullPath)
|
||||
|
|
@ -189,7 +202,7 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) {
|
|||
return true
|
||||
})
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
return Result{scanErr, false}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,18 +210,18 @@ func ScanAssets(filenames []string) ([]ScannedPackage, error) {
|
|||
for _, pkg := range packageMap {
|
||||
result = append(result, *pkg)
|
||||
}
|
||||
return result, nil
|
||||
return Result{result, true}
|
||||
}
|
||||
|
||||
// GeneratePack creates Go source code that embeds the scanned assets.
|
||||
func GeneratePack(pkg ScannedPackage) (string, error) {
|
||||
var b strings.Builder
|
||||
func GeneratePack(pkg ScannedPackage) Result {
|
||||
b := NewBuilder()
|
||||
|
||||
b.WriteString(fmt.Sprintf("package %s\n\n", pkg.PackageName))
|
||||
b.WriteString("// Code generated by core pack. DO NOT EDIT.\n\n")
|
||||
|
||||
if len(pkg.Assets) == 0 && len(pkg.Groups) == 0 {
|
||||
return b.String(), nil
|
||||
return Result{b.String(), true}
|
||||
}
|
||||
|
||||
b.WriteString("import \"forge.lthn.ai/core/go/pkg/core\"\n\n")
|
||||
|
|
@ -219,7 +232,7 @@ func GeneratePack(pkg ScannedPackage) (string, error) {
|
|||
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))
|
||||
return Result{err, false}
|
||||
}
|
||||
for _, file := range files {
|
||||
if packed[file] {
|
||||
|
|
@ -227,12 +240,12 @@ func GeneratePack(pkg ScannedPackage) (string, error) {
|
|||
}
|
||||
data, err := compressFile(file)
|
||||
if err != nil {
|
||||
return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q in group %q", file, groupPath))
|
||||
return Result{err, false}
|
||||
}
|
||||
localPath := strings.TrimPrefix(file, groupPath+"/")
|
||||
relGroup, err := filepath.Rel(pkg.BaseDir, groupPath)
|
||||
localPath := TrimPrefix(file, groupPath+"/")
|
||||
relGroup, err := filepath.Rel(pkg.BaseDirectory, groupPath)
|
||||
if err != nil {
|
||||
return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("could not determine relative path for group %q (base %q)", groupPath, pkg.BaseDir))
|
||||
return Result{err, false}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data))
|
||||
packed[file] = true
|
||||
|
|
@ -246,14 +259,14 @@ func GeneratePack(pkg ScannedPackage) (string, error) {
|
|||
}
|
||||
data, err := compressFile(asset.FullPath)
|
||||
if err != nil {
|
||||
return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q", asset.FullPath))
|
||||
return Result{err, false}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data))
|
||||
packed[asset.FullPath] = true
|
||||
}
|
||||
|
||||
b.WriteString("}\n")
|
||||
return b.String(), nil
|
||||
return Result{b.String(), true}
|
||||
}
|
||||
|
||||
// --- Compression ---
|
||||
|
|
@ -289,7 +302,7 @@ func compress(input string) (string, error) {
|
|||
}
|
||||
|
||||
func decompress(input string) (string, error) {
|
||||
b64 := base64.NewDecoder(base64.StdEncoding, strings.NewReader(input))
|
||||
b64 := base64.NewDecoder(base64.StdEncoding, NewReader(input))
|
||||
gz, err := gzip.NewReader(b64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -330,62 +343,104 @@ type Embed struct {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
//
|
||||
// r := core.Mount(myFS, "lib/prompts")
|
||||
// if r.OK { emb := r.Value.(*Embed) }
|
||||
func Mount(fsys fs.FS, basedir string) Result {
|
||||
s := &Embed{fsys: fsys, basedir: basedir}
|
||||
|
||||
// If 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
|
||||
if r := s.ReadDir("."); !r.OK {
|
||||
return r
|
||||
}
|
||||
return s, nil
|
||||
return Result{s, true}
|
||||
}
|
||||
|
||||
// MountEmbed creates a scoped view of an embed.FS.
|
||||
func MountEmbed(efs embed.FS, basedir string) (*Embed, error) {
|
||||
//
|
||||
// r := core.MountEmbed(myFS, "testdata")
|
||||
func MountEmbed(efs embed.FS, basedir string) Result {
|
||||
return Mount(efs, basedir)
|
||||
}
|
||||
|
||||
func (s *Embed) path(name string) string {
|
||||
return filepath.ToSlash(filepath.Join(s.basedir, name))
|
||||
func (s *Embed) path(name string) Result {
|
||||
joined := filepath.ToSlash(filepath.Join(s.basedir, name))
|
||||
if HasPrefix(joined, "..") || Contains(joined, "/../") || HasSuffix(joined, "/..") {
|
||||
return Result{E("embed.path", Concat("path traversal rejected: ", name), nil), false}
|
||||
}
|
||||
return Result{joined, true}
|
||||
}
|
||||
|
||||
// Open opens the named file for reading.
|
||||
func (s *Embed) Open(name string) (fs.File, error) {
|
||||
return s.fsys.Open(s.path(name))
|
||||
//
|
||||
// r := emb.Open("test.txt")
|
||||
// if r.OK { file := r.Value.(fs.File) }
|
||||
func (s *Embed) Open(name string) Result {
|
||||
r := s.path(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
f, err := s.fsys.Open(r.Value.(string))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{f, true}
|
||||
}
|
||||
|
||||
// ReadDir reads the named directory.
|
||||
func (s *Embed) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return fs.ReadDir(s.fsys, s.path(name))
|
||||
func (s *Embed) ReadDir(name string) Result {
|
||||
r := s.path(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return Result{}.Result(fs.ReadDir(s.fsys, r.Value.(string)))
|
||||
}
|
||||
|
||||
// ReadFile reads the named file.
|
||||
func (s *Embed) ReadFile(name string) ([]byte, error) {
|
||||
return fs.ReadFile(s.fsys, s.path(name))
|
||||
//
|
||||
// r := emb.ReadFile("test.txt")
|
||||
// if r.OK { data := r.Value.([]byte) }
|
||||
func (s *Embed) ReadFile(name string) Result {
|
||||
r := s.path(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
data, err := fs.ReadFile(s.fsys, r.Value.(string))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{data, true}
|
||||
}
|
||||
|
||||
// ReadString reads the named file as a string.
|
||||
func (s *Embed) ReadString(name string) (string, error) {
|
||||
data, err := s.ReadFile(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
//
|
||||
// r := emb.ReadString("test.txt")
|
||||
// if r.OK { content := r.Value.(string) }
|
||||
func (s *Embed) ReadString(name string) Result {
|
||||
r := s.ReadFile(name)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return string(data), nil
|
||||
return Result{string(r.Value.([]byte)), true}
|
||||
}
|
||||
|
||||
// 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
|
||||
//
|
||||
// r := emb.Sub("testdata")
|
||||
// if r.OK { sub := r.Value.(*Embed) }
|
||||
func (s *Embed) Sub(subDir string) Result {
|
||||
r := s.path(subDir)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
return &Embed{fsys: sub, basedir: "."}, nil
|
||||
sub, err := fs.Sub(s.fsys, r.Value.(string))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{&Embed{fsys: sub, basedir: "."}, true}
|
||||
}
|
||||
|
||||
// FS returns the underlying fs.FS.
|
||||
|
|
@ -402,8 +457,8 @@ func (s *Embed) EmbedFS() embed.FS {
|
|||
return embed.FS{}
|
||||
}
|
||||
|
||||
// BaseDir returns the basedir this Embed is anchored at.
|
||||
func (s *Embed) BaseDir() string {
|
||||
// BaseDirectory returns the base directory this Embed is anchored at.
|
||||
func (s *Embed) BaseDirectory() string {
|
||||
return s.basedir
|
||||
}
|
||||
|
||||
|
|
@ -433,7 +488,7 @@ type ExtractOptions struct {
|
|||
// {{.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 {
|
||||
func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) Result {
|
||||
opt := ExtractOptions{
|
||||
TemplateFilters: []string{".tmpl"},
|
||||
IgnoreFiles: make(map[string]struct{}),
|
||||
|
|
@ -454,10 +509,10 @@ func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) err
|
|||
// Ensure target directory exists
|
||||
targetDir, err := filepath.Abs(targetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
// Categorise files
|
||||
|
|
@ -488,14 +543,29 @@ func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) err
|
|||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
// safePath ensures a rendered path stays under targetDir.
|
||||
safePath := func(rendered string) (string, error) {
|
||||
abs, err := filepath.Abs(rendered)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !HasPrefix(abs, targetDir+string(filepath.Separator)) && abs != targetDir {
|
||||
return "", E("embed.Extract", Concat("path escapes target: ", abs), nil)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// Create directories (names may contain templates)
|
||||
for _, dir := range dirs {
|
||||
target := renderPath(filepath.Join(targetDir, dir), data)
|
||||
target, err := safePath(renderPath(filepath.Join(targetDir, dir), data))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -503,7 +573,7 @@ func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) err
|
|||
for _, path := range templateFiles {
|
||||
tmpl, err := template.ParseFS(fsys, path)
|
||||
if err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
targetFile := renderPath(filepath.Join(targetDir, path), data)
|
||||
|
|
@ -512,20 +582,23 @@ func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) err
|
|||
dir := filepath.Dir(targetFile)
|
||||
name := filepath.Base(targetFile)
|
||||
for _, filter := range opt.TemplateFilters {
|
||||
name = strings.ReplaceAll(name, filter, "")
|
||||
name = Replace(name, filter, "")
|
||||
}
|
||||
if renamed := opt.RenameFiles[name]; renamed != "" {
|
||||
name = renamed
|
||||
}
|
||||
targetFile = filepath.Join(dir, name)
|
||||
targetFile, err = safePath(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
f, err := os.Create(targetFile)
|
||||
if err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := tmpl.Execute(f, data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
|
@ -537,18 +610,21 @@ func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) err
|
|||
if renamed := opt.RenameFiles[name]; renamed != "" {
|
||||
targetPath = filepath.Join(filepath.Dir(path), renamed)
|
||||
}
|
||||
target := renderPath(filepath.Join(targetDir, targetPath), data)
|
||||
target, err := safePath(renderPath(filepath.Join(targetDir, targetPath), data))
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := copyFile(fsys, path, target); err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
func isTemplate(filename string, filters []string) bool {
|
||||
for _, f := range filters {
|
||||
if strings.Contains(filename, f) {
|
||||
if Contains(filename, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,57 +9,55 @@ 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 {
|
||||
// ErrorSink is the shared interface for error reporting.
|
||||
// Implemented by ErrorLog (structured logging) and ErrorPanic (panic recovery).
|
||||
type ErrorSink interface {
|
||||
Error(msg string, keyvals ...any)
|
||||
Warn(msg string, keyvals ...any)
|
||||
}
|
||||
|
||||
var _ ErrSink = (*Log)(nil)
|
||||
var _ ErrorSink = (*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)
|
||||
Operation string // Operation being performed (e.g., "user.Save")
|
||||
Message string // Human-readable message
|
||||
Cause error // Underlying error (optional)
|
||||
Code string // Error code (optional, e.g., "VALIDATION_FAILED")
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *Err) Error() string {
|
||||
var prefix string
|
||||
if e.Op != "" {
|
||||
prefix = e.Op + ": "
|
||||
if e.Operation != "" {
|
||||
prefix = e.Operation + ": "
|
||||
}
|
||||
if e.Err != nil {
|
||||
if e.Cause != nil {
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
|
||||
return Concat(prefix, e.Message, " [", e.Code, "]: ", e.Cause.Error())
|
||||
}
|
||||
return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
|
||||
return Concat(prefix, e.Message, ": ", e.Cause.Error())
|
||||
}
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
|
||||
return Concat(prefix, e.Message, " [", e.Code, "]")
|
||||
}
|
||||
return fmt.Sprintf("%s%s", prefix, e.Msg)
|
||||
return Concat(prefix, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error for use with errors.Is and errors.As.
|
||||
func (e *Err) Unwrap() error {
|
||||
return e.Err
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// --- Error Creation Functions ---
|
||||
|
|
@ -72,7 +70,7 @@ func (e *Err) Unwrap() error {
|
|||
// 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}
|
||||
return &Err{Operation: op, Message: msg, Cause: err}
|
||||
}
|
||||
|
||||
// Wrap wraps an error with operation context.
|
||||
|
|
@ -89,9 +87,9 @@ func Wrap(err error, op, msg string) error {
|
|||
// 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{Operation: op, Message: msg, Cause: err, Code: logErr.Code}
|
||||
}
|
||||
return &Err{Op: op, Msg: msg, Err: err}
|
||||
return &Err{Operation: op, Message: msg, Cause: err}
|
||||
}
|
||||
|
||||
// WrapCode wraps an error with operation context and error code.
|
||||
|
|
@ -105,7 +103,7 @@ 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}
|
||||
return &Err{Operation: op, Message: msg, Cause: err, Code: code}
|
||||
}
|
||||
|
||||
// NewCode creates an error with just code and message (no underlying error).
|
||||
|
|
@ -115,7 +113,7 @@ func WrapCode(err error, code, op, msg string) error {
|
|||
//
|
||||
// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found")
|
||||
func NewCode(code, msg string) error {
|
||||
return &Err{Msg: msg, Code: code}
|
||||
return &Err{Message: msg, Code: code}
|
||||
}
|
||||
|
||||
// --- Standard Library Wrappers ---
|
||||
|
|
@ -138,27 +136,28 @@ 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 {
|
||||
// ErrorJoin combines multiple errors into one.
|
||||
//
|
||||
// core.ErrorJoin(err1, err2, err3)
|
||||
func ErrorJoin(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// --- Error Introspection Helpers ---
|
||||
|
||||
// Op extracts the operation name from an error.
|
||||
// Operation extracts the operation name from an error.
|
||||
// Returns empty string if the error is not an *Err.
|
||||
func Op(err error) string {
|
||||
func Operation(err error) string {
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Op
|
||||
return e.Operation
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrCode extracts the error code from an error.
|
||||
// ErrorCode 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 {
|
||||
func ErrorCode(err error) string {
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Code
|
||||
|
|
@ -174,7 +173,7 @@ func ErrorMessage(err error) string {
|
|||
}
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Msg
|
||||
return e.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
|
@ -194,14 +193,14 @@ func Root(err error) error {
|
|||
}
|
||||
}
|
||||
|
||||
// AllOps returns an iterator over all operational contexts in the error chain.
|
||||
// AllOperations 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] {
|
||||
func AllOperations(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) {
|
||||
if e.Operation != "" {
|
||||
if !yield(e.Operation) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +214,7 @@ func AllOps(err error) iter.Seq[string] {
|
|||
// It returns an empty slice if no operational context is found.
|
||||
func StackTrace(err error) []string {
|
||||
var stack []string
|
||||
for op := range AllOps(err) {
|
||||
for op := range AllOperations(err) {
|
||||
stack = append(stack, op)
|
||||
}
|
||||
return stack
|
||||
|
|
@ -224,64 +223,54 @@ func StackTrace(err error) []string {
|
|||
// FormatStackTrace returns a pretty-printed logical stack trace.
|
||||
func FormatStackTrace(err error) string {
|
||||
var ops []string
|
||||
for op := range AllOps(err) {
|
||||
for op := range AllOperations(err) {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(ops, " -> ")
|
||||
return Join(" -> ", ops...)
|
||||
}
|
||||
|
||||
// --- ErrLog: Log-and-Return Error Helpers ---
|
||||
// --- ErrorLog: Log-and-Return Error Helpers ---
|
||||
|
||||
// ErrOpts holds shared options for error subsystems.
|
||||
type ErrOpts struct {
|
||||
Log *Log
|
||||
}
|
||||
|
||||
// ErrLog combines error creation with logging.
|
||||
// ErrorLog combines error creation with logging.
|
||||
// Primary action: return an error. Secondary: log it.
|
||||
type ErrLog struct {
|
||||
*ErrOpts
|
||||
type ErrorLog struct {
|
||||
log *Log
|
||||
}
|
||||
|
||||
// NewErrLog creates an ErrLog (consumer convenience).
|
||||
func NewErrLog(opts *ErrOpts) *ErrLog {
|
||||
return &ErrLog{opts}
|
||||
func (el *ErrorLog) logger() *Log {
|
||||
if el.log != nil {
|
||||
return el.log
|
||||
}
|
||||
return Default()
|
||||
}
|
||||
|
||||
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 {
|
||||
// Error logs at Error level and returns a Result with the wrapped error.
|
||||
func (el *ErrorLog) Error(err error, op, msg string) Result {
|
||||
if err == nil {
|
||||
return nil
|
||||
return Result{OK: true}
|
||||
}
|
||||
wrapped := Wrap(err, op, msg)
|
||||
el.log().Error(msg, "op", op, "err", err)
|
||||
return wrapped
|
||||
el.logger().Error(msg, "op", op, "err", err)
|
||||
return Result{wrapped, false}
|
||||
}
|
||||
|
||||
// Warn logs at Warn level and returns a wrapped error.
|
||||
func (el *ErrLog) Warn(err error, op, msg string) error {
|
||||
// Warn logs at Warn level and returns a Result with the wrapped error.
|
||||
func (el *ErrorLog) Warn(err error, op, msg string) Result {
|
||||
if err == nil {
|
||||
return nil
|
||||
return Result{OK: true}
|
||||
}
|
||||
wrapped := Wrap(err, op, msg)
|
||||
el.log().Warn(msg, "op", op, "err", err)
|
||||
return wrapped
|
||||
el.logger().Warn(msg, "op", op, "err", err)
|
||||
return Result{wrapped, false}
|
||||
}
|
||||
|
||||
// Must logs and panics if err is not nil.
|
||||
func (el *ErrLog) Must(err error, op, msg string) {
|
||||
func (el *ErrorLog) Must(err error, op, msg string) {
|
||||
if err != nil {
|
||||
el.log().Error(msg, "op", op, "err", err)
|
||||
el.logger().Error(msg, "op", op, "err", err)
|
||||
panic(Wrap(err, op, msg))
|
||||
}
|
||||
}
|
||||
|
|
@ -299,45 +288,21 @@ type CrashReport struct {
|
|||
|
||||
// CrashSystem holds system information at crash time.
|
||||
type CrashSystem struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
OperatingSystem string `json:"operatingsystem"`
|
||||
Architecture string `json:"architecture"`
|
||||
Version string `json:"go_version"`
|
||||
}
|
||||
|
||||
// ErrPan manages panic recovery and crash reporting.
|
||||
type ErrPan struct {
|
||||
// ErrorPanic manages panic recovery and crash reporting.
|
||||
type ErrorPanic 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() {
|
||||
func (h *ErrorPanic) Recover() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -348,7 +313,7 @@ func (h *ErrPan) Recover() {
|
|||
|
||||
err, ok := r.(error)
|
||||
if !ok {
|
||||
err = fmt.Errorf("%v", r)
|
||||
err = NewError(Sprint("panic: ", r))
|
||||
}
|
||||
|
||||
report := CrashReport{
|
||||
|
|
@ -356,8 +321,8 @@ func (h *ErrPan) Recover() {
|
|||
Error: err.Error(),
|
||||
Stack: string(debug.Stack()),
|
||||
System: CrashSystem{
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
OperatingSystem: runtime.GOOS,
|
||||
Architecture: runtime.GOARCH,
|
||||
Version: runtime.Version(),
|
||||
},
|
||||
Meta: maps.Clone(h.meta),
|
||||
|
|
@ -373,7 +338,7 @@ func (h *ErrPan) Recover() {
|
|||
}
|
||||
|
||||
// SafeGo runs a function in a goroutine with panic recovery.
|
||||
func (h *ErrPan) SafeGo(fn func()) {
|
||||
func (h *ErrorPanic) SafeGo(fn func()) {
|
||||
go func() {
|
||||
defer h.Recover()
|
||||
fn()
|
||||
|
|
@ -381,29 +346,29 @@ func (h *ErrPan) SafeGo(fn func()) {
|
|||
}
|
||||
|
||||
// Reports returns the last n crash reports from the file.
|
||||
func (h *ErrPan) Reports(n int) ([]CrashReport, error) {
|
||||
func (h *ErrorPanic) Reports(n int) Result {
|
||||
if h.filePath == "" {
|
||||
return nil, nil
|
||||
return Result{}
|
||||
}
|
||||
crashMu.Lock()
|
||||
defer crashMu.Unlock()
|
||||
data, err := os.ReadFile(h.filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Result{err, false}
|
||||
}
|
||||
var reports []CrashReport
|
||||
if err := json.Unmarshal(data, &reports); err != nil {
|
||||
return nil, err
|
||||
return Result{err, false}
|
||||
}
|
||||
if n <= 0 || len(reports) <= n {
|
||||
return reports, nil
|
||||
return Result{reports, true}
|
||||
}
|
||||
return reports[len(reports)-n:], nil
|
||||
return Result{reports[len(reports)-n:], true}
|
||||
}
|
||||
|
||||
var crashMu sync.Mutex
|
||||
|
||||
func (h *ErrPan) appendReport(report CrashReport) {
|
||||
func (h *ErrorPanic) appendReport(report CrashReport) {
|
||||
crashMu.Lock()
|
||||
defer crashMu.Unlock()
|
||||
|
||||
|
|
@ -415,8 +380,16 @@ func (h *ErrPan) appendReport(report CrashReport) {
|
|||
}
|
||||
|
||||
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)
|
||||
data, err := json.MarshalIndent(reports, "", " ")
|
||||
if err != nil {
|
||||
Default().Error(Concat("crash report marshal failed: ", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(h.filePath), 0755); err != nil {
|
||||
Default().Error(Concat("crash report dir failed: ", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(h.filePath, data, 0600); err != nil {
|
||||
Default().Error(Concat("crash report write failed: ", err.Error()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
pkg/core/fs.go
198
pkg/core/fs.go
|
|
@ -2,13 +2,9 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -17,7 +13,6 @@ 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 {
|
||||
|
|
@ -47,13 +42,13 @@ func (m *Fs) path(p string) string {
|
|||
}
|
||||
|
||||
// validatePath ensures the path is within the sandbox, following symlinks if they exist.
|
||||
func (m *Fs) validatePath(p string) (string, error) {
|
||||
func (m *Fs) validatePath(p string) Result {
|
||||
if m.root == "/" {
|
||||
return m.path(p), nil
|
||||
return Result{m.path(p), true}
|
||||
}
|
||||
|
||||
// Split the cleaned path into components
|
||||
parts := strings.Split(filepath.Clean("/"+p), string(os.PathSeparator))
|
||||
parts := Split(filepath.Clean("/"+p), string(os.PathSeparator))
|
||||
current := m.root
|
||||
|
||||
for _, part := range parts {
|
||||
|
|
@ -71,67 +66,77 @@ func (m *Fs) validatePath(p string) (string, error) {
|
|||
current = next
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
return Result{err, false}
|
||||
}
|
||||
|
||||
// Verify the resolved part is still within the root
|
||||
rel, err := filepath.Rel(m.root, realNext)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
if err != nil || 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",
|
||||
Print(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s",
|
||||
time.Now().Format(time.RFC3339), m.root, p, realNext, username)
|
||||
return "", os.ErrPermission // Path escapes sandbox
|
||||
if err == nil {
|
||||
err = E("fs.validatePath", Concat("sandbox escape: ", p, " resolves outside ", m.root), nil)
|
||||
}
|
||||
return Result{err, false}
|
||||
}
|
||||
current = realNext
|
||||
}
|
||||
|
||||
return current, nil
|
||||
return Result{current, true}
|
||||
}
|
||||
|
||||
// Read returns file contents as string.
|
||||
func (m *Fs) Read(p string) (string, error) {
|
||||
full, err := m.validatePath(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func (m *Fs) Read(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
data, err := os.ReadFile(vp.Value.(string))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return Result{err, false}
|
||||
}
|
||||
return string(data), nil
|
||||
return Result{string(data), true}
|
||||
}
|
||||
|
||||
// Write saves content to file, creating parent directories as needed.
|
||||
// Files are created with mode 0644. For sensitive files (keys, secrets),
|
||||
// use WriteMode with 0600.
|
||||
func (m *Fs) Write(p, content string) error {
|
||||
func (m *Fs) Write(p, content string) Result {
|
||||
return m.WriteMode(p, content, 0644)
|
||||
}
|
||||
|
||||
// WriteMode saves content to file with explicit permissions.
|
||||
// Use 0600 for sensitive files (encryption output, private keys, auth hashes).
|
||||
func (m *Fs) WriteMode(p, content string, mode os.FileMode) error {
|
||||
full, err := m.validatePath(p)
|
||||
if err != nil {
|
||||
return err
|
||||
func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return err
|
||||
return Result{err, false}
|
||||
}
|
||||
return os.WriteFile(full, []byte(content), mode)
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// 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
|
||||
func (m *Fs) EnsureDir(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return os.MkdirAll(full, 0755)
|
||||
if err := os.MkdirAll(vp.Value.(string), 0755); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// IsDir returns true if path is a directory.
|
||||
|
|
@ -139,11 +144,11 @@ func (m *Fs) IsDir(p string) bool {
|
|||
if p == "" {
|
||||
return false
|
||||
}
|
||||
full, err := m.validatePath(p)
|
||||
if err != nil {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
info, err := os.Stat(vp.Value.(string))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
|
|
@ -152,118 +157,131 @@ func (m *Fs) IsFile(p string) bool {
|
|||
if p == "" {
|
||||
return false
|
||||
}
|
||||
full, err := m.validatePath(p)
|
||||
if err != nil {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
info, err := os.Stat(vp.Value.(string))
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// Exists returns true if path exists.
|
||||
func (m *Fs) Exists(p string) bool {
|
||||
full, err := m.validatePath(p)
|
||||
if err != nil {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(full)
|
||||
_, err := os.Stat(vp.Value.(string))
|
||||
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
|
||||
func (m *Fs) List(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return os.ReadDir(full)
|
||||
return Result{}.Result(os.ReadDir(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// Stat returns file info.
|
||||
func (m *Fs) Stat(p string) (fs.FileInfo, error) {
|
||||
full, err := m.validatePath(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (m *Fs) Stat(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return os.Stat(full)
|
||||
return Result{}.Result(os.Stat(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// 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
|
||||
func (m *Fs) Open(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
return os.Open(full)
|
||||
return Result{}.Result(os.Open(vp.Value.(string)))
|
||||
}
|
||||
|
||||
// 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
|
||||
func (m *Fs) Create(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return nil, err
|
||||
return Result{err, false}
|
||||
}
|
||||
return os.Create(full)
|
||||
return Result{}.Result(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
|
||||
func (m *Fs) Append(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return nil, err
|
||||
return Result{err, false}
|
||||
}
|
||||
return os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
return Result{}.Result(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) {
|
||||
func (m *Fs) ReadStream(path string) Result {
|
||||
return m.Open(path)
|
||||
}
|
||||
|
||||
// WriteStream returns a writer for the file content.
|
||||
func (m *Fs) WriteStream(path string) (io.WriteCloser, error) {
|
||||
func (m *Fs) WriteStream(path string) Result {
|
||||
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
|
||||
func (m *Fs) Delete(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if full == "/" || full == os.Getenv("HOME") {
|
||||
return E("core.Delete", "refusing to delete protected path: "+full, nil)
|
||||
return Result{E("fs.Delete", Concat("refusing to delete protected path: ", full), nil), false}
|
||||
}
|
||||
return os.Remove(full)
|
||||
if err := os.Remove(full); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// DeleteAll removes a file or directory recursively.
|
||||
func (m *Fs) DeleteAll(p string) error {
|
||||
full, err := m.validatePath(p)
|
||||
if err != nil {
|
||||
return err
|
||||
func (m *Fs) DeleteAll(p string) Result {
|
||||
vp := m.validatePath(p)
|
||||
if !vp.OK {
|
||||
return vp
|
||||
}
|
||||
full := vp.Value.(string)
|
||||
if full == "/" || full == os.Getenv("HOME") {
|
||||
return E("core.DeleteAll", "refusing to delete protected path: "+full, nil)
|
||||
return Result{E("fs.DeleteAll", Concat("refusing to delete protected path: ", full), nil), false}
|
||||
}
|
||||
return os.RemoveAll(full)
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Rename moves a file or directory.
|
||||
func (m *Fs) Rename(oldPath, newPath string) error {
|
||||
oldFull, err := m.validatePath(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
func (m *Fs) Rename(oldPath, newPath string) Result {
|
||||
oldVp := m.validatePath(oldPath)
|
||||
if !oldVp.OK {
|
||||
return oldVp
|
||||
}
|
||||
newFull, err := m.validatePath(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
newVp := m.validatePath(newPath)
|
||||
if !newVp.OK {
|
||||
return newVp
|
||||
}
|
||||
return os.Rename(oldFull, newFull)
|
||||
if err := os.Rename(oldVp.Value.(string), newVp.Value.(string)); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import (
|
|||
// 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
|
||||
// Translate translates a message by its ID with optional arguments.
|
||||
Translate(messageID string, args ...any) Result
|
||||
// SetLanguage sets the active language (BCP47 tag, e.g., "en-GB", "de").
|
||||
SetLanguage(lang string) error
|
||||
// Language returns the current language code.
|
||||
|
|
@ -44,10 +44,10 @@ type LocaleProvider interface {
|
|||
type I18n struct {
|
||||
mu sync.RWMutex
|
||||
locales []*Embed // collected from LocaleProvider services
|
||||
locale string
|
||||
translator Translator // registered implementation (nil until set)
|
||||
}
|
||||
|
||||
|
||||
// AddLocales adds locale mounts (called during service registration).
|
||||
func (i *I18n) AddLocales(mounts ...*Embed) {
|
||||
i.mu.Lock()
|
||||
|
|
@ -56,12 +56,12 @@ func (i *I18n) AddLocales(mounts ...*Embed) {
|
|||
}
|
||||
|
||||
// Locales returns all collected locale mounts.
|
||||
func (i *I18n) Locales() []*Embed {
|
||||
func (i *I18n) Locales() Result {
|
||||
i.mu.RLock()
|
||||
out := make([]*Embed, len(i.locales))
|
||||
copy(out, i.locales)
|
||||
i.mu.RUnlock()
|
||||
return out
|
||||
return Result{out, true}
|
||||
}
|
||||
|
||||
// SetTranslator registers the translation implementation.
|
||||
|
|
@ -69,46 +69,59 @@ func (i *I18n) Locales() []*Embed {
|
|||
func (i *I18n) SetTranslator(t Translator) {
|
||||
i.mu.Lock()
|
||||
i.translator = t
|
||||
locale := i.locale
|
||||
i.mu.Unlock()
|
||||
if t != nil && locale != "" {
|
||||
_ = t.SetLanguage(locale)
|
||||
}
|
||||
}
|
||||
|
||||
// Translator returns the registered translation implementation, or nil.
|
||||
func (i *I18n) Translator() Translator {
|
||||
func (i *I18n) Translator() Result {
|
||||
i.mu.RLock()
|
||||
t := i.translator
|
||||
i.mu.RUnlock()
|
||||
return t
|
||||
if t == nil {
|
||||
return Result{}
|
||||
}
|
||||
return Result{t, true}
|
||||
}
|
||||
|
||||
// T translates a message. Returns the key as-is if no translator is registered.
|
||||
func (i *I18n) T(messageID string, args ...any) string {
|
||||
// Translate translates a message. Returns the key as-is if no translator is registered.
|
||||
func (i *I18n) Translate(messageID string, args ...any) Result {
|
||||
i.mu.RLock()
|
||||
t := i.translator
|
||||
i.mu.RUnlock()
|
||||
if t != nil {
|
||||
return t.T(messageID, args...)
|
||||
return t.Translate(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
return Result{messageID, true}
|
||||
}
|
||||
|
||||
// SetLanguage sets the active language. No-op if no translator is registered.
|
||||
func (i *I18n) SetLanguage(lang string) error {
|
||||
i.mu.RLock()
|
||||
// SetLanguage sets the active language and forwards to the translator if registered.
|
||||
func (i *I18n) SetLanguage(lang string) Result {
|
||||
if lang == "" {
|
||||
return Result{OK: true}
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.locale = lang
|
||||
t := i.translator
|
||||
i.mu.RUnlock()
|
||||
i.mu.Unlock()
|
||||
if t != nil {
|
||||
return t.SetLanguage(lang)
|
||||
if err := t.SetLanguage(lang); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Language returns the current language code, or "en" if no translator.
|
||||
// Language returns the current language code, or "en" if not set.
|
||||
func (i *I18n) Language() string {
|
||||
i.mu.RLock()
|
||||
t := i.translator
|
||||
locale := i.locale
|
||||
i.mu.RUnlock()
|
||||
if t != nil {
|
||||
return t.Language()
|
||||
if locale != "" {
|
||||
return locale
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
|
@ -15,7 +14,7 @@ import (
|
|||
// Ipc holds IPC dispatch data.
|
||||
type Ipc struct {
|
||||
ipcMu sync.RWMutex
|
||||
ipcHandlers []func(*Core, Message) error
|
||||
ipcHandlers []func(*Core, Message) Result
|
||||
|
||||
queryMu sync.RWMutex
|
||||
queryHandlers []QueryHandler
|
||||
|
|
@ -24,51 +23,46 @@ type Ipc struct {
|
|||
taskHandlers []TaskHandler
|
||||
}
|
||||
|
||||
func (c *Core) Action(msg Message) error {
|
||||
func (c *Core) Action(msg Message) Result {
|
||||
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)
|
||||
if r := h(c, msg); !r.OK {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return agg
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
func (c *Core) Query(q Query) (any, bool, error) {
|
||||
func (c *Core) Query(q Query) Result {
|
||||
c.ipc.queryMu.RLock()
|
||||
handlers := slices.Clone(c.ipc.queryHandlers)
|
||||
c.ipc.queryMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(c, q)
|
||||
if handled {
|
||||
return result, true, err
|
||||
r := h(c, q)
|
||||
if r.OK {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
return Result{}
|
||||
}
|
||||
|
||||
func (c *Core) QueryAll(q Query) ([]any, error) {
|
||||
func (c *Core) QueryAll(q Query) Result {
|
||||
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)
|
||||
r := h(c, q)
|
||||
if r.OK && r.Value != nil {
|
||||
results = append(results, r.Value)
|
||||
}
|
||||
}
|
||||
return results, agg
|
||||
return Result{results, true}
|
||||
}
|
||||
|
||||
func (c *Core) RegisterQuery(handler QueryHandler) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
|
|
@ -18,7 +17,7 @@ var (
|
|||
// Lock is the DTO for a named mutex.
|
||||
type Lock struct {
|
||||
Name string
|
||||
Mu *sync.RWMutex
|
||||
Mutex *sync.RWMutex
|
||||
}
|
||||
|
||||
// Lock returns a named Lock, creating the mutex if needed.
|
||||
|
|
@ -30,7 +29,7 @@ func (c *Core) Lock(name string) *Lock {
|
|||
lockMap[name] = m
|
||||
}
|
||||
lockMu.Unlock()
|
||||
return &Lock{Name: name, Mu: m}
|
||||
return &Lock{Name: name, Mutex: m}
|
||||
}
|
||||
|
||||
// LockEnable marks that the service lock should be applied after initialisation.
|
||||
|
|
@ -39,9 +38,9 @@ func (c *Core) LockEnable(name ...string) {
|
|||
if len(name) > 0 {
|
||||
n = name[0]
|
||||
}
|
||||
c.Lock(n).Mu.Lock()
|
||||
defer c.Lock(n).Mu.Unlock()
|
||||
c.srv.lockEnabled = true
|
||||
c.Lock(n).Mutex.Lock()
|
||||
defer c.Lock(n).Mutex.Unlock()
|
||||
c.services.lockEnabled = true
|
||||
}
|
||||
|
||||
// LockApply activates the service lock if it was enabled.
|
||||
|
|
@ -50,25 +49,41 @@ func (c *Core) LockApply(name ...string) {
|
|||
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
|
||||
c.Lock(n).Mutex.Lock()
|
||||
defer c.Lock(n).Mutex.Unlock()
|
||||
if c.services.lockEnabled {
|
||||
c.services.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
|
||||
// Startables returns services that have an OnStart function.
|
||||
func (c *Core) Startables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var out []*Service
|
||||
for _, svc := range c.services.services {
|
||||
if svc.OnStart != nil {
|
||||
out = append(out, svc)
|
||||
}
|
||||
}
|
||||
return Result{out, true}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Stoppables returns services that have an OnStop function.
|
||||
func (c *Core) Stoppables() Result {
|
||||
if c.services == nil {
|
||||
return Result{}
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var out []*Service
|
||||
for _, svc := range c.services.services {
|
||||
if svc.OnStop != nil {
|
||||
out = append(out, svc)
|
||||
}
|
||||
}
|
||||
return Result{out, true}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
goio "io"
|
||||
"os"
|
||||
"os/user"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -68,8 +68,8 @@ type Log struct {
|
|||
StyleSecurity func(string) string
|
||||
}
|
||||
|
||||
// RotationLogOpts defines the log rotation and retention policy.
|
||||
type RotationLogOpts struct {
|
||||
// RotationLogOptions defines the log rotation and retention policy.
|
||||
type RotationLogOptions struct {
|
||||
// Filename is the log file path. If empty, rotation is disabled.
|
||||
Filename string
|
||||
|
||||
|
|
@ -91,24 +91,24 @@ type RotationLogOpts struct {
|
|||
Compress bool
|
||||
}
|
||||
|
||||
// LogOpts configures a Log.
|
||||
type LogOpts struct {
|
||||
// LogOptions configures a Log.
|
||||
type LogOptions struct {
|
||||
Level Level
|
||||
// Output is the destination for log messages. If Rotation is provided,
|
||||
// Output is ignored and logs are written to the rotating file instead.
|
||||
Output goio.Writer
|
||||
// Rotation enables log rotation to file. If provided, Filename must be set.
|
||||
Rotation *RotationLogOpts
|
||||
Rotation *RotationLogOptions
|
||||
// RedactKeys is a list of keys whose values should be masked in logs.
|
||||
RedactKeys []string
|
||||
}
|
||||
|
||||
// RotationWriterFactory creates a rotating writer from options.
|
||||
// Set this to enable log rotation (provided by core/go-io integration).
|
||||
var RotationWriterFactory func(RotationLogOpts) goio.WriteCloser
|
||||
var RotationWriterFactory func(RotationLogOptions) goio.WriteCloser
|
||||
|
||||
// New creates a new Log with the given options.
|
||||
func NewLog(opts LogOpts) *Log {
|
||||
func NewLog(opts LogOptions) *Log {
|
||||
output := opts.Output
|
||||
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
|
||||
output = RotationWriterFactory(*opts.Rotation)
|
||||
|
|
@ -183,7 +183,7 @@ func (l *Log) log(level Level, prefix, msg string, keyvals ...any) {
|
|||
for i := 0; i < origLen; i += 2 {
|
||||
if i+1 < origLen {
|
||||
if err, ok := keyvals[i+1].(error); ok {
|
||||
if op := Op(err); op != "" {
|
||||
if op := Operation(err); op != "" {
|
||||
// Check if op is already in keyvals
|
||||
hasOp := false
|
||||
for j := 0; j < len(keyvals); j += 2 {
|
||||
|
|
@ -228,21 +228,21 @@ func (l *Log) log(level Level, prefix, msg string, keyvals ...any) {
|
|||
}
|
||||
|
||||
// Redaction logic
|
||||
keyStr := fmt.Sprintf("%v", key)
|
||||
keyStr := Sprint(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)
|
||||
kvStr += Sprintf("%v=%q", key, s)
|
||||
} else {
|
||||
kvStr += fmt.Sprintf("%v=%v", key, val)
|
||||
kvStr += Sprintf("%v=%v", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, kvStr)
|
||||
Print(output, "%s %s %s%s", timestamp, prefix, msg, kvStr)
|
||||
}
|
||||
|
||||
// Debug logs a debug message with optional key-value pairs.
|
||||
|
|
@ -297,51 +297,56 @@ func Username() string {
|
|||
|
||||
// --- Default logger ---
|
||||
|
||||
var defaultLog = NewLog(LogOpts{Level: LevelInfo})
|
||||
var defaultLogPtr atomic.Pointer[Log]
|
||||
|
||||
func init() {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
defaultLogPtr.Store(l)
|
||||
}
|
||||
|
||||
// Default returns the default logger.
|
||||
func Default() *Log {
|
||||
return defaultLog
|
||||
return defaultLogPtr.Load()
|
||||
}
|
||||
|
||||
// SetDefault sets the default logger.
|
||||
func SetDefault(l *Log) {
|
||||
defaultLog = l
|
||||
defaultLogPtr.Store(l)
|
||||
}
|
||||
|
||||
// SetLevel sets the default logger's level.
|
||||
func SetLevel(level Level) {
|
||||
defaultLog.SetLevel(level)
|
||||
Default().SetLevel(level)
|
||||
}
|
||||
|
||||
// SetRedactKeys sets the default logger's redaction keys.
|
||||
func SetRedactKeys(keys ...string) {
|
||||
defaultLog.SetRedactKeys(keys...)
|
||||
Default().SetRedactKeys(keys...)
|
||||
}
|
||||
|
||||
// Debug logs to the default logger.
|
||||
func Debug(msg string, keyvals ...any) {
|
||||
defaultLog.Debug(msg, keyvals...)
|
||||
Default().Debug(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Info logs to the default logger.
|
||||
func Info(msg string, keyvals ...any) {
|
||||
defaultLog.Info(msg, keyvals...)
|
||||
Default().Info(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Warn logs to the default logger.
|
||||
func Warn(msg string, keyvals ...any) {
|
||||
defaultLog.Warn(msg, keyvals...)
|
||||
Default().Warn(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Error logs to the default logger.
|
||||
func Error(msg string, keyvals ...any) {
|
||||
defaultLog.Error(msg, keyvals...)
|
||||
Default().Error(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Security logs to the default logger.
|
||||
func Security(msg string, keyvals ...any) {
|
||||
defaultLog.Security(msg, keyvals...)
|
||||
Default().Security(msg, keyvals...)
|
||||
}
|
||||
|
||||
// --- LogErr: Error-Aware Logger ---
|
||||
|
|
@ -362,36 +367,36 @@ func (le *LogErr) Log(err error) {
|
|||
if err == nil {
|
||||
return
|
||||
}
|
||||
le.log.Error(ErrorMessage(err), "op", Op(err), "code", ErrCode(err), "stack", FormatStackTrace(err))
|
||||
le.log.Error(ErrorMessage(err), "op", Operation(err), "code", ErrorCode(err), "stack", FormatStackTrace(err))
|
||||
}
|
||||
|
||||
// --- LogPan: Panic-Aware Logger ---
|
||||
// --- LogPanic: Panic-Aware Logger ---
|
||||
|
||||
// LogPan logs panic context without crash file management.
|
||||
// LogPanic logs panic context without crash file management.
|
||||
// Primary action: log. Secondary: recover panics.
|
||||
type LogPan struct {
|
||||
type LogPanic struct {
|
||||
log *Log
|
||||
}
|
||||
|
||||
// NewLogPan creates a LogPan bound to the given logger.
|
||||
func NewLogPan(log *Log) *LogPan {
|
||||
return &LogPan{log: log}
|
||||
// NewLogPanic creates a LogPanic bound to the given logger.
|
||||
func NewLogPanic(log *Log) *LogPanic {
|
||||
return &LogPanic{log: log}
|
||||
}
|
||||
|
||||
// Recover captures a panic and logs it. Does not write crash files.
|
||||
// Use as: defer core.NewLogPan(logger).Recover()
|
||||
func (lp *LogPan) Recover() {
|
||||
// Use as: defer core.NewLogPanic(logger).Recover()
|
||||
func (lp *LogPanic) Recover() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
err, ok := r.(error)
|
||||
if !ok {
|
||||
err = fmt.Errorf("%v", r)
|
||||
err = NewError(Sprint("panic: ", r))
|
||||
}
|
||||
lp.log.Error("panic recovered",
|
||||
"err", err,
|
||||
"op", Op(err),
|
||||
"op", Operation(err),
|
||||
"stack", FormatStackTrace(err),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
140
pkg/core/options.go
Normal file
140
pkg/core/options.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Core primitives: Option, Options, Result.
|
||||
//
|
||||
// Option is a single key-value pair. Options is a collection.
|
||||
// Any function that returns Result can accept Options.
|
||||
//
|
||||
// Create options:
|
||||
//
|
||||
// opts := core.Options{
|
||||
// {Key: "name", Value: "brain"},
|
||||
// {Key: "path", Value: "prompts"},
|
||||
// }
|
||||
//
|
||||
// Read options:
|
||||
//
|
||||
// name := opts.String("name")
|
||||
// port := opts.Int("port")
|
||||
// ok := opts.Has("debug")
|
||||
//
|
||||
// Use with subsystems:
|
||||
//
|
||||
// c.Drive().New(core.Options{
|
||||
// {Key: "name", Value: "brain"},
|
||||
// {Key: "source", Value: brainFS},
|
||||
// {Key: "path", Value: "prompts"},
|
||||
// })
|
||||
//
|
||||
// Use with New:
|
||||
//
|
||||
// c := core.New(core.Options{
|
||||
// {Key: "name", Value: "myapp"},
|
||||
// })
|
||||
package core
|
||||
|
||||
// Result is the universal return type for Core operations.
|
||||
// Replaces the (value, error) pattern — errors flow through Core internally.
|
||||
//
|
||||
// r := c.Data().New(core.Options{{Key: "name", Value: "brain"}})
|
||||
// if r.OK { use(r.Result()) }
|
||||
type Result struct {
|
||||
Value any
|
||||
OK bool
|
||||
}
|
||||
|
||||
// Result gets or sets the value. Zero args returns Value. With args, maps
|
||||
// Go (value, error) pairs to Result and returns self.
|
||||
//
|
||||
// r.Result(file, err) // OK = err == nil, Value = file
|
||||
// r.Result(value) // OK = true, Value = value
|
||||
// r.Result() // after set — returns the value
|
||||
func (r Result) Result(args ...any) Result {
|
||||
if len(args) == 0 {
|
||||
return r
|
||||
}
|
||||
|
||||
if len(args) == 1 {
|
||||
return Result{args[0], true}
|
||||
}
|
||||
|
||||
if err, ok := args[len(args)-1].(error); ok {
|
||||
if err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
return Result{args[0], true}
|
||||
}
|
||||
return Result{args[0], true}
|
||||
}
|
||||
|
||||
// Option is a single key-value configuration pair.
|
||||
//
|
||||
// core.Option{Key: "name", Value: "brain"}
|
||||
// core.Option{Key: "port", Value: 8080}
|
||||
type Option struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
// Options is a collection of Option items.
|
||||
// The universal input type for Core operations.
|
||||
//
|
||||
// opts := core.Options{{Key: "name", Value: "myapp"}}
|
||||
// name := opts.String("name")
|
||||
type Options []Option
|
||||
|
||||
// Get retrieves a value by key.
|
||||
//
|
||||
// r := opts.Get("name")
|
||||
// if r.OK { name := r.Value.(string) }
|
||||
func (o Options) Get(key string) Result {
|
||||
for _, opt := range o {
|
||||
if opt.Key == key {
|
||||
return Result{opt.Value, true}
|
||||
}
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// Has returns true if a key exists.
|
||||
//
|
||||
// if opts.Has("debug") { ... }
|
||||
func (o Options) Has(key string) bool {
|
||||
return o.Get(key).OK
|
||||
}
|
||||
|
||||
// String retrieves a string value, empty string if missing.
|
||||
//
|
||||
// name := opts.String("name")
|
||||
func (o Options) String(key string) string {
|
||||
r := o.Get(key)
|
||||
if !r.OK {
|
||||
return ""
|
||||
}
|
||||
s, _ := r.Value.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Int retrieves an int value, 0 if missing.
|
||||
//
|
||||
// port := opts.Int("port")
|
||||
func (o Options) Int(key string) int {
|
||||
r := o.Get(key)
|
||||
if !r.OK {
|
||||
return 0
|
||||
}
|
||||
i, _ := r.Value.(int)
|
||||
return i
|
||||
}
|
||||
|
||||
// Bool retrieves a bool value, false if missing.
|
||||
//
|
||||
// debug := opts.Bool("debug")
|
||||
func (o Options) Bool(key string) bool {
|
||||
r := o.Get(key)
|
||||
if !r.OK {
|
||||
return false
|
||||
}
|
||||
b, _ := r.Value.(bool)
|
||||
return b
|
||||
}
|
||||
|
|
@ -8,8 +8,6 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
|
@ -28,57 +26,71 @@ func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
|
|||
}
|
||||
|
||||
func (r *ServiceRuntime[T]) Core() *Core { return r.core }
|
||||
func (r *ServiceRuntime[T]) Opts() T { return r.opts }
|
||||
func (r *ServiceRuntime[T]) Options() 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 {
|
||||
// ServiceStartup runs OnStart for all registered services that have one.
|
||||
func (c *Core) ServiceStartup(ctx context.Context, options any) Result {
|
||||
c.shutdown.Store(false)
|
||||
c.context, c.cancel = context.WithCancel(ctx)
|
||||
startables := c.Startables()
|
||||
var agg error
|
||||
for _, s := range startables {
|
||||
if startables.OK {
|
||||
for _, s := range startables.Value.([]*Service) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errors.Join(agg, err)
|
||||
return Result{err, false}
|
||||
}
|
||||
if err := s.OnStartup(ctx); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
r := s.OnStart()
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
}
|
||||
if err := c.ACTION(ActionServiceStartup{}); err != nil {
|
||||
agg = errors.Join(agg, err)
|
||||
}
|
||||
return agg
|
||||
c.ACTION(ActionServiceStartup{})
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// ServiceShutdown runs the shutdown lifecycle for all registered services.
|
||||
func (c *Core) ServiceShutdown(ctx context.Context) error {
|
||||
// ServiceShutdown drains background tasks, then stops all registered services.
|
||||
func (c *Core) ServiceShutdown(ctx context.Context) Result {
|
||||
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)
|
||||
}
|
||||
}
|
||||
c.cancel() // signal all context-aware tasks to stop
|
||||
c.ACTION(ActionServiceShutdown{})
|
||||
|
||||
// Drain background tasks before stopping services.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.wg.Wait()
|
||||
c.waitGroup.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
agg = errors.Join(agg, ctx.Err())
|
||||
return Result{ctx.Err(), false}
|
||||
}
|
||||
return agg
|
||||
|
||||
// Stop services
|
||||
var firstErr error
|
||||
stoppables := c.Stoppables()
|
||||
if stoppables.OK {
|
||||
for _, s := range stoppables.Value.([]*Service) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{err, false}
|
||||
}
|
||||
r := s.OnStop()
|
||||
if !r.OK && firstErr == nil {
|
||||
if e, ok := r.Value.(error); ok {
|
||||
firstErr = e
|
||||
} else {
|
||||
firstErr = E("core.ServiceShutdown", Sprint("service OnStop failed: ", r.Value), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
return Result{firstErr, false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// --- Runtime DTO (GUI binding) ---
|
||||
|
|
@ -89,44 +101,49 @@ type Runtime struct {
|
|||
Core *Core
|
||||
}
|
||||
|
||||
// ServiceFactory defines a function that creates a service instance.
|
||||
type ServiceFactory func() (any, error)
|
||||
// ServiceFactory defines a function that creates a Service.
|
||||
type ServiceFactory func() Result
|
||||
|
||||
// NewWithFactories creates a Runtime with the provided service factories.
|
||||
func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, error) {
|
||||
coreOpts := []Option{WithApp(app)}
|
||||
func NewWithFactories(app any, factories map[string]ServiceFactory) Result {
|
||||
c := New(Options{{Key: "name", Value: "core"}})
|
||||
c.app.Runtime = 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)
|
||||
continue
|
||||
}
|
||||
svc, err := factory()
|
||||
if err != nil {
|
||||
return nil, E("core.NewWithFactories", fmt.Sprintf("failed to create service %q", name), err)
|
||||
r := factory()
|
||||
if !r.OK {
|
||||
cause, _ := r.Value.(error)
|
||||
return Result{E("core.NewWithFactories", Concat("factory \"", name, "\" failed"), cause), false}
|
||||
}
|
||||
svcCopy := svc
|
||||
coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil }))
|
||||
svc, ok := r.Value.(Service)
|
||||
if !ok {
|
||||
return Result{E("core.NewWithFactories", Concat("factory \"", name, "\" returned non-Service type"), nil), false}
|
||||
}
|
||||
coreInstance, err := New(coreOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
sr := c.Service(name, svc)
|
||||
if !sr.OK {
|
||||
return sr
|
||||
}
|
||||
return &Runtime{app: app, Core: coreInstance}, nil
|
||||
}
|
||||
return Result{&Runtime{app: app, Core: c}, true}
|
||||
}
|
||||
|
||||
// NewRuntime creates a Runtime with no custom services.
|
||||
func NewRuntime(app any) (*Runtime, error) {
|
||||
func NewRuntime(app any) Result {
|
||||
return NewWithFactories(app, map[string]ServiceFactory{})
|
||||
}
|
||||
|
||||
func (r *Runtime) ServiceName() string { return "Core" }
|
||||
func (r *Runtime) ServiceStartup(ctx context.Context, options any) error {
|
||||
func (r *Runtime) ServiceStartup(ctx context.Context, options any) Result {
|
||||
return r.Core.ServiceStartup(ctx, options)
|
||||
}
|
||||
func (r *Runtime) ServiceShutdown(ctx context.Context) error {
|
||||
func (r *Runtime) ServiceShutdown(ctx context.Context) Result {
|
||||
if r.Core != nil {
|
||||
return r.Core.ServiceShutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,83 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Service registry, lifecycle tracking, and runtime helpers for the Core framework.
|
||||
// Service registry for the Core framework.
|
||||
//
|
||||
// Register a service:
|
||||
//
|
||||
// c.Service("auth", core.Service{})
|
||||
//
|
||||
// Get a service:
|
||||
//
|
||||
// r := c.Service("auth")
|
||||
// if r.OK { svc := r.Value }
|
||||
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
// No imports needed — uses package-level string helpers.
|
||||
|
||||
// --- Service Registry DTO ---
|
||||
|
||||
// Service holds service registry data.
|
||||
// Service is a managed component with optional lifecycle.
|
||||
type Service struct {
|
||||
Services map[string]any
|
||||
startables []Startable
|
||||
stoppables []Stoppable
|
||||
Name string
|
||||
Options Options
|
||||
OnStart func() Result
|
||||
OnStop func() Result
|
||||
OnReload func() Result
|
||||
}
|
||||
|
||||
// serviceRegistry holds registered services.
|
||||
type serviceRegistry struct {
|
||||
services map[string]*Service
|
||||
lockEnabled bool
|
||||
locked bool
|
||||
}
|
||||
|
||||
|
||||
// --- Core service methods ---
|
||||
|
||||
// Service gets or registers a service.
|
||||
// Service gets or registers a service by name.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
// c.Service("auth", core.Service{OnStart: startFn})
|
||||
// r := c.Service("auth")
|
||||
func (c *Core) Service(name string, service ...Service) Result {
|
||||
if len(service) == 0 {
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
v, ok := c.services.services[name]
|
||||
c.Lock("srv").Mutex.RUnlock()
|
||||
return Result{v, ok}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return Result{E("core.Service", "service name cannot be empty", nil), false}
|
||||
}
|
||||
|
||||
c.Lock("srv").Mutex.Lock()
|
||||
defer c.Lock("srv").Mutex.Unlock()
|
||||
|
||||
if c.services.locked {
|
||||
return Result{E("core.Service", Concat("service \"", name, "\" not permitted — registry locked"), nil), false}
|
||||
}
|
||||
if _, exists := c.services.services[name]; exists {
|
||||
return Result{E("core.Service", Join(" ", "service", name, "already registered"), nil), false}
|
||||
}
|
||||
|
||||
srv := &service[0]
|
||||
srv.Name = name
|
||||
c.services.services[name] = srv
|
||||
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
// Services returns all registered service names.
|
||||
//
|
||||
// names := c.Services()
|
||||
func (c *Core) Services() []string {
|
||||
if c.services == nil {
|
||||
return nil
|
||||
}
|
||||
c.Lock("srv").Mutex.RLock()
|
||||
defer c.Lock("srv").Mutex.RUnlock()
|
||||
var names []string
|
||||
for k := range c.services.services {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
|
|
|||
157
pkg/core/string.go
Normal file
157
pkg/core/string.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// String operations for the Core framework.
|
||||
// Provides safe, predictable string helpers that downstream packages
|
||||
// use directly — same pattern as Array[T] for slices.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// HasPrefix returns true if s starts with prefix.
|
||||
//
|
||||
// core.HasPrefix("--verbose", "--") // true
|
||||
func HasPrefix(s, prefix string) bool {
|
||||
return strings.HasPrefix(s, prefix)
|
||||
}
|
||||
|
||||
// HasSuffix returns true if s ends with suffix.
|
||||
//
|
||||
// core.HasSuffix("test.go", ".go") // true
|
||||
func HasSuffix(s, suffix string) bool {
|
||||
return strings.HasSuffix(s, suffix)
|
||||
}
|
||||
|
||||
// TrimPrefix removes prefix from s.
|
||||
//
|
||||
// core.TrimPrefix("--verbose", "--") // "verbose"
|
||||
func TrimPrefix(s, prefix string) string {
|
||||
return strings.TrimPrefix(s, prefix)
|
||||
}
|
||||
|
||||
// TrimSuffix removes suffix from s.
|
||||
//
|
||||
// core.TrimSuffix("test.go", ".go") // "test"
|
||||
func TrimSuffix(s, suffix string) string {
|
||||
return strings.TrimSuffix(s, suffix)
|
||||
}
|
||||
|
||||
// Contains returns true if s contains substr.
|
||||
//
|
||||
// core.Contains("hello world", "world") // true
|
||||
func Contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
|
||||
// Split splits s by separator.
|
||||
//
|
||||
// core.Split("a/b/c", "/") // ["a", "b", "c"]
|
||||
func Split(s, sep string) []string {
|
||||
return strings.Split(s, sep)
|
||||
}
|
||||
|
||||
// SplitN splits s by separator into at most n parts.
|
||||
//
|
||||
// core.SplitN("key=value=extra", "=", 2) // ["key", "value=extra"]
|
||||
func SplitN(s, sep string, n int) []string {
|
||||
return strings.SplitN(s, sep, n)
|
||||
}
|
||||
|
||||
// Join joins parts with a separator, building via Concat.
|
||||
//
|
||||
// core.Join("/", "deploy", "to", "homelab") // "deploy/to/homelab"
|
||||
// core.Join(".", "cmd", "deploy", "description") // "cmd.deploy.description"
|
||||
func Join(sep string, parts ...string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
result = Concat(result, sep, p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Replace replaces all occurrences of old with new in s.
|
||||
//
|
||||
// core.Replace("deploy/to/homelab", "/", ".") // "deploy.to.homelab"
|
||||
func Replace(s, old, new string) string {
|
||||
return strings.ReplaceAll(s, old, new)
|
||||
}
|
||||
|
||||
// Lower returns s in lowercase.
|
||||
//
|
||||
// core.Lower("HELLO") // "hello"
|
||||
func Lower(s string) string {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
// Upper returns s in uppercase.
|
||||
//
|
||||
// core.Upper("hello") // "HELLO"
|
||||
func Upper(s string) string {
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
// Trim removes leading and trailing whitespace.
|
||||
//
|
||||
// core.Trim(" hello ") // "hello"
|
||||
func Trim(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// RuneCount returns the number of runes (unicode characters) in s.
|
||||
//
|
||||
// core.RuneCount("hello") // 5
|
||||
// core.RuneCount("🔥") // 1
|
||||
func RuneCount(s string) int {
|
||||
return utf8.RuneCountInString(s)
|
||||
}
|
||||
|
||||
// NewBuilder returns a new strings.Builder.
|
||||
//
|
||||
// b := core.NewBuilder()
|
||||
// b.WriteString("hello")
|
||||
// b.String() // "hello"
|
||||
func NewBuilder() *strings.Builder {
|
||||
return &strings.Builder{}
|
||||
}
|
||||
|
||||
// NewReader returns a strings.NewReader for the given string.
|
||||
//
|
||||
// r := core.NewReader("hello world")
|
||||
func NewReader(s string) *strings.Reader {
|
||||
return strings.NewReader(s)
|
||||
}
|
||||
|
||||
// Sprint converts any value to its string representation.
|
||||
//
|
||||
// core.Sprint(42) // "42"
|
||||
// core.Sprint(err) // "connection refused"
|
||||
func Sprint(args ...any) string {
|
||||
return fmt.Sprint(args...)
|
||||
}
|
||||
|
||||
// Sprintf formats a string with the given arguments.
|
||||
//
|
||||
// core.Sprintf("%v=%q", "key", "value") // `key="value"`
|
||||
func Sprintf(format string, args ...any) string {
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
// Concat joins variadic string parts into one string.
|
||||
// Hook point for validation, sanitisation, and security checks.
|
||||
//
|
||||
// core.Concat("cmd.", "deploy.to.homelab", ".description")
|
||||
// core.Concat("https://", host, "/api/v1")
|
||||
func Concat(parts ...string) string {
|
||||
b := NewBuilder()
|
||||
for _, p := range parts {
|
||||
b.WriteString(p)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -5,64 +5,81 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// TaskState holds background task state.
|
||||
type TaskState struct {
|
||||
ID string
|
||||
Identifier string
|
||||
Task Task
|
||||
Result any
|
||||
Error error
|
||||
}
|
||||
|
||||
// PerformAsync dispatches a task in a background goroutine.
|
||||
func (c *Core) PerformAsync(t Task) string {
|
||||
func (c *Core) PerformAsync(t Task) Result {
|
||||
if c.shutdown.Load() {
|
||||
return ""
|
||||
return Result{}
|
||||
}
|
||||
taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1))
|
||||
if tid, ok := t.(TaskWithID); ok {
|
||||
tid.SetTaskID(taskID)
|
||||
taskID := Concat("task-", strconv.FormatUint(c.taskIDCounter.Add(1), 10))
|
||||
if tid, ok := t.(TaskWithIdentifier); ok {
|
||||
tid.SetTaskIdentifier(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(ActionTaskStarted{TaskIdentifier: taskID, Task: t})
|
||||
c.waitGroup.Go(func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
err := E("core.PerformAsync", Sprint("panic: ", rec), nil)
|
||||
c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: nil, Error: err})
|
||||
}
|
||||
_ = c.ACTION(ActionTaskCompleted{TaskID: taskID, Task: t, Result: result, Error: err})
|
||||
}()
|
||||
r := c.PERFORM(t)
|
||||
var err error
|
||||
if !r.OK {
|
||||
if e, ok := r.Value.(error); ok {
|
||||
err = e
|
||||
} else {
|
||||
taskType := reflect.TypeOf(t)
|
||||
typeName := "<nil>"
|
||||
if taskType != nil {
|
||||
typeName = taskType.String()
|
||||
}
|
||||
err = E("core.PerformAsync", Join(" ", "no handler found for task type", typeName), nil)
|
||||
}
|
||||
}
|
||||
c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: r.Value, Error: err})
|
||||
})
|
||||
return taskID
|
||||
return Result{taskID, true}
|
||||
}
|
||||
|
||||
// Progress broadcasts a progress update for a background task.
|
||||
func (c *Core) Progress(taskID string, progress float64, message string, t Task) {
|
||||
_ = c.ACTION(ActionTaskProgress{TaskID: taskID, Task: t, Progress: progress, Message: message})
|
||||
c.ACTION(ActionTaskProgress{TaskIdentifier: taskID, Task: t, Progress: progress, Message: message})
|
||||
}
|
||||
|
||||
func (c *Core) Perform(t Task) (any, bool, error) {
|
||||
func (c *Core) Perform(t Task) Result {
|
||||
c.ipc.taskMu.RLock()
|
||||
handlers := slices.Clone(c.ipc.taskHandlers)
|
||||
c.ipc.taskMu.RUnlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
result, handled, err := h(c, t)
|
||||
if handled {
|
||||
return result, true, err
|
||||
r := h(c, t)
|
||||
if r.OK {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
return Result{}
|
||||
}
|
||||
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) error) {
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) {
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
|
||||
c.ipc.ipcMu.Lock()
|
||||
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
|
||||
c.ipc.ipcMu.Unlock()
|
||||
|
|
|
|||
159
pkg/core/utils.go
Normal file
159
pkg/core/utils.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Utility functions for the Core framework.
|
||||
// Built on core string.go primitives.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Print writes a formatted line to a writer, defaulting to os.Stdout.
|
||||
//
|
||||
// core.Print(nil, "hello %s", "world") // → stdout
|
||||
// core.Print(w, "port: %d", 8080) // → w
|
||||
func Print(w io.Writer, format string, args ...any) {
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
fmt.Fprintf(w, format+"\n", args...)
|
||||
}
|
||||
|
||||
// JoinPath joins string segments into a path with "/" separator.
|
||||
//
|
||||
// core.JoinPath("deploy", "to", "homelab") // → "deploy/to/homelab"
|
||||
func JoinPath(segments ...string) string {
|
||||
return Join("/", segments...)
|
||||
}
|
||||
|
||||
// IsFlag returns true if the argument starts with a dash.
|
||||
//
|
||||
// core.IsFlag("--verbose") // true
|
||||
// core.IsFlag("-v") // true
|
||||
// core.IsFlag("deploy") // false
|
||||
func IsFlag(arg string) bool {
|
||||
return HasPrefix(arg, "-")
|
||||
}
|
||||
|
||||
// Arg extracts a value from variadic args at the given index.
|
||||
// Type-checks and delegates to the appropriate typed extractor.
|
||||
// Returns Result — OK is false if index is out of bounds.
|
||||
//
|
||||
// r := core.Arg(0, args...)
|
||||
// if r.OK { path = r.Value.(string) }
|
||||
func Arg(index int, args ...any) Result {
|
||||
if index >= len(args) {
|
||||
return Result{}
|
||||
}
|
||||
v := args[index]
|
||||
switch v.(type) {
|
||||
case string:
|
||||
return Result{ArgString(index, args...), true}
|
||||
case int:
|
||||
return Result{ArgInt(index, args...), true}
|
||||
case bool:
|
||||
return Result{ArgBool(index, args...), true}
|
||||
default:
|
||||
return Result{v, true}
|
||||
}
|
||||
}
|
||||
|
||||
// ArgString extracts a string at the given index.
|
||||
//
|
||||
// name := core.ArgString(0, args...)
|
||||
func ArgString(index int, args ...any) string {
|
||||
if index >= len(args) {
|
||||
return ""
|
||||
}
|
||||
s, ok := args[index].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ArgInt extracts an int at the given index.
|
||||
//
|
||||
// port := core.ArgInt(1, args...)
|
||||
func ArgInt(index int, args ...any) int {
|
||||
if index >= len(args) {
|
||||
return 0
|
||||
}
|
||||
i, ok := args[index].(int)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// ArgBool extracts a bool at the given index.
|
||||
//
|
||||
// debug := core.ArgBool(2, args...)
|
||||
func ArgBool(index int, args ...any) bool {
|
||||
if index >= len(args) {
|
||||
return false
|
||||
}
|
||||
b, ok := args[index].(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// FilterArgs removes empty strings and Go test runner flags from an argument list.
|
||||
//
|
||||
// clean := core.FilterArgs(os.Args[1:])
|
||||
func FilterArgs(args []string) []string {
|
||||
var clean []string
|
||||
for _, a := range args {
|
||||
if a == "" || HasPrefix(a, "-test.") {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, a)
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
// ParseFlag parses a single flag argument into key, value, and validity.
|
||||
// Single dash (-) requires exactly 1 character (letter, emoji, unicode).
|
||||
// Double dash (--) requires 2+ characters.
|
||||
//
|
||||
// "-v" → "v", "", true
|
||||
// "-🔥" → "🔥", "", true
|
||||
// "--verbose" → "verbose", "", true
|
||||
// "--port=8080" → "port", "8080", true
|
||||
// "-verbose" → "", "", false (single dash, 2+ chars)
|
||||
// "--v" → "", "", false (double dash, 1 char)
|
||||
// "hello" → "", "", false (not a flag)
|
||||
func ParseFlag(arg string) (key, value string, valid bool) {
|
||||
if HasPrefix(arg, "--") {
|
||||
rest := TrimPrefix(arg, "--")
|
||||
parts := SplitN(rest, "=", 2)
|
||||
name := parts[0]
|
||||
if RuneCount(name) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return name, parts[1], true
|
||||
}
|
||||
return name, "", true
|
||||
}
|
||||
|
||||
if HasPrefix(arg, "-") {
|
||||
rest := TrimPrefix(arg, "-")
|
||||
parts := SplitN(rest, "=", 2)
|
||||
name := parts[0]
|
||||
if RuneCount(name) != 1 {
|
||||
return "", "", false
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return name, parts[1], true
|
||||
}
|
||||
return name, "", true
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
39
tests/app_test.go
Normal file
39
tests/app_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- App ---
|
||||
|
||||
func TestApp_Good(t *testing.T) {
|
||||
c := New(Options{{Key: "name", Value: "myapp"}})
|
||||
assert.Equal(t, "myapp", c.App().Name)
|
||||
}
|
||||
|
||||
func TestApp_Empty_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.App())
|
||||
assert.Equal(t, "", c.App().Name)
|
||||
}
|
||||
|
||||
func TestApp_Runtime_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.App().Runtime = &struct{ Name string }{Name: "wails"}
|
||||
assert.NotNil(t, c.App().Runtime)
|
||||
}
|
||||
|
||||
func TestApp_Find_Good(t *testing.T) {
|
||||
r := Find("go", "go")
|
||||
assert.True(t, r.OK)
|
||||
app := r.Value.(*App)
|
||||
assert.NotEmpty(t, app.Path)
|
||||
}
|
||||
|
||||
func TestApp_Find_Bad(t *testing.T) {
|
||||
r := Find("nonexistent-binary-xyz", "test")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
90
tests/array_test.go
Normal file
90
tests/array_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Array[T] ---
|
||||
|
||||
func TestArray_New_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "c")
|
||||
assert.Equal(t, 3, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Add_Good(t *testing.T) {
|
||||
a := NewArray[string]()
|
||||
a.Add("x", "y")
|
||||
assert.Equal(t, 2, a.Len())
|
||||
assert.True(t, a.Contains("x"))
|
||||
assert.True(t, a.Contains("y"))
|
||||
}
|
||||
|
||||
func TestArray_AddUnique_Good(t *testing.T) {
|
||||
a := NewArray("a", "b")
|
||||
a.AddUnique("b", "c")
|
||||
assert.Equal(t, 3, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Contains_Good(t *testing.T) {
|
||||
a := NewArray(1, 2, 3)
|
||||
assert.True(t, a.Contains(2))
|
||||
assert.False(t, a.Contains(99))
|
||||
}
|
||||
|
||||
func TestArray_Filter_Good(t *testing.T) {
|
||||
a := NewArray(1, 2, 3, 4, 5)
|
||||
r := a.Filter(func(n int) bool { return n%2 == 0 })
|
||||
assert.True(t, r.OK)
|
||||
evens := r.Value.(*Array[int])
|
||||
assert.Equal(t, 2, evens.Len())
|
||||
assert.True(t, evens.Contains(2))
|
||||
assert.True(t, evens.Contains(4))
|
||||
}
|
||||
|
||||
func TestArray_Each_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "c")
|
||||
var collected []string
|
||||
a.Each(func(s string) { collected = append(collected, s) })
|
||||
assert.Equal(t, []string{"a", "b", "c"}, collected)
|
||||
}
|
||||
|
||||
func TestArray_Remove_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "c")
|
||||
a.Remove("b")
|
||||
assert.Equal(t, 2, a.Len())
|
||||
assert.False(t, a.Contains("b"))
|
||||
}
|
||||
|
||||
func TestArray_Remove_Bad(t *testing.T) {
|
||||
a := NewArray("a", "b")
|
||||
a.Remove("missing")
|
||||
assert.Equal(t, 2, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Deduplicate_Good(t *testing.T) {
|
||||
a := NewArray("a", "b", "a", "c", "b")
|
||||
a.Deduplicate()
|
||||
assert.Equal(t, 3, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_Clear_Good(t *testing.T) {
|
||||
a := NewArray(1, 2, 3)
|
||||
a.Clear()
|
||||
assert.Equal(t, 0, a.Len())
|
||||
}
|
||||
|
||||
func TestArray_AsSlice_Good(t *testing.T) {
|
||||
a := NewArray("x", "y")
|
||||
s := a.AsSlice()
|
||||
assert.Equal(t, []string{"x", "y"}, s)
|
||||
}
|
||||
|
||||
func TestArray_Empty_Good(t *testing.T) {
|
||||
a := NewArray[int]()
|
||||
assert.Equal(t, 0, a.Len())
|
||||
assert.False(t, a.Contains(0))
|
||||
assert.Equal(t, []int(nil), a.AsSlice())
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCore_PerformAsync_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var completed atomic.Bool
|
||||
var resultReceived any
|
||||
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if tc, ok := msg.(ActionTaskCompleted); ok {
|
||||
resultReceived = tc.Result
|
||||
completed.Store(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
|
||||
return "async-result", true, nil
|
||||
})
|
||||
|
||||
taskID := c.PerformAsync(TestTask{})
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
// Wait for completion
|
||||
assert.Eventually(t, func() bool {
|
||||
return completed.Load()
|
||||
}, 1*time.Second, 10*time.Millisecond)
|
||||
|
||||
assert.Equal(t, "async-result", resultReceived)
|
||||
}
|
||||
|
||||
func TestCore_PerformAsync_Shutdown(t *testing.T) {
|
||||
c, _ := New()
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
|
||||
taskID := c.PerformAsync(TestTask{})
|
||||
assert.Empty(t, taskID, "PerformAsync should return empty string if already shut down")
|
||||
}
|
||||
|
||||
func TestCore_Progress_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
var progressReceived float64
|
||||
var messageReceived string
|
||||
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if tp, ok := msg.(ActionTaskProgress); ok {
|
||||
progressReceived = tp.Progress
|
||||
messageReceived = tp.Message
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
c.Progress("task-1", 0.5, "halfway", TestTask{})
|
||||
|
||||
assert.Equal(t, 0.5, progressReceived)
|
||||
assert.Equal(t, "halfway", messageReceived)
|
||||
}
|
||||
|
||||
func TestCore_WithService_UnnamedType(t *testing.T) {
|
||||
// Primitive types have no package path
|
||||
factory := func(c *Core) (any, error) {
|
||||
s := "primitive"
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
_, err := New(WithService(factory))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "service name could not be discovered")
|
||||
}
|
||||
|
||||
func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) {
|
||||
rt, _ := NewRuntime(nil)
|
||||
|
||||
// Register a service that fails startup
|
||||
errSvc := &MockStartable{err: errors.New("startup failed")}
|
||||
_ = rt.Core.RegisterService("error-svc", errSvc)
|
||||
|
||||
err := rt.ServiceStartup(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "startup failed")
|
||||
}
|
||||
|
||||
func TestCore_ServiceStartup_ContextCancellation(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
s1 := &MockStartable{}
|
||||
_ = c.RegisterService("s1", s1)
|
||||
|
||||
err := c.ServiceStartup(ctx, nil)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.False(t, s1.started, "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, "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 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,40 +0,0 @@
|
|||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkMessageBus_Action(b *testing.B) {
|
||||
c, _ := New()
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
return nil
|
||||
})
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = c.ACTION("test")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMessageBus_Query(b *testing.B) {
|
||||
c, _ := New()
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "result", true, nil
|
||||
})
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = c.QUERY("test")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMessageBus_Perform(b *testing.B) {
|
||||
c, _ := New()
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
return "result", true, nil
|
||||
})
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = c.PERFORM("test")
|
||||
}
|
||||
}
|
||||
76
tests/cli_test.go
Normal file
76
tests/cli_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Cli Surface ---
|
||||
|
||||
func TestCli_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.Cli())
|
||||
}
|
||||
|
||||
func TestCli_Banner_Good(t *testing.T) {
|
||||
c := New(Options{{Key: "name", Value: "myapp"}})
|
||||
assert.Equal(t, "myapp", c.Cli().Banner())
|
||||
}
|
||||
|
||||
func TestCli_SetBanner_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Cli().SetBanner(func(_ *Cli) string { return "Custom Banner" })
|
||||
assert.Equal(t, "Custom Banner", c.Cli().Banner())
|
||||
}
|
||||
|
||||
func TestCli_Run_Good(t *testing.T) {
|
||||
c := New()
|
||||
executed := false
|
||||
c.Command("hello", Command{Action: func(_ Options) Result {
|
||||
executed = true
|
||||
return Result{Value: "world", OK: true}
|
||||
}})
|
||||
r := c.Cli().Run("hello")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "world", r.Value)
|
||||
assert.True(t, executed)
|
||||
}
|
||||
|
||||
func TestCli_Run_Nested_Good(t *testing.T) {
|
||||
c := New()
|
||||
executed := false
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
|
||||
executed = true
|
||||
return Result{OK: true}
|
||||
}})
|
||||
r := c.Cli().Run("deploy", "to", "homelab")
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, executed)
|
||||
}
|
||||
|
||||
func TestCli_Run_WithFlags_Good(t *testing.T) {
|
||||
c := New()
|
||||
var received Options
|
||||
c.Command("serve", Command{Action: func(opts Options) Result {
|
||||
received = opts
|
||||
return Result{OK: true}
|
||||
}})
|
||||
c.Cli().Run("serve", "--port=8080", "--debug")
|
||||
assert.Equal(t, "8080", received.String("port"))
|
||||
assert.True(t, received.Bool("debug"))
|
||||
}
|
||||
|
||||
func TestCli_Run_NoCommand_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Cli().Run()
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCli_PrintHelp_Good(t *testing.T) {
|
||||
c := New(Options{{Key: "name", Value: "myapp"}})
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Cli().PrintHelp()
|
||||
}
|
||||
130
tests/command_test.go
Normal file
130
tests/command_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Command DTO ---
|
||||
|
||||
func TestCommand_Register_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Command("deploy", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed", OK: true}
|
||||
}})
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCommand_Get_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
r := c.Command("deploy")
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestCommand_Get_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Command("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCommand_Run_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("greet", Command{Action: func(opts Options) Result {
|
||||
return Result{Value: Concat("hello ", opts.String("name")), OK: true}
|
||||
}})
|
||||
cmd := c.Command("greet").Value.(*Command)
|
||||
r := cmd.Run(Options{{Key: "name", Value: "world"}})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello world", r.Value)
|
||||
}
|
||||
|
||||
func TestCommand_Run_NoAction_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("empty", Command{Description: "no action"})
|
||||
cmd := c.Command("empty").Value.(*Command)
|
||||
r := cmd.Run(Options{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Nested Commands ---
|
||||
|
||||
func TestCommand_Nested_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed to homelab", OK: true}
|
||||
}})
|
||||
|
||||
r := c.Command("deploy/to/homelab")
|
||||
assert.True(t, r.OK)
|
||||
|
||||
// Parent auto-created
|
||||
assert.True(t, c.Command("deploy").OK)
|
||||
assert.True(t, c.Command("deploy/to").OK)
|
||||
}
|
||||
|
||||
func TestCommand_Paths_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
|
||||
paths := c.Commands()
|
||||
assert.Contains(t, paths, "deploy")
|
||||
assert.Contains(t, paths, "serve")
|
||||
assert.Contains(t, paths, "deploy/to/homelab")
|
||||
assert.Contains(t, paths, "deploy/to")
|
||||
}
|
||||
|
||||
// --- I18n Key Derivation ---
|
||||
|
||||
func TestCommand_I18nKey_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy/to/homelab", Command{})
|
||||
cmd := c.Command("deploy/to/homelab").Value.(*Command)
|
||||
assert.Equal(t, "cmd.deploy.to.homelab.description", cmd.I18nKey())
|
||||
}
|
||||
|
||||
func TestCommand_I18nKey_Custom_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("deploy", Command{Description: "custom.deploy.key"})
|
||||
cmd := c.Command("deploy").Value.(*Command)
|
||||
assert.Equal(t, "custom.deploy.key", cmd.I18nKey())
|
||||
}
|
||||
|
||||
func TestCommand_I18nKey_Simple_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("serve", Command{})
|
||||
cmd := c.Command("serve").Value.(*Command)
|
||||
assert.Equal(t, "cmd.serve.description", cmd.I18nKey())
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Command("serve", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "running", OK: true}
|
||||
}})
|
||||
cmd := c.Command("serve").Value.(*Command)
|
||||
|
||||
r := cmd.Start(Options{})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "running", r.Value)
|
||||
|
||||
assert.False(t, cmd.Stop().OK)
|
||||
assert.False(t, cmd.Restart().OK)
|
||||
assert.False(t, cmd.Reload().OK)
|
||||
assert.False(t, cmd.Signal("HUP").OK)
|
||||
}
|
||||
|
||||
// --- Empty path ---
|
||||
|
||||
func TestCommand_EmptyPath_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Command("", Command{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
102
tests/config_test.go
Normal file
102
tests/config_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Config ---
|
||||
|
||||
func TestConfig_SetGet_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Set("api_url", "https://api.lthn.ai")
|
||||
c.Config().Set("max_agents", 5)
|
||||
|
||||
r := c.Config().Get("api_url")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "https://api.lthn.ai", r.Value)
|
||||
}
|
||||
|
||||
func TestConfig_Get_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Config().Get("missing")
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestConfig_TypedAccessors_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Set("url", "https://lthn.ai")
|
||||
c.Config().Set("port", 8080)
|
||||
c.Config().Set("debug", true)
|
||||
|
||||
assert.Equal(t, "https://lthn.ai", c.Config().String("url"))
|
||||
assert.Equal(t, 8080, c.Config().Int("port"))
|
||||
assert.True(t, c.Config().Bool("debug"))
|
||||
}
|
||||
|
||||
func TestConfig_TypedAccessors_Bad(t *testing.T) {
|
||||
c := New()
|
||||
// Missing keys return zero values
|
||||
assert.Equal(t, "", c.Config().String("missing"))
|
||||
assert.Equal(t, 0, c.Config().Int("missing"))
|
||||
assert.False(t, c.Config().Bool("missing"))
|
||||
}
|
||||
|
||||
// --- Feature Flags ---
|
||||
|
||||
func TestConfig_Features_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Enable("dark-mode")
|
||||
c.Config().Enable("beta")
|
||||
|
||||
assert.True(t, c.Config().Enabled("dark-mode"))
|
||||
assert.True(t, c.Config().Enabled("beta"))
|
||||
assert.False(t, c.Config().Enabled("missing"))
|
||||
}
|
||||
|
||||
func TestConfig_Features_Disable_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Enable("feature")
|
||||
assert.True(t, c.Config().Enabled("feature"))
|
||||
|
||||
c.Config().Disable("feature")
|
||||
assert.False(t, c.Config().Enabled("feature"))
|
||||
}
|
||||
|
||||
func TestConfig_Features_CaseSensitive(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Enable("Feature")
|
||||
assert.True(t, c.Config().Enabled("Feature"))
|
||||
assert.False(t, c.Config().Enabled("feature"))
|
||||
}
|
||||
|
||||
func TestConfig_EnabledFeatures_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Config().Enable("a")
|
||||
c.Config().Enable("b")
|
||||
c.Config().Enable("c")
|
||||
c.Config().Disable("b")
|
||||
|
||||
features := c.Config().EnabledFeatures()
|
||||
assert.Contains(t, features, "a")
|
||||
assert.Contains(t, features, "c")
|
||||
assert.NotContains(t, features, "b")
|
||||
}
|
||||
|
||||
// --- ConfigVar ---
|
||||
|
||||
func TestConfigVar_Good(t *testing.T) {
|
||||
v := NewConfigVar("hello")
|
||||
assert.True(t, v.IsSet())
|
||||
assert.Equal(t, "hello", v.Get())
|
||||
|
||||
v.Set("world")
|
||||
assert.Equal(t, "world", v.Get())
|
||||
|
||||
v.Unset()
|
||||
assert.False(t, v.IsSet())
|
||||
assert.Equal(t, "", v.Get())
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MockServiceWithIPC struct {
|
||||
MockService
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (m *MockServiceWithIPC) HandleIPCEvents(c *Core, msg Message) error {
|
||||
m.handled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_WithService_IPC(t *testing.T) {
|
||||
svc := &MockServiceWithIPC{MockService: MockService{Name: "ipc-service"}}
|
||||
factory := func(c *Core) (any, error) {
|
||||
return svc, nil
|
||||
}
|
||||
c, err := New(WithService(factory))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Trigger ACTION to verify handler was registered
|
||||
err = c.ACTION(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, svc.handled)
|
||||
}
|
||||
|
||||
func TestCore_ACTION_Bad(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
errHandler := func(c *Core, msg Message) error {
|
||||
return assert.AnError
|
||||
}
|
||||
c.RegisterAction(errHandler)
|
||||
err = c.ACTION(nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), assert.AnError.Error())
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MockStartable struct {
|
||||
started bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockStartable) OnStartup(ctx context.Context) error {
|
||||
m.started = true
|
||||
return m.err
|
||||
}
|
||||
|
||||
type MockStoppable struct {
|
||||
stopped bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockStoppable) OnShutdown(ctx context.Context) error {
|
||||
m.stopped = true
|
||||
return m.err
|
||||
}
|
||||
|
||||
type MockLifecycle struct {
|
||||
MockStartable
|
||||
MockStoppable
|
||||
}
|
||||
|
||||
func TestCore_LifecycleInterfaces(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
startable := &MockStartable{}
|
||||
stoppable := &MockStoppable{}
|
||||
lifecycle := &MockLifecycle{}
|
||||
|
||||
// Register services
|
||||
err = c.RegisterService("startable", startable)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("stoppable", stoppable)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("lifecycle", lifecycle)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, startable.started)
|
||||
assert.True(t, lifecycle.started)
|
||||
assert.False(t, stoppable.stopped)
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, stoppable.stopped)
|
||||
assert.True(t, lifecycle.stopped)
|
||||
}
|
||||
|
||||
type MockLifecycleWithLog struct {
|
||||
id string
|
||||
log *[]string
|
||||
}
|
||||
|
||||
func (m *MockLifecycleWithLog) OnStartup(ctx context.Context) error {
|
||||
*m.log = append(*m.log, "start-"+m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockLifecycleWithLog) OnShutdown(ctx context.Context) error {
|
||||
*m.log = append(*m.log, "stop-"+m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_LifecycleOrder(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
var callOrder []string
|
||||
|
||||
s1 := &MockLifecycleWithLog{id: "1", log: &callOrder}
|
||||
s2 := &MockLifecycleWithLog{id: "2", log: &callOrder}
|
||||
|
||||
err = c.RegisterService("s1", s1)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("s2", s2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)
|
||||
|
||||
// Reset log
|
||||
callOrder = nil
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder)
|
||||
}
|
||||
|
||||
func TestCore_LifecycleErrors(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
s1 := &MockStartable{err: assert.AnError}
|
||||
s2 := &MockStoppable{err: assert.AnError}
|
||||
|
||||
_ = c.RegisterService("s1", s1)
|
||||
_ = c.RegisterService("s2", s2)
|
||||
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestCore_LifecycleErrors_Aggregated(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Register action that fails
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if _, ok := msg.(ActionServiceStartup); ok {
|
||||
return errors.New("startup action error")
|
||||
}
|
||||
if _, ok := msg.(ActionServiceShutdown); ok {
|
||||
return errors.New("shutdown action error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register service that fails
|
||||
s1 := &MockStartable{err: errors.New("startup service error")}
|
||||
s2 := &MockStoppable{err: errors.New("shutdown service error")}
|
||||
|
||||
err = c.RegisterService("s1", s1)
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("s2", s2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Startup
|
||||
err = c.ServiceStartup(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "startup action error")
|
||||
assert.Contains(t, err.Error(), "startup service error")
|
||||
|
||||
// Shutdown
|
||||
err = c.ServiceShutdown(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "shutdown action error")
|
||||
assert.Contains(t, err.Error(), "shutdown service error")
|
||||
}
|
||||
|
|
@ -1,346 +1,97 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"context"
|
||||
"embed"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// mockApp is a simple mock for testing app injection
|
||||
type mockApp struct{}
|
||||
// --- New ---
|
||||
|
||||
func TestCore_New_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
func TestNew_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
// Mock service for testing
|
||||
type MockService struct {
|
||||
Name string
|
||||
func TestNew_WithOptions_Good(t *testing.T) {
|
||||
c := New(Options{{Key: "name", Value: "myapp"}})
|
||||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "myapp", c.App().Name)
|
||||
}
|
||||
|
||||
func (m *MockService) GetName() string {
|
||||
return m.Name
|
||||
func TestNew_WithOptions_Bad(t *testing.T) {
|
||||
// Empty options — should still create a valid Core
|
||||
c := New(Options{})
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestCore_WithService_Good(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
}
|
||||
c, err := New(WithService(factory))
|
||||
assert.NoError(t, err)
|
||||
svc := c.Service().Get("core")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.GetName())
|
||||
}
|
||||
// --- Accessors ---
|
||||
|
||||
func TestCore_WithService_Bad(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err := New(WithService(factory))
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
type MockConfigService struct{}
|
||||
|
||||
func (m *MockConfigService) Get(key string, out any) error { return nil }
|
||||
func (m *MockConfigService) Set(key string, v any) error { return nil }
|
||||
|
||||
func TestCore_Services_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = c.RegisterService("config", &MockConfigService{})
|
||||
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)
|
||||
}
|
||||
|
||||
func TestCore_App_Good(t *testing.T) {
|
||||
app := &mockApp{}
|
||||
c, err := New(WithApp(app))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// To test the global CoreGUI() function, we need to set the global instance.
|
||||
originalInstance := GetInstance()
|
||||
SetInstance(c)
|
||||
defer SetInstance(originalInstance)
|
||||
|
||||
assert.Equal(t, app, CoreGUI())
|
||||
}
|
||||
|
||||
func TestCore_App_Ugly(t *testing.T) {
|
||||
// This test ensures that calling CoreGUI() before the core is initialized panics.
|
||||
originalInstance := GetInstance()
|
||||
ClearInstance()
|
||||
defer SetInstance(originalInstance)
|
||||
assert.Panics(t, func() {
|
||||
CoreGUI()
|
||||
})
|
||||
}
|
||||
|
||||
func TestCore_Core_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
func TestAccessors_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.App())
|
||||
assert.NotNil(t, c.Data())
|
||||
assert.NotNil(t, c.Drive())
|
||||
assert.NotNil(t, c.Fs())
|
||||
assert.NotNil(t, c.Config())
|
||||
assert.NotNil(t, c.Error())
|
||||
assert.NotNil(t, c.Log())
|
||||
assert.NotNil(t, c.Cli())
|
||||
assert.NotNil(t, c.IPC())
|
||||
assert.NotNil(t, c.I18n())
|
||||
assert.Equal(t, c, c.Core())
|
||||
}
|
||||
|
||||
func TestEtc_Features_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
c.Config().Enable("feature1")
|
||||
c.Config().Enable("feature2")
|
||||
|
||||
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 TestEtc_Settings_Good(t *testing.T) {
|
||||
c, _ := New()
|
||||
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) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
var messageReceived Message
|
||||
handler := func(c *Core, msg Message) error {
|
||||
messageReceived = msg
|
||||
return nil
|
||||
}
|
||||
c.RegisterAction(handler)
|
||||
|
||||
// Test Startup
|
||||
_ = c.ServiceStartup(context.TODO(), nil)
|
||||
_, ok := messageReceived.(ActionServiceStartup)
|
||||
assert.True(t, ok, "expected ActionServiceStartup message")
|
||||
|
||||
// Test Shutdown
|
||||
_ = c.ServiceShutdown(context.TODO())
|
||||
_, ok = messageReceived.(ActionServiceShutdown)
|
||||
assert.True(t, ok, "expected ActionServiceShutdown message")
|
||||
}
|
||||
|
||||
func TestCore_WithApp_Good(t *testing.T) {
|
||||
app := &mockApp{}
|
||||
c, err := New(WithApp(app))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, app, c.App().Runtime)
|
||||
}
|
||||
|
||||
//go:embed testdata
|
||||
var testFS embed.FS
|
||||
|
||||
func TestCore_WithAssets_Good(t *testing.T) {
|
||||
c, err := New(WithAssets(testFS))
|
||||
assert.NoError(t, err)
|
||||
file, err := c.Embed().Open("testdata/test.txt")
|
||||
assert.NoError(t, err)
|
||||
defer func() { _ = file.Close() }()
|
||||
content, err := io.ReadAll(file)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello from testdata\n", string(content))
|
||||
}
|
||||
|
||||
func TestCore_WithServiceLock_Good(t *testing.T) {
|
||||
c, err := New(WithServiceLock())
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCore_RegisterService_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{Name: "test"})
|
||||
assert.NoError(t, err)
|
||||
svc := c.Service("test")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_RegisterService_Bad(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{})
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{})
|
||||
assert.Error(t, err)
|
||||
err = c.RegisterService("", &MockService{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCore_ServiceFor_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{Name: "test"})
|
||||
assert.NoError(t, err)
|
||||
svc, err := ServiceFor[*MockService](c, "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test", svc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_ServiceFor_Bad(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
_, err = ServiceFor[*MockService](c, "nonexistent")
|
||||
assert.Error(t, err)
|
||||
err = c.RegisterService("test", "not a service")
|
||||
assert.NoError(t, err)
|
||||
_, err = ServiceFor[*MockService](c, "test")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCore_MustServiceFor_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
err = c.RegisterService("test", &MockService{Name: "test"})
|
||||
assert.NoError(t, err)
|
||||
svc := MustServiceFor[*MockService](c, "test")
|
||||
assert.Equal(t, "test", svc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_MustServiceFor_Ugly(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// MustServiceFor panics on missing service
|
||||
assert.Panics(t, func() {
|
||||
MustServiceFor[*MockService](c, "nonexistent")
|
||||
func TestOptions_Accessor_Good(t *testing.T) {
|
||||
c := New(Options{
|
||||
{Key: "name", Value: "testapp"},
|
||||
{Key: "port", Value: 8080},
|
||||
{Key: "debug", Value: true},
|
||||
})
|
||||
opts := c.Options()
|
||||
assert.NotNil(t, opts)
|
||||
assert.Equal(t, "testapp", opts.String("name"))
|
||||
assert.Equal(t, 8080, opts.Int("port"))
|
||||
assert.True(t, opts.Bool("debug"))
|
||||
}
|
||||
|
||||
err = c.RegisterService("test", "not a service")
|
||||
assert.NoError(t, err)
|
||||
func TestOptions_Accessor_Nil(t *testing.T) {
|
||||
c := New()
|
||||
// No options passed — Options() returns nil
|
||||
assert.Nil(t, c.Options())
|
||||
}
|
||||
|
||||
// MustServiceFor panics on type mismatch
|
||||
// --- Core Error/Log Helpers ---
|
||||
|
||||
func TestCore_LogError_Good(t *testing.T) {
|
||||
c := New()
|
||||
cause := assert.AnError
|
||||
r := c.LogError(cause, "test.Operation", "something broke")
|
||||
assert.False(t, r.OK)
|
||||
err, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestCore_LogWarn_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.LogWarn(assert.AnError, "test.Operation", "heads up")
|
||||
assert.False(t, r.OK)
|
||||
_, ok := r.Value.(error)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestCore_Must_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
assert.Panics(t, func() {
|
||||
MustServiceFor[*MockService](c, "test")
|
||||
c.Must(assert.AnError, "test.Operation", "fatal")
|
||||
})
|
||||
}
|
||||
|
||||
type MockAction struct {
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (a *MockAction) Handle(c *Core, msg Message) error {
|
||||
a.handled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCore_ACTION_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
action := &MockAction{}
|
||||
c.RegisterAction(action.Handle)
|
||||
err = c.ACTION(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, action.handled)
|
||||
}
|
||||
|
||||
func TestCore_RegisterActions_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
action1 := &MockAction{}
|
||||
action2 := &MockAction{}
|
||||
c.RegisterActions(action1.Handle, action2.Handle)
|
||||
err = c.ACTION(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, action1.handled)
|
||||
assert.True(t, action2.handled)
|
||||
}
|
||||
|
||||
func TestCore_WithName_Good(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
}
|
||||
c, err := New(WithName("my-service", factory))
|
||||
assert.NoError(t, err)
|
||||
svc := c.Service("my-service")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.GetName())
|
||||
}
|
||||
|
||||
func TestCore_WithName_Bad(t *testing.T) {
|
||||
factory := func(c *Core) (any, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err := New(WithName("my-service", factory))
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestCore_GlobalInstance_ThreadSafety_Good(t *testing.T) {
|
||||
// Save original instance
|
||||
original := GetInstance()
|
||||
defer SetInstance(original)
|
||||
|
||||
// Test SetInstance/GetInstance
|
||||
c1, _ := New()
|
||||
SetInstance(c1)
|
||||
assert.Equal(t, c1, GetInstance())
|
||||
|
||||
// Test ClearInstance
|
||||
ClearInstance()
|
||||
assert.Nil(t, GetInstance())
|
||||
|
||||
// Test concurrent access (race detector should catch issues)
|
||||
c2, _ := New(WithApp(&mockApp{}))
|
||||
done := make(chan bool)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
SetInstance(c2)
|
||||
_ = GetInstance()
|
||||
done <- true
|
||||
}()
|
||||
go func() {
|
||||
inst := GetInstance()
|
||||
if inst != nil {
|
||||
_ = inst.App
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 20; i++ {
|
||||
<-done
|
||||
}
|
||||
func TestCore_Must_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotPanics(t, func() {
|
||||
c.Must(nil, "test.Operation", "no error")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
130
tests/data_test.go
Normal file
130
tests/data_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testFS embed.FS
|
||||
|
||||
// --- Data (Embedded Content Mounts) ---
|
||||
|
||||
func TestData_New_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "test"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestData_New_Bad(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
r := c.Data().New(Options{{Key: "source", Value: testFS}})
|
||||
assert.False(t, r.OK)
|
||||
|
||||
r = c.Data().New(Options{{Key: "name", Value: "test"}})
|
||||
assert.False(t, r.OK)
|
||||
|
||||
r = c.Data().New(Options{{Key: "name", Value: "test"}, {Key: "source", Value: "not-an-fs"}})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ReadString_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
r := c.Data().ReadString("app/test.txt")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello from testdata\n", r.Value.(string))
|
||||
}
|
||||
|
||||
func TestData_ReadString_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().ReadString("nonexistent/file.txt")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ReadFile_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
r := c.Data().ReadFile("app/test.txt")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello from testdata\n", string(r.Value.([]byte)))
|
||||
}
|
||||
|
||||
func TestData_Get_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "brain"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
gr := c.Data().Get("brain")
|
||||
assert.True(t, gr.OK)
|
||||
emb := gr.Value.(*Embed)
|
||||
|
||||
r := emb.Open("test.txt")
|
||||
assert.True(t, r.OK)
|
||||
file := r.Value.(io.ReadCloser)
|
||||
defer file.Close()
|
||||
content, _ := io.ReadAll(file)
|
||||
assert.Equal(t, "hello from testdata\n", string(content))
|
||||
}
|
||||
|
||||
func TestData_Get_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().Get("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_Mounts_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "a"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
c.Data().New(Options{{Key: "name", Value: "b"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
mounts := c.Data().Mounts()
|
||||
assert.Len(t, mounts, 2)
|
||||
}
|
||||
|
||||
func TestEmbed_Legacy_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "testdata"}})
|
||||
assert.NotNil(t, c.Embed())
|
||||
}
|
||||
|
||||
func TestData_List_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().List("app/testdata")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_List_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().List("nonexistent/path")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_ListNames_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().ListNames("app/testdata")
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.([]string), "test")
|
||||
}
|
||||
|
||||
func TestData_Extract_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Data().New(Options{{Key: "name", Value: "app"}, {Key: "source", Value: testFS}, {Key: "path", Value: "."}})
|
||||
r := c.Data().Extract("app/testdata", t.TempDir(), nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestData_Extract_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().Extract("nonexistent/path", t.TempDir(), nil)
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
80
tests/drive_test.go
Normal file
80
tests/drive_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Drive (Transport Handles) ---
|
||||
|
||||
func TestDrive_New_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Drive().New(Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "api", r.Value.(*DriveHandle).Name)
|
||||
assert.Equal(t, "https://api.lthn.ai", r.Value.(*DriveHandle).Transport)
|
||||
}
|
||||
|
||||
func TestDrive_New_Bad(t *testing.T) {
|
||||
c := New()
|
||||
// Missing name
|
||||
r := c.Drive().New(Options{
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestDrive_Get_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Drive().New(Options{
|
||||
{Key: "name", Value: "ssh"},
|
||||
{Key: "transport", Value: "ssh://claude@10.69.69.165"},
|
||||
})
|
||||
r := c.Drive().Get("ssh")
|
||||
assert.True(t, r.OK)
|
||||
handle := r.Value.(*DriveHandle)
|
||||
assert.Equal(t, "ssh://claude@10.69.69.165", handle.Transport)
|
||||
}
|
||||
|
||||
func TestDrive_Get_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Drive().Get("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestDrive_Has_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Drive().New(Options{{Key: "name", Value: "mcp"}, {Key: "transport", Value: "mcp://mcp.lthn.sh"}})
|
||||
assert.True(t, c.Drive().Has("mcp"))
|
||||
assert.False(t, c.Drive().Has("missing"))
|
||||
}
|
||||
|
||||
func TestDrive_Names_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Drive().New(Options{{Key: "name", Value: "api"}, {Key: "transport", Value: "https://api.lthn.ai"}})
|
||||
c.Drive().New(Options{{Key: "name", Value: "ssh"}, {Key: "transport", Value: "ssh://claude@10.69.69.165"}})
|
||||
c.Drive().New(Options{{Key: "name", Value: "mcp"}, {Key: "transport", Value: "mcp://mcp.lthn.sh"}})
|
||||
names := c.Drive().Names()
|
||||
assert.Len(t, names, 3)
|
||||
assert.Contains(t, names, "api")
|
||||
assert.Contains(t, names, "ssh")
|
||||
assert.Contains(t, names, "mcp")
|
||||
}
|
||||
|
||||
func TestDrive_OptionsPreserved_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Drive().New(Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
{Key: "timeout", Value: 30},
|
||||
})
|
||||
r := c.Drive().Get("api")
|
||||
assert.True(t, r.OK)
|
||||
handle := r.Value.(*DriveHandle)
|
||||
assert.Equal(t, 30, handle.Options.Int("timeout"))
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestE_Good(t *testing.T) {
|
||||
err := E("test.op", "test message", assert.AnError)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "test.op: test message: assert.AnError general error for testing", err.Error())
|
||||
|
||||
err = E("test.op", "test message", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "test.op: test message", err.Error())
|
||||
}
|
||||
|
||||
func TestE_Unwrap(t *testing.T) {
|
||||
originalErr := errors.New("original error")
|
||||
err := E("test.op", "test message", originalErr)
|
||||
|
||||
assert.True(t, errors.Is(err, originalErr))
|
||||
|
||||
var eErr *Err
|
||||
assert.True(t, errors.As(err, &eErr))
|
||||
assert.Equal(t, "test.op", eErr.Op)
|
||||
}
|
||||
168
tests/embed_test.go
Normal file
168
tests/embed_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Mount ---
|
||||
|
||||
func TestMount_Good(t *testing.T) {
|
||||
r := Mount(testFS, "testdata")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestMount_Bad(t *testing.T) {
|
||||
r := Mount(testFS, "nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
// --- Embed methods ---
|
||||
|
||||
func TestEmbed_ReadFile_Good(t *testing.T) {
|
||||
emb := Mount(testFS, "testdata").Value.(*Embed)
|
||||
r := emb.ReadFile("test.txt")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello from testdata\n", string(r.Value.([]byte)))
|
||||
}
|
||||
|
||||
func TestEmbed_ReadString_Good(t *testing.T) {
|
||||
emb := Mount(testFS, "testdata").Value.(*Embed)
|
||||
r := emb.ReadString("test.txt")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello from testdata\n", r.Value.(string))
|
||||
}
|
||||
|
||||
func TestEmbed_Open_Good(t *testing.T) {
|
||||
emb := Mount(testFS, "testdata").Value.(*Embed)
|
||||
r := emb.Open("test.txt")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_ReadDir_Good(t *testing.T) {
|
||||
emb := Mount(testFS, "testdata").Value.(*Embed)
|
||||
r := emb.ReadDir(".")
|
||||
assert.True(t, r.OK)
|
||||
assert.NotEmpty(t, r.Value)
|
||||
}
|
||||
|
||||
func TestEmbed_Sub_Good(t *testing.T) {
|
||||
emb := Mount(testFS, ".").Value.(*Embed)
|
||||
r := emb.Sub("testdata")
|
||||
assert.True(t, r.OK)
|
||||
sub := r.Value.(*Embed)
|
||||
r2 := sub.ReadFile("test.txt")
|
||||
assert.True(t, r2.OK)
|
||||
}
|
||||
|
||||
func TestEmbed_BaseDir_Good(t *testing.T) {
|
||||
emb := Mount(testFS, "testdata").Value.(*Embed)
|
||||
assert.Equal(t, "testdata", emb.BaseDirectory())
|
||||
}
|
||||
|
||||
func TestEmbed_FS_Good(t *testing.T) {
|
||||
emb := Mount(testFS, "testdata").Value.(*Embed)
|
||||
assert.NotNil(t, emb.FS())
|
||||
}
|
||||
|
||||
func TestEmbed_EmbedFS_Good(t *testing.T) {
|
||||
emb := Mount(testFS, "testdata").Value.(*Embed)
|
||||
efs := emb.EmbedFS()
|
||||
_, err := efs.ReadFile("testdata/test.txt")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- Extract ---
|
||||
|
||||
func TestExtract_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := Extract(testFS, dir, nil)
|
||||
assert.True(t, r.OK)
|
||||
|
||||
content, err := os.ReadFile(dir + "/testdata/test.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello from testdata\n", string(content))
|
||||
}
|
||||
|
||||
// --- Asset Pack ---
|
||||
|
||||
func TestAddGetAsset_Good(t *testing.T) {
|
||||
AddAsset("test-group", "greeting", mustCompress("hello world"))
|
||||
r := GetAsset("test-group", "greeting")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello world", r.Value.(string))
|
||||
}
|
||||
|
||||
func TestGetAsset_Bad(t *testing.T) {
|
||||
r := GetAsset("missing-group", "missing")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestGetAssetBytes_Good(t *testing.T) {
|
||||
AddAsset("bytes-group", "file", mustCompress("binary content"))
|
||||
r := GetAssetBytes("bytes-group", "file")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, []byte("binary content"), r.Value.([]byte))
|
||||
}
|
||||
|
||||
func TestMountEmbed_Good(t *testing.T) {
|
||||
r := MountEmbed(testFS, "testdata")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
// --- ScanAssets ---
|
||||
|
||||
func TestScanAssets_Good(t *testing.T) {
|
||||
r := ScanAssets([]string{"testdata/scantest/sample.go"})
|
||||
assert.True(t, r.OK)
|
||||
pkgs := r.Value.([]ScannedPackage)
|
||||
assert.Len(t, pkgs, 1)
|
||||
assert.Equal(t, "scantest", pkgs[0].PackageName)
|
||||
}
|
||||
|
||||
func TestScanAssets_Bad(t *testing.T) {
|
||||
r := ScanAssets([]string{"nonexistent.go"})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestGeneratePack_Empty_Good(t *testing.T) {
|
||||
pkg := ScannedPackage{PackageName: "empty"}
|
||||
r := GeneratePack(pkg)
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "package empty")
|
||||
}
|
||||
|
||||
func TestGeneratePack_WithFiles_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assetDir := dir + "/mygroup"
|
||||
os.MkdirAll(assetDir, 0755)
|
||||
os.WriteFile(assetDir+"/hello.txt", []byte("hello world"), 0644)
|
||||
|
||||
source := "package test\nimport \"forge.lthn.ai/core/go/pkg/core\"\nfunc example() {\n\t_, _ = core.GetAsset(\"mygroup\", \"hello.txt\")\n}\n"
|
||||
goFile := dir + "/test.go"
|
||||
os.WriteFile(goFile, []byte(source), 0644)
|
||||
|
||||
sr := ScanAssets([]string{goFile})
|
||||
assert.True(t, sr.OK)
|
||||
pkgs := sr.Value.([]ScannedPackage)
|
||||
|
||||
r := GeneratePack(pkgs[0])
|
||||
assert.True(t, r.OK)
|
||||
assert.Contains(t, r.Value.(string), "core.AddAsset")
|
||||
}
|
||||
|
||||
func mustCompress(input string) string {
|
||||
var buf bytes.Buffer
|
||||
b64 := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
gz, _ := gzip.NewWriterLevel(b64, gzip.BestCompression)
|
||||
gz.Write([]byte(input))
|
||||
gz.Close()
|
||||
b64.Close()
|
||||
return buf.String()
|
||||
}
|
||||
229
tests/error_test.go
Normal file
229
tests/error_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Error Creation ---
|
||||
|
||||
func TestE_Good(t *testing.T) {
|
||||
err := E("user.Save", "failed to save", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user.Save")
|
||||
assert.Contains(t, err.Error(), "failed to save")
|
||||
}
|
||||
|
||||
func TestE_WithCause_Good(t *testing.T) {
|
||||
cause := errors.New("connection refused")
|
||||
err := E("db.Connect", "database unavailable", cause)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestWrap_Good(t *testing.T) {
|
||||
cause := errors.New("timeout")
|
||||
err := Wrap(cause, "api.Call", "request failed")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestWrap_Nil_Good(t *testing.T) {
|
||||
err := Wrap(nil, "api.Call", "request failed")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestWrapCode_Good(t *testing.T) {
|
||||
cause := errors.New("invalid email")
|
||||
err := WrapCode(cause, "VALIDATION_ERROR", "user.Validate", "bad input")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "VALIDATION_ERROR", ErrorCode(err))
|
||||
}
|
||||
|
||||
func TestNewCode_Good(t *testing.T) {
|
||||
err := NewCode("NOT_FOUND", "resource not found")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "NOT_FOUND", ErrorCode(err))
|
||||
}
|
||||
|
||||
// --- Error Introspection ---
|
||||
|
||||
func TestOperation_Good(t *testing.T) {
|
||||
err := E("brain.Recall", "search failed", nil)
|
||||
assert.Equal(t, "brain.Recall", Operation(err))
|
||||
}
|
||||
|
||||
func TestOperation_Bad(t *testing.T) {
|
||||
err := errors.New("plain error")
|
||||
assert.Equal(t, "", Operation(err))
|
||||
}
|
||||
|
||||
func TestErrorMessage_Good(t *testing.T) {
|
||||
err := E("op", "the message", nil)
|
||||
assert.Equal(t, "the message", ErrorMessage(err))
|
||||
}
|
||||
|
||||
func TestErrorMessage_Plain(t *testing.T) {
|
||||
err := errors.New("plain")
|
||||
assert.Equal(t, "plain", ErrorMessage(err))
|
||||
}
|
||||
|
||||
func TestErrorMessage_Nil(t *testing.T) {
|
||||
assert.Equal(t, "", ErrorMessage(nil))
|
||||
}
|
||||
|
||||
func TestRoot_Good(t *testing.T) {
|
||||
root := errors.New("root cause")
|
||||
wrapped := Wrap(root, "layer1", "first wrap")
|
||||
double := Wrap(wrapped, "layer2", "second wrap")
|
||||
assert.Equal(t, root, Root(double))
|
||||
}
|
||||
|
||||
func TestRoot_Nil(t *testing.T) {
|
||||
assert.Nil(t, Root(nil))
|
||||
}
|
||||
|
||||
func TestStackTrace_Good(t *testing.T) {
|
||||
err := Wrap(E("inner", "cause", nil), "outer", "wrapper")
|
||||
stack := StackTrace(err)
|
||||
assert.Len(t, stack, 2)
|
||||
assert.Equal(t, "outer", stack[0])
|
||||
assert.Equal(t, "inner", stack[1])
|
||||
}
|
||||
|
||||
func TestFormatStackTrace_Good(t *testing.T) {
|
||||
err := Wrap(E("a", "x", nil), "b", "y")
|
||||
formatted := FormatStackTrace(err)
|
||||
assert.Equal(t, "b -> a", formatted)
|
||||
}
|
||||
|
||||
// --- ErrorLog ---
|
||||
|
||||
func TestErrorLog_Good(t *testing.T) {
|
||||
c := New()
|
||||
cause := errors.New("boom")
|
||||
r := c.Log().Error(cause, "test.Operation", "something broke")
|
||||
assert.False(t, r.OK)
|
||||
assert.ErrorIs(t, r.Value.(error), cause)
|
||||
}
|
||||
|
||||
func TestErrorLog_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Log().Error(nil, "test.Operation", "no error")
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestErrorLog_Warn_Good(t *testing.T) {
|
||||
c := New()
|
||||
cause := errors.New("warning")
|
||||
r := c.Log().Warn(cause, "test.Operation", "heads up")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestErrorLog_Must_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
assert.Panics(t, func() {
|
||||
c.Log().Must(errors.New("fatal"), "test.Operation", "must fail")
|
||||
})
|
||||
}
|
||||
|
||||
func TestErrorLog_Must_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotPanics(t, func() {
|
||||
c.Log().Must(nil, "test.Operation", "no error")
|
||||
})
|
||||
}
|
||||
|
||||
// --- ErrorPanic ---
|
||||
|
||||
func TestErrorPanic_Recover_Good(t *testing.T) {
|
||||
c := New()
|
||||
// Should not panic — Recover catches it
|
||||
assert.NotPanics(t, func() {
|
||||
defer c.Error().Recover()
|
||||
panic("test panic")
|
||||
})
|
||||
}
|
||||
|
||||
func TestErrorPanic_SafeGo_Good(t *testing.T) {
|
||||
c := New()
|
||||
done := make(chan bool, 1)
|
||||
c.Error().SafeGo(func() {
|
||||
done <- true
|
||||
})
|
||||
assert.True(t, <-done)
|
||||
}
|
||||
|
||||
func TestErrorPanic_SafeGo_Panic_Good(t *testing.T) {
|
||||
c := New()
|
||||
done := make(chan bool, 1)
|
||||
c.Error().SafeGo(func() {
|
||||
defer func() { done <- true }()
|
||||
panic("caught by SafeGo")
|
||||
})
|
||||
// SafeGo recovers — goroutine completes without crashing the process
|
||||
<-done
|
||||
}
|
||||
|
||||
// --- Standard Library Wrappers ---
|
||||
|
||||
func TestIs_Good(t *testing.T) {
|
||||
target := errors.New("target")
|
||||
wrapped := Wrap(target, "op", "msg")
|
||||
assert.True(t, Is(wrapped, target))
|
||||
}
|
||||
|
||||
func TestAs_Good(t *testing.T) {
|
||||
err := E("op", "msg", nil)
|
||||
var e *Err
|
||||
assert.True(t, As(err, &e))
|
||||
assert.Equal(t, "op", e.Operation)
|
||||
}
|
||||
|
||||
func TestNewError_Good(t *testing.T) {
|
||||
err := NewError("simple error")
|
||||
assert.Equal(t, "simple error", err.Error())
|
||||
}
|
||||
|
||||
func TestErrorJoin_Good(t *testing.T) {
|
||||
e1 := errors.New("first")
|
||||
e2 := errors.New("second")
|
||||
joined := ErrorJoin(e1, e2)
|
||||
assert.ErrorIs(t, joined, e1)
|
||||
assert.ErrorIs(t, joined, e2)
|
||||
}
|
||||
|
||||
// --- ErrorPanic Crash Reports ---
|
||||
|
||||
func TestErrorPanic_Reports_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/crashes.json"
|
||||
|
||||
// Create ErrorPanic with file output
|
||||
c := New()
|
||||
// Access internals via a crash that writes to file
|
||||
// Since ErrorPanic fields are unexported, we test via Recover
|
||||
_ = c
|
||||
_ = path
|
||||
// Crash reporting needs ErrorPanic configured with filePath — tested indirectly
|
||||
}
|
||||
|
||||
// --- ErrorPanic Crash File ---
|
||||
|
||||
func TestErrorPanic_CrashFile_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/crashes.json"
|
||||
|
||||
// Create Core, trigger a panic through SafeGo, check crash file
|
||||
// ErrorPanic.filePath is unexported — but we can test via the package-level
|
||||
// error handling that writes crash reports
|
||||
|
||||
// For now, test that Reports handles missing file gracefully
|
||||
c := New()
|
||||
r := c.Error().Reports(5)
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
_ = path
|
||||
}
|
||||
186
tests/fs_test.go
Normal file
186
tests/fs_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Fs (Sandboxed Filesystem) ---
|
||||
|
||||
func TestFs_WriteRead_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
assert.True(t, c.Fs().Write(path, "hello core").OK)
|
||||
|
||||
r := c.Fs().Read(path)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello core", r.Value.(string))
|
||||
}
|
||||
|
||||
func TestFs_Read_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Fs().Read("/nonexistent/path/to/file.txt")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestFs_EnsureDir_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "sub", "dir")
|
||||
assert.True(t, c.Fs().EnsureDir(path).OK)
|
||||
assert.True(t, c.Fs().IsDir(path))
|
||||
}
|
||||
|
||||
func TestFs_IsDir_Good(t *testing.T) {
|
||||
c := New()
|
||||
dir := t.TempDir()
|
||||
assert.True(t, c.Fs().IsDir(dir))
|
||||
assert.False(t, c.Fs().IsDir(filepath.Join(dir, "nonexistent")))
|
||||
assert.False(t, c.Fs().IsDir(""))
|
||||
}
|
||||
|
||||
func TestFs_IsFile_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
c.Fs().Write(path, "data")
|
||||
assert.True(t, c.Fs().IsFile(path))
|
||||
assert.False(t, c.Fs().IsFile(dir))
|
||||
assert.False(t, c.Fs().IsFile(""))
|
||||
}
|
||||
|
||||
func TestFs_Exists_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "exists.txt")
|
||||
c.Fs().Write(path, "yes")
|
||||
assert.True(t, c.Fs().Exists(path))
|
||||
assert.True(t, c.Fs().Exists(dir))
|
||||
assert.False(t, c.Fs().Exists(filepath.Join(dir, "nope")))
|
||||
}
|
||||
|
||||
func TestFs_List_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
c.Fs().Write(filepath.Join(dir, "a.txt"), "a")
|
||||
c.Fs().Write(filepath.Join(dir, "b.txt"), "b")
|
||||
r := c.Fs().List(dir)
|
||||
assert.True(t, r.OK)
|
||||
assert.Len(t, r.Value.([]fs.DirEntry), 2)
|
||||
}
|
||||
|
||||
func TestFs_Stat_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "stat.txt")
|
||||
c.Fs().Write(path, "data")
|
||||
r := c.Fs().Stat(path)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "stat.txt", r.Value.(os.FileInfo).Name())
|
||||
}
|
||||
|
||||
func TestFs_Open_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "open.txt")
|
||||
c.Fs().Write(path, "content")
|
||||
r := c.Fs().Open(path)
|
||||
assert.True(t, r.OK)
|
||||
r.Value.(io.Closer).Close()
|
||||
}
|
||||
|
||||
func TestFs_Create_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "sub", "created.txt")
|
||||
r := c.Fs().Create(path)
|
||||
assert.True(t, r.OK)
|
||||
w := r.Value.(io.WriteCloser)
|
||||
w.Write([]byte("hello"))
|
||||
w.Close()
|
||||
rr := c.Fs().Read(path)
|
||||
assert.Equal(t, "hello", rr.Value.(string))
|
||||
}
|
||||
|
||||
func TestFs_Append_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "append.txt")
|
||||
c.Fs().Write(path, "first")
|
||||
r := c.Fs().Append(path)
|
||||
assert.True(t, r.OK)
|
||||
w := r.Value.(io.WriteCloser)
|
||||
w.Write([]byte(" second"))
|
||||
w.Close()
|
||||
rr := c.Fs().Read(path)
|
||||
assert.Equal(t, "first second", rr.Value.(string))
|
||||
}
|
||||
|
||||
func TestFs_ReadStream_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "stream.txt")
|
||||
c.Fs().Write(path, "streamed")
|
||||
r := c.Fs().ReadStream(path)
|
||||
assert.True(t, r.OK)
|
||||
r.Value.(io.Closer).Close()
|
||||
}
|
||||
|
||||
func TestFs_WriteStream_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "sub", "ws.txt")
|
||||
r := c.Fs().WriteStream(path)
|
||||
assert.True(t, r.OK)
|
||||
w := r.Value.(io.WriteCloser)
|
||||
w.Write([]byte("stream"))
|
||||
w.Close()
|
||||
}
|
||||
|
||||
func TestFs_Delete_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "delete.txt")
|
||||
c.Fs().Write(path, "gone")
|
||||
assert.True(t, c.Fs().Delete(path).OK)
|
||||
assert.False(t, c.Fs().Exists(path))
|
||||
}
|
||||
|
||||
func TestFs_DeleteAll_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
sub := filepath.Join(dir, "deep", "nested")
|
||||
c.Fs().EnsureDir(sub)
|
||||
c.Fs().Write(filepath.Join(sub, "file.txt"), "data")
|
||||
assert.True(t, c.Fs().DeleteAll(filepath.Join(dir, "deep")).OK)
|
||||
assert.False(t, c.Fs().Exists(filepath.Join(dir, "deep")))
|
||||
}
|
||||
|
||||
func TestFs_Rename_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
old := filepath.Join(dir, "old.txt")
|
||||
nw := filepath.Join(dir, "new.txt")
|
||||
c.Fs().Write(old, "data")
|
||||
assert.True(t, c.Fs().Rename(old, nw).OK)
|
||||
assert.False(t, c.Fs().Exists(old))
|
||||
assert.True(t, c.Fs().Exists(nw))
|
||||
}
|
||||
|
||||
func TestFs_WriteMode_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := New()
|
||||
path := filepath.Join(dir, "secret.txt")
|
||||
assert.True(t, c.Fs().WriteMode(path, "secret", 0600).OK)
|
||||
r := c.Fs().Stat(path)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "secret.txt", r.Value.(os.FileInfo).Name())
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzE exercises the E() error constructor with arbitrary input.
|
||||
func FuzzE(f *testing.F) {
|
||||
f.Add("svc.Method", "something broke", true)
|
||||
f.Add("", "", false)
|
||||
f.Add("a.b.c.d.e.f", "unicode: \u00e9\u00e8\u00ea", true)
|
||||
|
||||
f.Fuzz(func(t *testing.T, op, msg string, withErr bool) {
|
||||
var underlying error
|
||||
if withErr {
|
||||
underlying = errors.New("wrapped")
|
||||
}
|
||||
|
||||
e := E(op, msg, underlying)
|
||||
if e == nil {
|
||||
t.Fatal("E() returned nil")
|
||||
}
|
||||
|
||||
s := e.Error()
|
||||
if s == "" && (op != "" || msg != "") {
|
||||
t.Fatal("Error() returned empty string for non-empty op/msg")
|
||||
}
|
||||
|
||||
// Round-trip: Unwrap should return the underlying error
|
||||
var coreErr *Err
|
||||
if !errors.As(e, &coreErr) {
|
||||
t.Fatal("errors.As failed for *Err")
|
||||
}
|
||||
if withErr && coreErr.Unwrap() == nil {
|
||||
t.Fatal("Unwrap() returned nil with underlying error")
|
||||
}
|
||||
if !withErr && coreErr.Unwrap() != nil {
|
||||
t.Fatal("Unwrap() returned non-nil without underlying error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzServiceRegistration exercises service registration with arbitrary names.
|
||||
func FuzzServiceRegistration(f *testing.F) {
|
||||
f.Add("myservice")
|
||||
f.Add("")
|
||||
f.Add("a/b/c")
|
||||
f.Add("service with spaces")
|
||||
f.Add("service\x00null")
|
||||
|
||||
f.Fuzz(func(t *testing.T, name string) {
|
||||
c, _ := New()
|
||||
|
||||
err := c.RegisterService(name, struct{}{})
|
||||
if name == "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for name %q: %v", name, err)
|
||||
}
|
||||
|
||||
// Retrieve should return the same service
|
||||
got := c.Service(name)
|
||||
if got == nil {
|
||||
t.Fatalf("service %q not found after registration", name)
|
||||
}
|
||||
|
||||
// Duplicate registration should fail
|
||||
err = c.RegisterService(name, struct{}{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected duplicate error for name %q", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzMessageDispatch exercises action dispatch with concurrent registrations.
|
||||
func FuzzMessageDispatch(f *testing.F) {
|
||||
f.Add("hello")
|
||||
f.Add("")
|
||||
f.Add("test\nmultiline")
|
||||
|
||||
f.Fuzz(func(t *testing.T, payload string) {
|
||||
c, _ := New()
|
||||
|
||||
var received string
|
||||
c.IPC().RegisterAction(func(_ *Core, msg Message) error {
|
||||
received = msg.(string)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := c.IPC().Action(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("action dispatch failed: %v", err)
|
||||
}
|
||||
if received != payload {
|
||||
t.Fatalf("got %q, want %q", received, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
94
tests/i18n_test.go
Normal file
94
tests/i18n_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- I18n ---
|
||||
|
||||
func TestI18n_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.I18n())
|
||||
}
|
||||
|
||||
func TestI18n_AddLocales_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "lang"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
if r.OK {
|
||||
c.I18n().AddLocales(r.Value.(*Embed))
|
||||
}
|
||||
r2 := c.I18n().Locales()
|
||||
assert.True(t, r2.OK)
|
||||
assert.Len(t, r2.Value.([]*Embed), 1)
|
||||
}
|
||||
|
||||
func TestI18n_Locales_Empty_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.I18n().Locales()
|
||||
assert.True(t, r.OK)
|
||||
assert.Empty(t, r.Value.([]*Embed))
|
||||
}
|
||||
|
||||
// --- Translator (no translator registered) ---
|
||||
|
||||
func TestI18n_Translate_NoTranslator_Good(t *testing.T) {
|
||||
c := New()
|
||||
// Without a translator, Translate returns the key as-is
|
||||
r := c.I18n().Translate("greeting.hello")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "greeting.hello", r.Value)
|
||||
}
|
||||
|
||||
func TestI18n_SetLanguage_NoTranslator_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.I18n().SetLanguage("de")
|
||||
assert.True(t, r.OK) // no-op without translator
|
||||
}
|
||||
|
||||
func TestI18n_Language_NoTranslator_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.Equal(t, "en", c.I18n().Language())
|
||||
}
|
||||
|
||||
func TestI18n_AvailableLanguages_NoTranslator_Good(t *testing.T) {
|
||||
c := New()
|
||||
langs := c.I18n().AvailableLanguages()
|
||||
assert.Equal(t, []string{"en"}, langs)
|
||||
}
|
||||
|
||||
func TestI18n_Translator_Nil_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.False(t, c.I18n().Translator().OK)
|
||||
}
|
||||
|
||||
// --- Translator (with mock) ---
|
||||
|
||||
type mockTranslator struct {
|
||||
lang string
|
||||
}
|
||||
|
||||
func (m *mockTranslator) Translate(id string, args ...any) Result { return Result{"translated:" + id, true} }
|
||||
func (m *mockTranslator) SetLanguage(lang string) error { m.lang = lang; return nil }
|
||||
func (m *mockTranslator) Language() string { return m.lang }
|
||||
func (m *mockTranslator) AvailableLanguages() []string { return []string{"en", "de", "fr"} }
|
||||
|
||||
func TestI18n_WithTranslator_Good(t *testing.T) {
|
||||
c := New()
|
||||
tr := &mockTranslator{lang: "en"}
|
||||
c.I18n().SetTranslator(tr)
|
||||
|
||||
assert.Equal(t, tr, c.I18n().Translator().Value)
|
||||
assert.Equal(t, "translated:hello", c.I18n().Translate("hello").Value)
|
||||
assert.Equal(t, "en", c.I18n().Language())
|
||||
assert.Equal(t, []string{"en", "de", "fr"}, c.I18n().AvailableLanguages())
|
||||
|
||||
c.I18n().SetLanguage("de")
|
||||
assert.Equal(t, "de", c.I18n().Language())
|
||||
}
|
||||
|
|
@ -1,121 +1,95 @@
|
|||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type IPCTestQuery struct{ Value string }
|
||||
type IPCTestTask struct{ Value string }
|
||||
// --- IPC: Actions ---
|
||||
|
||||
func TestIPC_Query(t *testing.T) {
|
||||
c, _ := New()
|
||||
type testMessage struct{ payload string }
|
||||
|
||||
// No handler
|
||||
res, handled, err := c.QUERY(IPCTestQuery{})
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, res)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// With handler
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
if tq, ok := q.(IPCTestQuery); ok {
|
||||
return tq.Value + "-response", true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
func TestAction_Good(t *testing.T) {
|
||||
c := New()
|
||||
var received Message
|
||||
c.RegisterAction(func(_ *Core, msg Message) Result {
|
||||
received = msg
|
||||
return Result{OK: true}
|
||||
})
|
||||
|
||||
res, handled, err = c.QUERY(IPCTestQuery{Value: "test"})
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "test-response", res)
|
||||
r := c.ACTION(testMessage{payload: "hello"})
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, testMessage{payload: "hello"}, received)
|
||||
}
|
||||
|
||||
func TestIPC_QueryAll(t *testing.T) {
|
||||
c, _ := New()
|
||||
func TestAction_Multiple_Good(t *testing.T) {
|
||||
c := New()
|
||||
count := 0
|
||||
handler := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
|
||||
c.RegisterActions(handler, handler, handler)
|
||||
c.ACTION(nil)
|
||||
assert.Equal(t, 3, count)
|
||||
}
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "h1", true, nil
|
||||
})
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "h2", true, nil
|
||||
})
|
||||
func TestAction_None_Good(t *testing.T) {
|
||||
c := New()
|
||||
// No handlers registered — should succeed
|
||||
r := c.ACTION(nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
results, err := c.QUERYALL(IPCTestQuery{})
|
||||
assert.Nil(t, err)
|
||||
// --- IPC: Queries ---
|
||||
|
||||
func TestQuery_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
if q == "ping" {
|
||||
return Result{Value: "pong", OK: true}
|
||||
}
|
||||
return Result{}
|
||||
})
|
||||
r := c.QUERY("ping")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "pong", r.Value)
|
||||
}
|
||||
|
||||
func TestQuery_Unhandled_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, q Query) Result {
|
||||
return Result{}
|
||||
})
|
||||
r := c.QUERY("unknown")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestQueryAll_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterQuery(func(_ *Core, _ Query) Result {
|
||||
return Result{Value: "a", OK: true}
|
||||
})
|
||||
c.RegisterQuery(func(_ *Core, _ Query) Result {
|
||||
return Result{Value: "b", OK: true}
|
||||
})
|
||||
r := c.QUERYALL("anything")
|
||||
assert.True(t, r.OK)
|
||||
results := r.Value.([]any)
|
||||
assert.Len(t, results, 2)
|
||||
assert.Contains(t, results, "h1")
|
||||
assert.Contains(t, results, "h2")
|
||||
assert.Contains(t, results, "a")
|
||||
assert.Contains(t, results, "b")
|
||||
}
|
||||
|
||||
func TestIPC_Perform(t *testing.T) {
|
||||
c, _ := New()
|
||||
// --- IPC: Tasks ---
|
||||
|
||||
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
|
||||
if tt, ok := task.(IPCTestTask); ok {
|
||||
if tt.Value == "error" {
|
||||
return nil, true, errors.New("task error")
|
||||
func TestPerform_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterTask(func(_ *Core, t Task) Result {
|
||||
if t == "compute" {
|
||||
return Result{Value: 42, OK: true}
|
||||
}
|
||||
return "done", true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
return Result{}
|
||||
})
|
||||
|
||||
// Success
|
||||
res, handled, err := c.PERFORM(IPCTestTask{Value: "run"})
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "done", res)
|
||||
|
||||
// Error
|
||||
res, handled, err = c.PERFORM(IPCTestTask{Value: "error"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
||||
func TestIPC_PerformAsync(t *testing.T) {
|
||||
c, _ := New()
|
||||
|
||||
type AsyncResult struct {
|
||||
TaskID string
|
||||
Result any
|
||||
Error error
|
||||
}
|
||||
done := make(chan AsyncResult, 1)
|
||||
|
||||
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
|
||||
if tt, ok := task.(IPCTestTask); ok {
|
||||
return tt.Value + "-done", true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
c.RegisterAction(func(c *Core, msg Message) error {
|
||||
if m, ok := msg.(ActionTaskCompleted); ok {
|
||||
done <- AsyncResult{
|
||||
TaskID: m.TaskID,
|
||||
Result: m.Result,
|
||||
Error: m.Error,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
taskID := c.PerformAsync(IPCTestTask{Value: "async"})
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
assert.Equal(t, taskID, res.TaskID)
|
||||
assert.Equal(t, "async-done", res.Result)
|
||||
assert.Nil(t, res.Error)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for task completion")
|
||||
}
|
||||
r := c.PERFORM("compute")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 42, r.Value)
|
||||
}
|
||||
|
|
|
|||
55
tests/lock_test.go
Normal file
55
tests/lock_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLock_Good(t *testing.T) {
|
||||
c := New()
|
||||
lock := c.Lock("test")
|
||||
assert.NotNil(t, lock)
|
||||
assert.NotNil(t, lock.Mutex)
|
||||
}
|
||||
|
||||
func TestLock_SameName_Good(t *testing.T) {
|
||||
c := New()
|
||||
l1 := c.Lock("shared")
|
||||
l2 := c.Lock("shared")
|
||||
assert.Equal(t, l1, l2)
|
||||
}
|
||||
|
||||
func TestLock_DifferentName_Good(t *testing.T) {
|
||||
c := New()
|
||||
l1 := c.Lock("a")
|
||||
l2 := c.Lock("b")
|
||||
assert.NotEqual(t, l1, l2)
|
||||
}
|
||||
|
||||
func TestLockEnable_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("early", Service{})
|
||||
c.LockEnable()
|
||||
c.LockApply()
|
||||
|
||||
r := c.Service("late", Service{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestStartables_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }})
|
||||
r := c.Startables()
|
||||
assert.True(t, r.OK)
|
||||
assert.Len(t, r.Value.([]*Service), 1)
|
||||
}
|
||||
|
||||
func TestStoppables_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }})
|
||||
r := c.Stoppables()
|
||||
assert.True(t, r.OK)
|
||||
assert.Len(t, r.Value.([]*Service), 1)
|
||||
}
|
||||
147
tests/log_test.go
Normal file
147
tests/log_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Log ---
|
||||
|
||||
func TestLog_New_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
assert.NotNil(t, l)
|
||||
}
|
||||
|
||||
func TestLog_AllLevels_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelDebug})
|
||||
l.Debug("debug")
|
||||
l.Info("info")
|
||||
l.Warn("warn")
|
||||
l.Error("error")
|
||||
l.Security("security event")
|
||||
}
|
||||
|
||||
func TestLog_LevelFiltering_Good(t *testing.T) {
|
||||
// At Error level, Debug/Info/Warn should be suppressed (no panic)
|
||||
l := NewLog(LogOptions{Level: LevelError})
|
||||
l.Debug("suppressed")
|
||||
l.Info("suppressed")
|
||||
l.Warn("suppressed")
|
||||
l.Error("visible")
|
||||
}
|
||||
|
||||
func TestLog_SetLevel_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
l.SetLevel(LevelDebug)
|
||||
assert.Equal(t, LevelDebug, l.Level())
|
||||
}
|
||||
|
||||
func TestLog_SetRedactKeys_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
l.SetRedactKeys("password", "token")
|
||||
// Redacted keys should mask values in output
|
||||
l.Info("login", "password", "secret123", "user", "admin")
|
||||
}
|
||||
|
||||
func TestLog_LevelString_Good(t *testing.T) {
|
||||
assert.Equal(t, "debug", LevelDebug.String())
|
||||
assert.Equal(t, "info", LevelInfo.String())
|
||||
assert.Equal(t, "warn", LevelWarn.String())
|
||||
assert.Equal(t, "error", LevelError.String())
|
||||
}
|
||||
|
||||
func TestLog_CoreLog_Good(t *testing.T) {
|
||||
c := New()
|
||||
assert.NotNil(t, c.Log())
|
||||
}
|
||||
|
||||
func TestLog_ErrorSink_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
var sink ErrorSink = l
|
||||
sink.Error("test")
|
||||
sink.Warn("test")
|
||||
}
|
||||
|
||||
// --- Default Logger ---
|
||||
|
||||
func TestLog_Default_Good(t *testing.T) {
|
||||
d := Default()
|
||||
assert.NotNil(t, d)
|
||||
}
|
||||
|
||||
func TestLog_SetDefault_Good(t *testing.T) {
|
||||
original := Default()
|
||||
defer SetDefault(original)
|
||||
|
||||
custom := NewLog(LogOptions{Level: LevelDebug})
|
||||
SetDefault(custom)
|
||||
assert.Equal(t, custom, Default())
|
||||
}
|
||||
|
||||
func TestLog_PackageLevelFunctions_Good(t *testing.T) {
|
||||
// Package-level log functions use the default logger
|
||||
Debug("debug msg")
|
||||
Info("info msg")
|
||||
Warn("warn msg")
|
||||
Error("error msg")
|
||||
Security("security msg")
|
||||
}
|
||||
|
||||
func TestLog_PackageSetLevel_Good(t *testing.T) {
|
||||
original := Default()
|
||||
defer SetDefault(original)
|
||||
|
||||
SetLevel(LevelDebug)
|
||||
SetRedactKeys("secret")
|
||||
}
|
||||
|
||||
func TestLog_Username_Good(t *testing.T) {
|
||||
u := Username()
|
||||
assert.NotEmpty(t, u)
|
||||
}
|
||||
|
||||
// --- LogErr ---
|
||||
|
||||
func TestLogErr_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
le := NewLogErr(l)
|
||||
assert.NotNil(t, le)
|
||||
|
||||
err := E("test.Operation", "something broke", nil)
|
||||
le.Log(err)
|
||||
}
|
||||
|
||||
func TestLogErr_Nil_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
le := NewLogErr(l)
|
||||
le.Log(nil) // should not panic
|
||||
}
|
||||
|
||||
// --- LogPanic ---
|
||||
|
||||
func TestLogPanic_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
lp := NewLogPanic(l)
|
||||
assert.NotNil(t, lp)
|
||||
}
|
||||
|
||||
func TestLogPanic_Recover_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
lp := NewLogPanic(l)
|
||||
assert.NotPanics(t, func() {
|
||||
defer lp.Recover()
|
||||
panic("caught")
|
||||
})
|
||||
}
|
||||
|
||||
// --- SetOutput ---
|
||||
|
||||
func TestLog_SetOutput_Good(t *testing.T) {
|
||||
l := NewLog(LogOptions{Level: LevelInfo})
|
||||
l.SetOutput(os.Stderr)
|
||||
// Should not panic — just changes where logs go
|
||||
l.Info("redirected")
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
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)
|
||||
}
|
||||
94
tests/options_test.go
Normal file
94
tests/options_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Option / Options ---
|
||||
|
||||
func TestOptions_Get_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
{Key: "name", Value: "brain"},
|
||||
{Key: "port", Value: 8080},
|
||||
}
|
||||
r := opts.Get("name")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "brain", r.Value)
|
||||
}
|
||||
|
||||
func TestOptions_Get_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
r := opts.Get("missing")
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestOptions_Has_Good(t *testing.T) {
|
||||
opts := Options{{Key: "debug", Value: true}}
|
||||
assert.True(t, opts.Has("debug"))
|
||||
assert.False(t, opts.Has("missing"))
|
||||
}
|
||||
|
||||
func TestOptions_String_Good(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
assert.Equal(t, "brain", opts.String("name"))
|
||||
}
|
||||
|
||||
func TestOptions_String_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "port", Value: 8080}}
|
||||
// Wrong type — returns empty string
|
||||
assert.Equal(t, "", opts.String("port"))
|
||||
// Missing key — returns empty string
|
||||
assert.Equal(t, "", opts.String("missing"))
|
||||
}
|
||||
|
||||
func TestOptions_Int_Good(t *testing.T) {
|
||||
opts := Options{{Key: "port", Value: 8080}}
|
||||
assert.Equal(t, 8080, opts.Int("port"))
|
||||
}
|
||||
|
||||
func TestOptions_Int_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
assert.Equal(t, 0, opts.Int("name"))
|
||||
assert.Equal(t, 0, opts.Int("missing"))
|
||||
}
|
||||
|
||||
func TestOptions_Bool_Good(t *testing.T) {
|
||||
opts := Options{{Key: "debug", Value: true}}
|
||||
assert.True(t, opts.Bool("debug"))
|
||||
}
|
||||
|
||||
func TestOptions_Bool_Bad(t *testing.T) {
|
||||
opts := Options{{Key: "name", Value: "brain"}}
|
||||
assert.False(t, opts.Bool("name"))
|
||||
assert.False(t, opts.Bool("missing"))
|
||||
}
|
||||
|
||||
func TestOptions_TypedStruct_Good(t *testing.T) {
|
||||
// Packages plug typed structs into Option.Value
|
||||
type BrainConfig struct {
|
||||
Name string
|
||||
OllamaURL string
|
||||
Collection string
|
||||
}
|
||||
cfg := BrainConfig{Name: "brain", OllamaURL: "http://localhost:11434", Collection: "openbrain"}
|
||||
opts := Options{{Key: "config", Value: cfg}}
|
||||
|
||||
r := opts.Get("config")
|
||||
assert.True(t, r.OK)
|
||||
bc, ok := r.Value.(BrainConfig)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "brain", bc.Name)
|
||||
assert.Equal(t, "http://localhost:11434", bc.OllamaURL)
|
||||
}
|
||||
|
||||
func TestOptions_Empty_Good(t *testing.T) {
|
||||
opts := Options{}
|
||||
assert.False(t, opts.Has("anything"))
|
||||
assert.Equal(t, "", opts.String("anything"))
|
||||
assert.Equal(t, 0, opts.Int("anything"))
|
||||
assert.False(t, opts.Bool("anything"))
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type TestQuery struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
type TestTask struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func TestCore_QUERY_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Register a handler that responds to TestQuery
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
if tq, ok := q.(TestQuery); ok {
|
||||
return "result-" + tq.Value, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(TestQuery{Value: "test"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "result-test", result)
|
||||
}
|
||||
|
||||
func TestCore_QUERY_NotHandled(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// No handlers registered
|
||||
result, handled, err := c.QUERY(TestQuery{Value: "test"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCore_QUERY_FirstResponder(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// First handler responds
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
// Second handler would respond but shouldn't be called
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "first", result)
|
||||
}
|
||||
|
||||
func TestCore_QUERY_SkipsNonHandlers(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// First handler doesn't handle
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
// Second handler responds
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "second", result)
|
||||
}
|
||||
|
||||
func TestCore_QUERYALL_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Multiple handlers respond
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return nil, false, nil // Doesn't handle
|
||||
})
|
||||
|
||||
results, err := c.QUERYALL(TestQuery{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.Contains(t, results, "first")
|
||||
assert.Contains(t, results, "second")
|
||||
}
|
||||
|
||||
func TestCore_QUERYALL_AggregatesErrors(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
err1 := errors.New("error1")
|
||||
err2 := errors.New("error2")
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "result1", true, err1
|
||||
})
|
||||
|
||||
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||
return "result2", true, err2
|
||||
})
|
||||
|
||||
results, err := c.QUERYALL(TestQuery{})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, err1)
|
||||
assert.ErrorIs(t, err, err2)
|
||||
assert.Len(t, results, 2)
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
executed := false
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
if tt, ok := t.(TestTask); ok {
|
||||
executed = true
|
||||
return "done-" + tt.Value, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(TestTask{Value: "work"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, executed)
|
||||
assert.Equal(t, "done-work", result)
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_NotHandled(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// No handlers registered
|
||||
result, handled, err := c.PERFORM(TestTask{Value: "work"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_FirstResponder(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
callCount := 0
|
||||
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
callCount++
|
||||
return "first", true, nil
|
||||
})
|
||||
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
callCount++
|
||||
return "second", true, nil
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(TestTask{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "first", result)
|
||||
assert.Equal(t, 1, callCount) // Only first handler called
|
||||
}
|
||||
|
||||
func TestCore_PERFORM_WithError(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectedErr := errors.New("task failed")
|
||||
c.RegisterTask(func(c *Core, t Task) (any, bool, error) {
|
||||
return nil, true, expectedErr
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(TestTask{})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package core_test
|
||||
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewWithFactories_EmptyName(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"": func() (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
},
|
||||
}
|
||||
_, err := NewWithFactories(nil, factories)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "service name cannot be empty")
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewRuntime(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
app any
|
||||
factories map[string]ServiceFactory
|
||||
expectErr bool
|
||||
expectErrStr string
|
||||
checkRuntime func(*testing.T, *Runtime)
|
||||
}{
|
||||
{
|
||||
name: "Good path",
|
||||
app: nil,
|
||||
factories: map[string]ServiceFactory{},
|
||||
expectErr: false,
|
||||
checkRuntime: func(t *testing.T, rt *Runtime) {
|
||||
assert.NotNil(t, rt)
|
||||
assert.NotNil(t, rt.Core)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With non-nil app",
|
||||
app: &mockApp{},
|
||||
factories: map[string]ServiceFactory{},
|
||||
expectErr: false,
|
||||
checkRuntime: func(t *testing.T, rt *Runtime) {
|
||||
assert.NotNil(t, rt)
|
||||
assert.NotNil(t, rt.Core)
|
||||
assert.NotNil(t, rt.Core.App)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt, err := NewRuntime(tc.app)
|
||||
|
||||
if tc.expectErr {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.expectErrStr)
|
||||
assert.Nil(t, rt)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tc.checkRuntime != nil {
|
||||
tc.checkRuntime(t, rt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithFactories_Good(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"test": func() (any, error) {
|
||||
return &MockService{Name: "test"}, nil
|
||||
},
|
||||
}
|
||||
rt, err := NewWithFactories(nil, factories)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, rt)
|
||||
svc := rt.Core.Service("test")
|
||||
assert.NotNil(t, svc)
|
||||
mockSvc, ok := svc.(*MockService)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", mockSvc.Name)
|
||||
}
|
||||
|
||||
func TestNewWithFactories_Bad(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"test": func() (any, error) {
|
||||
return nil, assert.AnError
|
||||
},
|
||||
}
|
||||
_, err := NewWithFactories(nil, factories)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestNewWithFactories_Ugly(t *testing.T) {
|
||||
factories := map[string]ServiceFactory{
|
||||
"test": nil,
|
||||
}
|
||||
_, err := NewWithFactories(nil, factories)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "factory is nil")
|
||||
}
|
||||
|
||||
func TestRuntime_Lifecycle_Good(t *testing.T) {
|
||||
rt, err := NewRuntime(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, rt)
|
||||
|
||||
// ServiceName
|
||||
assert.Equal(t, "Core", rt.ServiceName())
|
||||
|
||||
// ServiceStartup & ServiceShutdown
|
||||
// These are simple wrappers around the core methods, which are tested in core_test.go.
|
||||
// We call them here to ensure coverage.
|
||||
rt.ServiceStartup(context.TODO(), nil)
|
||||
rt.ServiceShutdown(context.TODO())
|
||||
|
||||
// Test shutdown with nil core
|
||||
rt.Core = nil
|
||||
rt.ServiceShutdown(context.TODO())
|
||||
}
|
||||
|
||||
func TestNewServiceRuntime_Good(t *testing.T) {
|
||||
c, err := New()
|
||||
assert.NoError(t, err)
|
||||
|
||||
sr := NewServiceRuntime(c, "test options")
|
||||
assert.NotNil(t, sr)
|
||||
assert.Equal(t, c, sr.Core())
|
||||
|
||||
// We can't directly test sr.Cfg() without a registered config service,
|
||||
// as it will panic.
|
||||
assert.Panics(t, func() {
|
||||
sr.Config()
|
||||
})
|
||||
}
|
||||
76
tests/runtime_test.go
Normal file
76
tests/runtime_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- ServiceRuntime ---
|
||||
|
||||
type testOpts struct {
|
||||
URL string
|
||||
Timeout int
|
||||
}
|
||||
|
||||
func TestServiceRuntime_Good(t *testing.T) {
|
||||
c := New()
|
||||
opts := testOpts{URL: "https://api.lthn.ai", Timeout: 30}
|
||||
rt := NewServiceRuntime(c, opts)
|
||||
|
||||
assert.Equal(t, c, rt.Core())
|
||||
assert.Equal(t, opts, rt.Options())
|
||||
assert.Equal(t, "https://api.lthn.ai", rt.Options().URL)
|
||||
assert.NotNil(t, rt.Config())
|
||||
}
|
||||
|
||||
// --- NewWithFactories ---
|
||||
|
||||
func TestNewWithFactories_Good(t *testing.T) {
|
||||
r := NewWithFactories(nil, map[string]ServiceFactory{
|
||||
"svc1": func() Result { return Result{Value: Service{}, OK: true} },
|
||||
"svc2": func() Result { return Result{Value: Service{}, OK: true} },
|
||||
})
|
||||
assert.True(t, r.OK)
|
||||
rt := r.Value.(*Runtime)
|
||||
assert.NotNil(t, rt.Core)
|
||||
}
|
||||
|
||||
func TestNewWithFactories_NilFactory_Good(t *testing.T) {
|
||||
r := NewWithFactories(nil, map[string]ServiceFactory{
|
||||
"bad": nil,
|
||||
})
|
||||
assert.True(t, r.OK) // nil factories skipped
|
||||
}
|
||||
|
||||
func TestNewRuntime_Good(t *testing.T) {
|
||||
r := NewRuntime(nil)
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestRuntime_ServiceName_Good(t *testing.T) {
|
||||
r := NewRuntime(nil)
|
||||
rt := r.Value.(*Runtime)
|
||||
assert.Equal(t, "Core", rt.ServiceName())
|
||||
}
|
||||
|
||||
// --- Lifecycle via Runtime ---
|
||||
|
||||
func TestRuntime_Lifecycle_Good(t *testing.T) {
|
||||
started := false
|
||||
r := NewWithFactories(nil, map[string]ServiceFactory{
|
||||
"test": func() Result {
|
||||
return Result{Value: Service{
|
||||
OnStart: func() Result { started = true; return Result{OK: true} },
|
||||
}, OK: true}
|
||||
},
|
||||
})
|
||||
assert.True(t, r.OK)
|
||||
rt := r.Value.(*Runtime)
|
||||
|
||||
result := rt.ServiceStartup(context.Background(), nil)
|
||||
assert.True(t, result.OK)
|
||||
assert.True(t, started)
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
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)
|
||||
}
|
||||
79
tests/service_test.go
Normal file
79
tests/service_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Service Registration ---
|
||||
|
||||
func TestService_Register_Good(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Service("auth", Service{})
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Register_Duplicate_Bad(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("auth", Service{})
|
||||
r := c.Service("auth", Service{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Register_Empty_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Service("", Service{})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Get_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("brain", Service{OnStart: func() Result { return Result{OK: true} }})
|
||||
r := c.Service("brain")
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestService_Get_Bad(t *testing.T) {
|
||||
c := New()
|
||||
r := c.Service("nonexistent")
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestService_Names_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.Service("a", Service{})
|
||||
c.Service("b", Service{})
|
||||
names := c.Services()
|
||||
assert.Len(t, names, 2)
|
||||
assert.Contains(t, names, "a")
|
||||
assert.Contains(t, names, "b")
|
||||
}
|
||||
|
||||
// --- Service Lifecycle ---
|
||||
|
||||
func TestService_Lifecycle_Good(t *testing.T) {
|
||||
c := New()
|
||||
started := false
|
||||
stopped := false
|
||||
c.Service("lifecycle", Service{
|
||||
OnStart: func() Result { started = true; return Result{OK: true} },
|
||||
OnStop: func() Result { stopped = true; return Result{OK: true} },
|
||||
})
|
||||
|
||||
sr := c.Startables()
|
||||
assert.True(t, sr.OK)
|
||||
startables := sr.Value.([]*Service)
|
||||
assert.Len(t, startables, 1)
|
||||
startables[0].OnStart()
|
||||
assert.True(t, started)
|
||||
|
||||
tr := c.Stoppables()
|
||||
assert.True(t, tr.OK)
|
||||
stoppables := tr.Value.([]*Service)
|
||||
assert.Len(t, stoppables, 1)
|
||||
stoppables[0].OnStop()
|
||||
assert.True(t, stopped)
|
||||
}
|
||||
70
tests/string_test.go
Normal file
70
tests/string_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- String Operations ---
|
||||
|
||||
func TestHasPrefix_Good(t *testing.T) {
|
||||
assert.True(t, HasPrefix("--verbose", "--"))
|
||||
assert.True(t, HasPrefix("-v", "-"))
|
||||
assert.False(t, HasPrefix("hello", "-"))
|
||||
}
|
||||
|
||||
func TestHasSuffix_Good(t *testing.T) {
|
||||
assert.True(t, HasSuffix("test.go", ".go"))
|
||||
assert.False(t, HasSuffix("test.go", ".py"))
|
||||
}
|
||||
|
||||
func TestTrimPrefix_Good(t *testing.T) {
|
||||
assert.Equal(t, "verbose", TrimPrefix("--verbose", "--"))
|
||||
assert.Equal(t, "hello", TrimPrefix("hello", "--"))
|
||||
}
|
||||
|
||||
func TestTrimSuffix_Good(t *testing.T) {
|
||||
assert.Equal(t, "test", TrimSuffix("test.go", ".go"))
|
||||
assert.Equal(t, "test.go", TrimSuffix("test.go", ".py"))
|
||||
}
|
||||
|
||||
func TestContains_Good(t *testing.T) {
|
||||
assert.True(t, Contains("hello world", "world"))
|
||||
assert.False(t, Contains("hello world", "mars"))
|
||||
}
|
||||
|
||||
func TestSplit_Good(t *testing.T) {
|
||||
assert.Equal(t, []string{"a", "b", "c"}, Split("a/b/c", "/"))
|
||||
}
|
||||
|
||||
func TestSplitN_Good(t *testing.T) {
|
||||
assert.Equal(t, []string{"key", "value=extra"}, SplitN("key=value=extra", "=", 2))
|
||||
}
|
||||
|
||||
func TestJoin_Good(t *testing.T) {
|
||||
assert.Equal(t, "a/b/c", Join("/", "a", "b", "c"))
|
||||
}
|
||||
|
||||
func TestReplace_Good(t *testing.T) {
|
||||
assert.Equal(t, "deploy.to.homelab", Replace("deploy/to/homelab", "/", "."))
|
||||
}
|
||||
|
||||
func TestLower_Good(t *testing.T) {
|
||||
assert.Equal(t, "hello", Lower("HELLO"))
|
||||
}
|
||||
|
||||
func TestUpper_Good(t *testing.T) {
|
||||
assert.Equal(t, "HELLO", Upper("hello"))
|
||||
}
|
||||
|
||||
func TestTrim_Good(t *testing.T) {
|
||||
assert.Equal(t, "hello", Trim(" hello "))
|
||||
}
|
||||
|
||||
func TestRuneCount_Good(t *testing.T) {
|
||||
assert.Equal(t, 5, RuneCount("hello"))
|
||||
assert.Equal(t, 1, RuneCount("🔥"))
|
||||
assert.Equal(t, 0, RuneCount(""))
|
||||
}
|
||||
69
tests/task_test.go
Normal file
69
tests/task_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- PerformAsync ---
|
||||
|
||||
func TestPerformAsync_Good(t *testing.T) {
|
||||
c := New()
|
||||
var mu sync.Mutex
|
||||
var result string
|
||||
|
||||
c.RegisterTask(func(_ *Core, task Task) Result {
|
||||
mu.Lock()
|
||||
result = "done"
|
||||
mu.Unlock()
|
||||
return Result{"completed", true}
|
||||
})
|
||||
|
||||
r := c.PerformAsync("work")
|
||||
assert.True(t, r.OK)
|
||||
taskID := r.Value.(string)
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
assert.Equal(t, "done", result)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestPerformAsync_Progress_Good(t *testing.T) {
|
||||
c := New()
|
||||
c.RegisterTask(func(_ *Core, task Task) Result {
|
||||
return Result{OK: true}
|
||||
})
|
||||
|
||||
r := c.PerformAsync("work")
|
||||
taskID := r.Value.(string)
|
||||
c.Progress(taskID, 0.5, "halfway", "work")
|
||||
}
|
||||
|
||||
// --- RegisterAction + RegisterActions ---
|
||||
|
||||
func TestRegisterAction_Good(t *testing.T) {
|
||||
c := New()
|
||||
called := false
|
||||
c.RegisterAction(func(_ *Core, _ Message) Result {
|
||||
called = true
|
||||
return Result{OK: true}
|
||||
})
|
||||
c.Action(nil)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestRegisterActions_Good(t *testing.T) {
|
||||
c := New()
|
||||
count := 0
|
||||
h := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
|
||||
c.RegisterActions(h, h)
|
||||
c.Action(nil)
|
||||
assert.Equal(t, 2, count)
|
||||
}
|
||||
1339
tests/testdata/cli_clir.go.bak
vendored
Normal file
1339
tests/testdata/cli_clir.go.bak
vendored
Normal file
File diff suppressed because it is too large
Load diff
7
tests/testdata/scantest/sample.go
vendored
Normal file
7
tests/testdata/scantest/sample.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package scantest
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
func example() {
|
||||
_, _ = core.GetAsset("mygroup", "myfile.txt")
|
||||
}
|
||||
217
tests/utils_test.go
Normal file
217
tests/utils_test.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
. "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- FilterArgs ---
|
||||
|
||||
func TestFilterArgs_Good(t *testing.T) {
|
||||
args := []string{"deploy", "", "to", "-test.v", "homelab", "-test.paniconexit0"}
|
||||
clean := FilterArgs(args)
|
||||
assert.Equal(t, []string{"deploy", "to", "homelab"}, clean)
|
||||
}
|
||||
|
||||
func TestFilterArgs_Empty_Good(t *testing.T) {
|
||||
clean := FilterArgs(nil)
|
||||
assert.Nil(t, clean)
|
||||
}
|
||||
|
||||
// --- ParseFlag ---
|
||||
|
||||
func TestParseFlag_ShortValid_Good(t *testing.T) {
|
||||
// Single letter
|
||||
k, v, ok := ParseFlag("-v")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "v", k)
|
||||
assert.Equal(t, "", v)
|
||||
|
||||
// Single emoji
|
||||
k, v, ok = ParseFlag("-🔥")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "🔥", k)
|
||||
assert.Equal(t, "", v)
|
||||
|
||||
// Short with value
|
||||
k, v, ok = ParseFlag("-p=8080")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "p", k)
|
||||
assert.Equal(t, "8080", v)
|
||||
}
|
||||
|
||||
func TestParseFlag_ShortInvalid_Bad(t *testing.T) {
|
||||
// Multiple chars with single dash — invalid
|
||||
_, _, ok := ParseFlag("-verbose")
|
||||
assert.False(t, ok)
|
||||
|
||||
_, _, ok = ParseFlag("-port")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseFlag_LongValid_Good(t *testing.T) {
|
||||
k, v, ok := ParseFlag("--verbose")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "verbose", k)
|
||||
assert.Equal(t, "", v)
|
||||
|
||||
k, v, ok = ParseFlag("--port=8080")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "port", k)
|
||||
assert.Equal(t, "8080", v)
|
||||
}
|
||||
|
||||
func TestParseFlag_LongInvalid_Bad(t *testing.T) {
|
||||
// Single char with double dash — invalid
|
||||
_, _, ok := ParseFlag("--v")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseFlag_NotAFlag_Bad(t *testing.T) {
|
||||
_, _, ok := ParseFlag("hello")
|
||||
assert.False(t, ok)
|
||||
|
||||
_, _, ok = ParseFlag("")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// --- IsFlag ---
|
||||
|
||||
func TestIsFlag_Good(t *testing.T) {
|
||||
assert.True(t, IsFlag("-v"))
|
||||
assert.True(t, IsFlag("--verbose"))
|
||||
assert.True(t, IsFlag("-"))
|
||||
}
|
||||
|
||||
func TestIsFlag_Bad(t *testing.T) {
|
||||
assert.False(t, IsFlag("hello"))
|
||||
assert.False(t, IsFlag(""))
|
||||
}
|
||||
|
||||
// --- Arg ---
|
||||
|
||||
func TestArg_String_Good(t *testing.T) {
|
||||
r := Arg(0, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello", r.Value)
|
||||
}
|
||||
|
||||
func TestArg_Int_Good(t *testing.T) {
|
||||
r := Arg(1, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 42, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_Bool_Good(t *testing.T) {
|
||||
r := Arg(2, "hello", 42, true)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, true, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_UnsupportedType_Good(t *testing.T) {
|
||||
r := Arg(0, 3.14)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, 3.14, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_OutOfBounds_Bad(t *testing.T) {
|
||||
r := Arg(5, "only", "two")
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_NoArgs_Bad(t *testing.T) {
|
||||
r := Arg(0)
|
||||
assert.False(t, r.OK)
|
||||
assert.Nil(t, r.Value)
|
||||
}
|
||||
|
||||
func TestArg_ErrorDetection_Good(t *testing.T) {
|
||||
err := errors.New("fail")
|
||||
r := Arg(0, err)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, err, r.Value)
|
||||
}
|
||||
|
||||
// --- ArgString ---
|
||||
|
||||
func TestArgString_Good(t *testing.T) {
|
||||
assert.Equal(t, "hello", ArgString(0, "hello", 42))
|
||||
assert.Equal(t, "world", ArgString(1, "hello", "world"))
|
||||
}
|
||||
|
||||
func TestArgString_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, "", ArgString(0, 42))
|
||||
}
|
||||
|
||||
func TestArgString_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, "", ArgString(3, "only"))
|
||||
}
|
||||
|
||||
// --- ArgInt ---
|
||||
|
||||
func TestArgInt_Good(t *testing.T) {
|
||||
assert.Equal(t, 42, ArgInt(0, 42, "hello"))
|
||||
assert.Equal(t, 99, ArgInt(1, 0, 99))
|
||||
}
|
||||
|
||||
func TestArgInt_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, 0, ArgInt(0, "not an int"))
|
||||
}
|
||||
|
||||
func TestArgInt_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, 0, ArgInt(5, 1, 2))
|
||||
}
|
||||
|
||||
// --- ArgBool ---
|
||||
|
||||
func TestArgBool_Good(t *testing.T) {
|
||||
assert.Equal(t, true, ArgBool(0, true, "hello"))
|
||||
assert.Equal(t, false, ArgBool(1, true, false))
|
||||
}
|
||||
|
||||
func TestArgBool_WrongType_Bad(t *testing.T) {
|
||||
assert.Equal(t, false, ArgBool(0, "not a bool"))
|
||||
}
|
||||
|
||||
func TestArgBool_OutOfBounds_Bad(t *testing.T) {
|
||||
assert.Equal(t, false, ArgBool(5, true))
|
||||
}
|
||||
|
||||
// --- Result.Result() ---
|
||||
|
||||
func TestResult_Result_SingleArg_Good(t *testing.T) {
|
||||
r := Result{}.Result("value")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_NilError_Good(t *testing.T) {
|
||||
r := Result{}.Result("value", nil)
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "value", r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_WithError_Bad(t *testing.T) {
|
||||
err := errors.New("fail")
|
||||
r := Result{}.Result("value", err)
|
||||
assert.False(t, r.OK)
|
||||
assert.Equal(t, err, r.Value)
|
||||
}
|
||||
|
||||
func TestResult_Result_ZeroArgs_Good(t *testing.T) {
|
||||
r := Result{"hello", true}
|
||||
got := r.Result()
|
||||
assert.Equal(t, "hello", got.Value)
|
||||
assert.True(t, got.OK)
|
||||
}
|
||||
|
||||
func TestResult_Result_ZeroArgs_Empty_Good(t *testing.T) {
|
||||
r := Result{}
|
||||
got := r.Result()
|
||||
assert.Nil(t, got.Value)
|
||||
assert.False(t, got.OK)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue