diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9f474d7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..fe40be8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "core": { + "type": "stdio", + "command": "core-agent", + "args": ["mcp"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 09471af..4c12f27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Session Context + +Running on **Claude Max20 plan** with **1M context window** (Opus 4.6). This enables marathon sessions — use the full context for complex multi-repo work, dispatch coordination, and ecosystem-wide operations. Compact when needed, but don't be afraid of long sessions. + ## Project Overview Core (`forge.lthn.ai/core/go`) is a **dependency injection and service lifecycle framework** for Go. It provides a typed service registry, lifecycle hooks, and a message-passing bus for decoupled communication between services. @@ -68,7 +72,7 @@ core.New(core.WithService(NewMyService)) - `WithService`: Auto-discovers service name from package path, registers IPC handler if service has `HandleIPCEvents` method - `WithName`: Explicitly names a service -### ServiceRuntime Generic Helper (`runtime.go`) +### ServiceRuntime Generic Helper (`runtime_pkg.go`) Embed `ServiceRuntime[T]` in services to get access to Core and typed options: ```go @@ -77,11 +81,12 @@ type MyService struct { } ``` -### Error Handling (`e.go`) +### Error Handling (go-log) -Use the `E()` helper for contextual errors: +All errors MUST use `E()` from `go-log` (re-exported in `e.go`), never `fmt.Errorf`: ```go return core.E("service.Method", "what failed", underlyingErr) +return core.E("service.Method", fmt.Sprintf("service %q not found", name), nil) ``` ### Test Naming Convention @@ -100,6 +105,6 @@ Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern: ## Go Workspace -Uses Go 1.25 workspaces. This module is part of the workspace at `~/Code/go.work`. +Uses Go 1.26 workspaces. This module is part of the workspace at `~/Code/go.work`. After adding modules: `go work sync` diff --git a/go.mod b/go.mod index e7d2a4e..e6140ad 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,13 @@ module forge.lthn.ai/core/go go 1.26.0 -require ( - forge.lthn.ai/core/go-io v0.0.5 - forge.lthn.ai/core/go-log v0.0.1 - github.com/stretchr/testify v1.11.1 -) +require github.com/stretchr/testify v1.11.1 require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 57f3230..5a10c39 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,17 @@ -forge.lthn.ai/core/go-io v0.0.5 h1:oSyngKTkB1gR5fEWYKXftTg9FxwnpddSiCq2dlwfImE= -forge.lthn.ai/core/go-io v0.0.5/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= -forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= -forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/pkg/core/app.go b/pkg/core/app.go new file mode 100644 index 0000000..18e976d --- /dev/null +++ b/pkg/core/app.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Application identity for the Core framework. +// Based on leaanthony/sail — Name, Filename, Path. + +package core + +import ( + "os/exec" + "path/filepath" +) + +// App holds the application identity and optional GUI runtime. +type App struct { + // Name is the human-readable application name (e.g., "Core CLI"). + Name string + + // Version is the application version string (e.g., "1.2.3"). + Version string + + // Description is a short description of the application. + Description string + + // Filename is the executable filename (e.g., "core"). + Filename string + + // Path is the absolute path to the executable. + Path string + + // Runtime is the GUI runtime (e.g., Wails App). + // Nil for CLI-only applications. + Runtime any +} + + +// Find locates a program on PATH and returns a App for it. +// Returns nil if not found. +func Find(filename, name string) *App { + path, err := exec.LookPath(filename) + if err != nil { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return nil + } + return &App{ + Name: name, + Filename: filename, + Path: abs, + } +} diff --git a/pkg/core/array.go b/pkg/core/array.go new file mode 100644 index 0000000..887bee9 --- /dev/null +++ b/pkg/core/array.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Generic slice operations for the Core framework. +// Based on leaanthony/slicer, rewritten with Go 1.18+ generics. + +package core + +// Array is a typed slice with common operations. +type Array[T comparable] struct { + items []T +} + +// NewArray creates an empty Array. +func NewArray[T comparable](items ...T) *Array[T] { + return &Array[T]{items: items} +} + +// Add appends values. +func (s *Array[T]) Add(values ...T) { + s.items = append(s.items, values...) +} + +// AddUnique appends values only if not already present. +func (s *Array[T]) AddUnique(values ...T) { + for _, v := range values { + if !s.Contains(v) { + s.items = append(s.items, v) + } + } +} + +// Contains returns true if the value is in the slice. +func (s *Array[T]) Contains(val T) bool { + for _, v := range s.items { + if v == val { + return true + } + } + return false +} + +// Filter returns a new Array with elements matching the predicate. +func (s *Array[T]) Filter(fn func(T) bool) *Array[T] { + result := &Array[T]{} + for _, v := range s.items { + if fn(v) { + result.items = append(result.items, v) + } + } + return result +} + +// Each runs a function on every element. +func (s *Array[T]) Each(fn func(T)) { + for _, v := range s.items { + fn(v) + } +} + +// Remove removes the first occurrence of a value. +func (s *Array[T]) Remove(val T) { + for i, v := range s.items { + if v == val { + s.items = append(s.items[:i], s.items[i+1:]...) + return + } + } +} + +// Deduplicate removes duplicate values, preserving order. +func (s *Array[T]) Deduplicate() { + seen := make(map[T]struct{}) + result := make([]T, 0, len(s.items)) + for _, v := range s.items { + if _, exists := seen[v]; !exists { + seen[v] = struct{}{} + result = append(result, v) + } + } + s.items = result +} + +// Len returns the number of elements. +func (s *Array[T]) Len() int { + return len(s.items) +} + +// Clear removes all elements. +func (s *Array[T]) Clear() { + s.items = nil +} + +// AsSlice returns the underlying slice. +func (s *Array[T]) AsSlice() []T { + return s.items +} diff --git a/pkg/core/cli.go b/pkg/core/cli.go new file mode 100644 index 0000000..03fb869 --- /dev/null +++ b/pkg/core/cli.go @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// CLI command framework for the Core framework. +// Based on leaanthony/clir — zero-dependency command line interface. + +package core + +import ( + "fmt" + "os" +) + +// CliAction represents a function called when a command is invoked. +type CliAction func() error + +// CliOpts configures a Cli. +type CliOpts struct { + Version string + Name string + Description string +} + +// Cli is the CLI command framework. +type Cli struct { + opts *CliOpts + rootCommand *Command + defaultCommand *Command + preRunCommand func(*Cli) error + postRunCommand func(*Cli) error + bannerFunction func(*Cli) string + errorHandler func(string, error) error +} + +// defaultBannerFunction prints a banner for the application. +func defaultBannerFunction(c *Cli) string { + version := "" + if c.opts != nil && c.opts.Version != "" { + version = " " + c.opts.Version + } + name := "" + description := "" + if c.opts != nil { + name = c.opts.Name + description = c.opts.Description + } + if description != "" { + return fmt.Sprintf("%s%s - %s", name, version, description) + } + return fmt.Sprintf("%s%s", name, version) +} + + +// Command returns the root command. +func (c *Cli) Command() *Command { + return c.rootCommand +} + +// Version returns the application version string. +func (c *Cli) Version() string { + if c.opts != nil { + return c.opts.Version + } + return "" +} + +// Name returns the application name. +func (c *Cli) Name() string { + if c.opts != nil { + return c.opts.Name + } + return c.rootCommand.name +} + +// ShortDescription returns the application short description. +func (c *Cli) ShortDescription() string { + if c.opts != nil { + return c.opts.Description + } + return c.rootCommand.shortdescription +} + +// SetBannerFunction sets the function that generates the banner string. +func (c *Cli) SetBannerFunction(fn func(*Cli) string) { + c.bannerFunction = fn +} + +// SetErrorFunction sets a custom error handler for undefined flags. +func (c *Cli) SetErrorFunction(fn func(string, error) error) { + c.errorHandler = fn +} + +// AddCommand adds a command to the application. +func (c *Cli) AddCommand(command *Command) { + c.rootCommand.AddCommand(command) +} + +// PrintBanner prints the application banner. +func (c *Cli) PrintBanner() { + fmt.Println(c.bannerFunction(c)) + fmt.Println("") +} + +// PrintHelp prints the application help. +func (c *Cli) PrintHelp() { + c.rootCommand.PrintHelp() +} + +// Run runs the application with the given arguments. +func (c *Cli) Run(args ...string) error { + if c.preRunCommand != nil { + if err := c.preRunCommand(c); err != nil { + return err + } + } + if len(args) == 0 { + args = os.Args[1:] + } + if err := c.rootCommand.run(args); err != nil { + return err + } + if c.postRunCommand != nil { + if err := c.postRunCommand(c); err != nil { + return err + } + } + return nil +} + +// DefaultCommand sets the command to run when no other commands are given. +func (c *Cli) DefaultCommand(defaultCommand *Command) *Cli { + c.defaultCommand = defaultCommand + return c +} + +// NewChildCommand creates a new subcommand. +func (c *Cli) NewChildCommand(name string, description ...string) *Command { + return c.rootCommand.NewChildCommand(name, description...) +} + +// NewChildCommandInheritFlags creates a new subcommand that inherits parent flags. +func (c *Cli) NewChildCommandInheritFlags(name string, description ...string) *Command { + return c.rootCommand.NewChildCommandInheritFlags(name, description...) +} + +// PreRun sets a function to call before running the command. +func (c *Cli) PreRun(callback func(*Cli) error) { + c.preRunCommand = callback +} + +// PostRun sets a function to call after running the command. +func (c *Cli) PostRun(callback func(*Cli) error) { + c.postRunCommand = callback +} + +// BoolFlag adds a boolean flag to the root command. +func (c *Cli) BoolFlag(name, description string, variable *bool) *Cli { + c.rootCommand.BoolFlag(name, description, variable) + return c +} + +// StringFlag adds a string flag to the root command. +func (c *Cli) StringFlag(name, description string, variable *string) *Cli { + c.rootCommand.StringFlag(name, description, variable) + return c +} + +// IntFlag adds an int flag to the root command. +func (c *Cli) IntFlag(name, description string, variable *int) *Cli { + c.rootCommand.IntFlag(name, description, variable) + return c +} + +// AddFlags adds struct-tagged flags to the root command. +func (c *Cli) AddFlags(flags any) *Cli { + c.rootCommand.AddFlags(flags) + return c +} + +// Action defines an action for the root command. +func (c *Cli) Action(callback CliAction) *Cli { + c.rootCommand.Action(callback) + return c +} + +// LongDescription sets the long description for the root command. +func (c *Cli) LongDescription(longdescription string) *Cli { + c.rootCommand.LongDescription(longdescription) + return c +} + +// OtherArgs returns the non-flag arguments passed to the CLI. +func (c *Cli) OtherArgs() []string { + return c.rootCommand.flags.Args() +} + +// NewChildCommandFunction creates a subcommand from a function with struct flags. +func (c *Cli) NewChildCommandFunction(name string, description string, fn any) *Cli { + c.rootCommand.NewChildCommandFunction(name, description, fn) + return c +} diff --git a/pkg/core/command.go b/pkg/core/command.go new file mode 100644 index 0000000..5c1d77c --- /dev/null +++ b/pkg/core/command.go @@ -0,0 +1,1336 @@ +package core + +import ( + "flag" + "fmt" + "io" + "os" + "reflect" + "strconv" + "strings" +) + +// Command represents a command that may be run by the user +type Command struct { + name string + commandPath string + shortdescription string + longdescription string + subCommands []*Command + subCommandsMap map[string]*Command + longestSubcommand int + actionCallback CliAction + app *Cli + flags *flag.FlagSet + flagCount int + helpFlag bool + hidden bool + positionalArgsMap map[string]reflect.Value + sliceSeparator map[string]string +} + +// NewCommand creates a new Command. +// Description is optional — if omitted, i18n resolves it from the command path. +func NewCommand(name string, description ...string) *Command { + desc := "" + if len(description) > 0 { + desc = description[0] + } + result := &Command{ + name: name, + shortdescription: desc, + subCommandsMap: make(map[string]*Command), + hidden: false, + positionalArgsMap: make(map[string]reflect.Value), + sliceSeparator: make(map[string]string), + } + + return result +} + +func (c *Command) setParentCommandPath(parentCommandPath string) { + // Set up command path + if parentCommandPath != "" { + c.commandPath += parentCommandPath + " " + } + c.commandPath += c.name + + // Set up flag set + c.flags = flag.NewFlagSet(c.commandPath, flag.ContinueOnError) + c.BoolFlag("help", "Get help on the '"+strings.ToLower(c.commandPath)+"' command.", &c.helpFlag) + + // result.Flags.Usage = result.PrintHelp + +} + +func (c *Command) inheritFlags(inheritFlags *flag.FlagSet) { + // inherit flags + inheritFlags.VisitAll(func(f *flag.Flag) { + if f.Name != "help" { + c.flags.Var(f.Value, f.Name, f.Usage) + } + }) +} + +func (c *Command) setApp(app *Cli) { + c.app = app +} + +// parseFlags parses the given flags +func (c *Command) parseFlags(args []string) error { + // Parse flags + // Suppress flag parse errors to stderr + + c.flags.SetOutput(io.Discard) + + // Credit: https://stackoverflow.com/a/74146375 + var positionalArgs []string + for { + if err := c.flags.Parse(args); err != nil { + return err + } + // Consume all the flags that were parsed as flags. + args = args[len(args)-c.flags.NArg():] + if len(args) == 0 { + break + } + // There's at least one flag remaining and it must be a positional arg since + // we consumed all args that were parsed as flags. Consume just the first + // one, and retry parsing, since subsequent args may be flags. + positionalArgs = append(positionalArgs, args[0]) + args = args[1:] + } + + // Parse just the positional args so that flagset.Args()/flagset.NArgs() + // return the expected value. + // Note: This should never return an error. + err := c.flags.Parse(positionalArgs) + if err != nil { + return err + } + + if len(positionalArgs) > 0 { + return c.parsePositionalArgs(positionalArgs) + } + return nil +} + +// Run - Runs the Command with the given arguments +func (c *Command) run(args []string) error { + + // If we have arguments, process them + if len(args) > 0 { + // Check for subcommand + subcommand := c.subCommandsMap[args[0]] + if subcommand != nil { + return subcommand.run(args[1:]) + } + + // Parse flags + err := c.parseFlags(args) + if err != nil { + if c.app.errorHandler != nil { + return c.app.errorHandler(c.commandPath, err) + } + return E("cli.Run", fmt.Sprintf("see '%s --help' for usage", c.commandPath), err) + } + + // Help takes precedence + if c.helpFlag { + c.PrintHelp() + return nil + } + } + + // Do we have an action? + if c.actionCallback != nil { + return c.actionCallback() + } + + // If we haven't specified a subcommand + // check for an app level default command + if c.app.defaultCommand != nil { + // Prevent recursion! + if c.app.defaultCommand != c { + // only run default command if no args passed + if len(args) == 0 { + return c.app.defaultCommand.run(args) + } + } + } + + // Nothing left we can do + c.PrintHelp() + + return nil +} + +// Action - Define an action from this command +func (c *Command) Action(callback CliAction) *Command { + c.actionCallback = callback + return c +} + +// PrintHelp - Output the help text for this command +func (c *Command) PrintHelp() { + c.app.PrintBanner() + + commandTitle := c.commandPath + if c.shortdescription != "" { + commandTitle += " - " + c.shortdescription + } + // Ignore root command + if c.commandPath != c.name { + fmt.Println(commandTitle) + } + if c.longdescription != "" { + fmt.Println(c.longdescription + "\n") + } + if len(c.subCommands) > 0 { + fmt.Println("Available commands:") + fmt.Println("") + for _, subcommand := range c.subCommands { + if subcommand.isHidden() { + continue + } + spacer := strings.Repeat(" ", 3+c.longestSubcommand-len(subcommand.name)) + isDefault := "" + if subcommand.isDefaultCommand() { + isDefault = "[default]" + } + fmt.Printf(" %s%s%s %s\n", subcommand.name, spacer, subcommand.shortdescription, isDefault) + } + fmt.Println("") + } + if c.flagCount > 0 { + fmt.Println("Flags:") + fmt.Println() + c.flags.SetOutput(os.Stdout) + c.flags.PrintDefaults() + c.flags.SetOutput(os.Stderr) + + } + fmt.Println() +} + +// isDefaultCommand returns true if called on the default command +func (c *Command) isDefaultCommand() bool { + return c.app.defaultCommand == c +} + +// isHidden returns true if the command is a hidden command +func (c *Command) isHidden() bool { + return c.hidden +} + +// Hidden hides the command from the Help system +func (c *Command) Hidden() { + c.hidden = true +} + +// NewChildCommand - Creates a new subcommand +func (c *Command) NewChildCommand(name string, description ...string) *Command { + result := NewCommand(name, description...) + c.AddCommand(result) + return result +} + +// AddCommand - Adds a subcommand +func (c *Command) AddCommand(command *Command) { + command.setApp(c.app) + command.setParentCommandPath(c.commandPath) + name := command.name + c.subCommands = append(c.subCommands, command) + c.subCommandsMap[name] = command + if len(name) > c.longestSubcommand { + c.longestSubcommand = len(name) + } +} + +// NewChildCommandInheritFlags - Creates a new subcommand, inherits flags from command +func (c *Command) NewChildCommandInheritFlags(name string, description ...string) *Command { + result := c.NewChildCommand(name, description...) + result.inheritFlags(c.flags) + return result +} + +func (c *Command) AddFlags(optionStruct any) *Command { + // use reflection to determine if this is a pointer to a struct + // if not, panic + + t := reflect.TypeOf(optionStruct) + + // Check for a pointer to a struct + if t.Kind() != reflect.Ptr { + panic("AddFlags() requires a pointer to a struct") + } + if t.Elem().Kind() != reflect.Struct { + panic("AddFlags() requires a pointer to a struct") + } + + // Iterate through the fields of the struct reading the struct tags + // and adding the flags + v := reflect.ValueOf(optionStruct).Elem() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Elem().Field(i) + if !fieldType.IsExported() { + continue + } + // If this is an embedded struct, recurse + if fieldType.Type.Kind() == reflect.Struct { + c.AddFlags(field.Addr().Interface()) + continue + } + + tag := t.Elem().Field(i).Tag + name := tag.Get("name") + description := tag.Get("description") + defaultValue := tag.Get("default") + pos := tag.Get("pos") + sep := tag.Get("sep") + c.positionalArgsMap[pos] = field + if sep != "" { + c.sliceSeparator[pos] = sep + } + if name == "" { + name = strings.ToLower(t.Elem().Field(i).Name) + } + switch field.Kind() { + case reflect.Bool: + var defaultValueBool bool + if defaultValue != "" { + var err error + defaultValueBool, err = strconv.ParseBool(defaultValue) + if err != nil { + panic("Invalid default value for bool flag") + } + } + field.SetBool(defaultValueBool) + c.BoolFlag(name, description, field.Addr().Interface().(*bool)) + case reflect.String: + if defaultValue != "" { + // set value of field to default value + field.SetString(defaultValue) + } + c.StringFlag(name, description, field.Addr().Interface().(*string)) + case reflect.Int: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int flag") + } + field.SetInt(int64(value)) + } + c.IntFlag(name, description, field.Addr().Interface().(*int)) + case reflect.Int8: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int8 flag") + } + field.SetInt(int64(value)) + } + c.Int8Flag(name, description, field.Addr().Interface().(*int8)) + case reflect.Int16: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int16 flag") + } + field.SetInt(int64(value)) + } + c.Int16Flag(name, description, field.Addr().Interface().(*int16)) + case reflect.Int32: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int32 flag") + } + field.SetInt(int64(value)) + } + c.Int32Flag(name, description, field.Addr().Interface().(*int32)) + case reflect.Int64: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for int64 flag") + } + field.SetInt(int64(value)) + } + c.Int64Flag(name, description, field.Addr().Interface().(*int64)) + case reflect.Uint: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint flag") + } + field.SetUint(uint64(value)) + } + c.UintFlag(name, description, field.Addr().Interface().(*uint)) + case reflect.Uint8: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint8 flag") + } + field.SetUint(uint64(value)) + } + c.Uint8Flag(name, description, field.Addr().Interface().(*uint8)) + case reflect.Uint16: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint16 flag") + } + field.SetUint(uint64(value)) + } + c.Uint16Flag(name, description, field.Addr().Interface().(*uint16)) + case reflect.Uint32: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint32 flag") + } + field.SetUint(uint64(value)) + } + c.Uint32Flag(name, description, field.Addr().Interface().(*uint32)) + case reflect.Uint64: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.Atoi(defaultValue) + if err != nil { + panic("Invalid default value for uint64 flag") + } + field.SetUint(uint64(value)) + } + c.UInt64Flag(name, description, field.Addr().Interface().(*uint64)) + case reflect.Float32: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.ParseFloat(defaultValue, 64) + if err != nil { + panic("Invalid default value for float32 flag") + } + field.SetFloat(value) + } + c.Float32Flag(name, description, field.Addr().Interface().(*float32)) + case reflect.Float64: + if defaultValue != "" { + // set value of field to default value + value, err := strconv.ParseFloat(defaultValue, 64) + if err != nil { + panic("Invalid default value for float64 flag") + } + field.SetFloat(value) + } + c.Float64Flag(name, description, field.Addr().Interface().(*float64)) + case reflect.Slice: + c.addSliceField(field, defaultValue, sep) + c.addSliceFlags(name, description, field) + default: + if pos != "" { + fmt.Fprintf(os.Stderr, "WARNING: unsupported type for flag: %s %s\n", fieldType.Type.Kind(), name) + } + } + } + + return c +} + +func (c *Command) addSliceFlags(name, description string, field reflect.Value) *Command { + if field.Kind() != reflect.Slice { + panic("addSliceFlags() requires a pointer to a slice") + } + t := reflect.TypeOf(field.Addr().Interface()) + if t.Kind() != reflect.Ptr { + panic("addSliceFlags() requires a pointer to a slice") + } + if t.Elem().Kind() != reflect.Slice { + panic("addSliceFlags() requires a pointer to a slice") + } + switch t.Elem().Elem().Kind() { + case reflect.Bool: + c.BoolsFlag(name, description, field.Addr().Interface().(*[]bool)) + case reflect.String: + c.StringsFlag(name, description, field.Addr().Interface().(*[]string)) + case reflect.Int: + c.IntsFlag(name, description, field.Addr().Interface().(*[]int)) + case reflect.Int8: + c.Int8sFlag(name, description, field.Addr().Interface().(*[]int8)) + case reflect.Int16: + c.Int16sFlag(name, description, field.Addr().Interface().(*[]int16)) + case reflect.Int32: + c.Int32sFlag(name, description, field.Addr().Interface().(*[]int32)) + case reflect.Int64: + c.Int64sFlag(name, description, field.Addr().Interface().(*[]int64)) + case reflect.Uint: + c.UintsFlag(name, description, field.Addr().Interface().(*[]uint)) + case reflect.Uint8: + c.Uint8sFlag(name, description, field.Addr().Interface().(*[]uint8)) + case reflect.Uint16: + c.Uint16sFlag(name, description, field.Addr().Interface().(*[]uint16)) + case reflect.Uint32: + c.Uint32sFlag(name, description, field.Addr().Interface().(*[]uint32)) + case reflect.Uint64: + c.Uint64sFlag(name, description, field.Addr().Interface().(*[]uint64)) + case reflect.Float32: + c.Float32sFlag(name, description, field.Addr().Interface().(*[]float32)) + case reflect.Float64: + c.Float64sFlag(name, description, field.Addr().Interface().(*[]float64)) + default: + panic(fmt.Sprintf("addSliceFlags() not supported slice type %s", t.Elem().Elem().Kind().String())) + } + return c +} + +func (c *Command) addSliceField(field reflect.Value, defaultValue, separator string) *Command { + if defaultValue == "" { + return c + } + if field.Kind() != reflect.Slice { + panic("addSliceField() requires a pointer to a slice") + } + t := reflect.TypeOf(field.Addr().Interface()) + if t.Kind() != reflect.Ptr { + panic("addSliceField() requires a pointer to a slice") + } + if t.Elem().Kind() != reflect.Slice { + panic("addSliceField() requires a pointer to a slice") + } + defaultSlice := []string{defaultValue} + if separator != "" { + defaultSlice = strings.Split(defaultValue, separator) + } + switch t.Elem().Elem().Kind() { + case reflect.Bool: + defaultValues := make([]bool, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseBool(value) + if err != nil { + panic("Invalid default value for bool flag") + } + defaultValues = append(defaultValues, val) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.String: + field.Set(reflect.ValueOf(defaultSlice)) + case reflect.Int: + defaultValues := make([]int, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for int flag") + } + defaultValues = append(defaultValues, val) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int8: + defaultValues := make([]int8, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for int8 flag") + } + defaultValues = append(defaultValues, int8(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int16: + defaultValues := make([]int16, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for int16 flag") + } + defaultValues = append(defaultValues, int16(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int32: + defaultValues := make([]int32, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseInt(value, 10, 32) + if err != nil { + panic("Invalid default value for int32 flag") + } + defaultValues = append(defaultValues, int32(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Int64: + defaultValues := make([]int64, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseInt(value, 10, 64) + if err != nil { + panic("Invalid default value for int64 flag") + } + defaultValues = append(defaultValues, val) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint: + defaultValues := make([]uint, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint flag") + } + defaultValues = append(defaultValues, uint(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint8: + defaultValues := make([]uint8, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint8 flag") + } + defaultValues = append(defaultValues, uint8(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint16: + defaultValues := make([]uint16, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint16 flag") + } + defaultValues = append(defaultValues, uint16(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint32: + defaultValues := make([]uint32, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint32 flag") + } + defaultValues = append(defaultValues, uint32(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Uint64: + defaultValues := make([]uint64, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.Atoi(value) + if err != nil { + panic("Invalid default value for uint64 flag") + } + defaultValues = append(defaultValues, uint64(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Float32: + defaultValues := make([]float32, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseFloat(value, 32) + if err != nil { + panic("Invalid default value for float32 flag") + } + defaultValues = append(defaultValues, float32(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + case reflect.Float64: + defaultValues := make([]float64, 0, len(defaultSlice)) + for _, value := range defaultSlice { + val, err := strconv.ParseFloat(value, 64) + if err != nil { + panic("Invalid default value for float64 flag") + } + defaultValues = append(defaultValues, float64(val)) + } + field.Set(reflect.ValueOf(defaultValues)) + default: + panic(fmt.Sprintf("addSliceField() not supported slice type %s", t.Elem().Elem().Kind().String())) + } + return c +} + +// BoolFlag - Adds a boolean flag to the command +func (c *Command) BoolFlag(name, description string, variable *bool) *Command { + c.flags.BoolVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// BoolsFlag - Adds a booleans flag to the command +func (c *Command) BoolsFlag(name, description string, variable *[]bool) *Command { + c.flags.Var(newBoolsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// StringFlag - Adds a string flag to the command +func (c *Command) StringFlag(name, description string, variable *string) *Command { + c.flags.StringVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// StringsFlag - Adds a strings flag to the command +func (c *Command) StringsFlag(name, description string, variable *[]string) *Command { + c.flags.Var(newStringsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// IntFlag - Adds an int flag to the command +func (c *Command) IntFlag(name, description string, variable *int) *Command { + c.flags.IntVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// IntsFlag - Adds an ints flag to the command +func (c *Command) IntsFlag(name, description string, variable *[]int) *Command { + c.flags.Var(newIntsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int8Flag - Adds an int8 flag to the command +func (c *Command) Int8Flag(name, description string, variable *int8) *Command { + c.flags.Var(newInt8Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int8sFlag - Adds an int8 s flag to the command +func (c *Command) Int8sFlag(name, description string, variable *[]int8) *Command { + c.flags.Var(newInt8sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int16Flag - Adds an int16 flag to the command +func (c *Command) Int16Flag(name, description string, variable *int16) *Command { + c.flags.Var(newInt16Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int16sFlag - Adds an int16s flag to the command +func (c *Command) Int16sFlag(name, description string, variable *[]int16) *Command { + c.flags.Var(newInt16sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int32Flag - Adds an int32 flag to the command +func (c *Command) Int32Flag(name, description string, variable *int32) *Command { + c.flags.Var(newInt32Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int32sFlag - Adds an int32s flag to the command +func (c *Command) Int32sFlag(name, description string, variable *[]int32) *Command { + c.flags.Var(newInt32sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Int64Flag - Adds an int64 flag to the command +func (c *Command) Int64Flag(name, description string, variable *int64) *Command { + c.flags.Int64Var(variable, name, *variable, description) + c.flagCount++ + return c +} + +// Int64sFlag - Adds an int64s flag to the command +func (c *Command) Int64sFlag(name, description string, variable *[]int64) *Command { + c.flags.Var(newInt64sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// UintFlag - Adds an uint flag to the command +func (c *Command) UintFlag(name, description string, variable *uint) *Command { + c.flags.UintVar(variable, name, *variable, description) + c.flagCount++ + return c +} + +// UintsFlag - Adds an uints flag to the command +func (c *Command) UintsFlag(name, description string, variable *[]uint) *Command { + c.flags.Var(newUintsValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint8Flag - Adds an uint8 flag to the command +func (c *Command) Uint8Flag(name, description string, variable *uint8) *Command { + c.flags.Var(newUint8Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint8sFlag - Adds an uint8 s flag to the command +func (c *Command) Uint8sFlag(name, description string, variable *[]uint8) *Command { + c.flags.Var(newUint8sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint16Flag - Adds an uint16 flag to the command +func (c *Command) Uint16Flag(name, description string, variable *uint16) *Command { + c.flags.Var(newUint16Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint16sFlag - Adds an uint16s flag to the command +func (c *Command) Uint16sFlag(name, description string, variable *[]uint16) *Command { + c.flags.Var(newUint16sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint32Flag - Adds an uint32 flag to the command +func (c *Command) Uint32Flag(name, description string, variable *uint32) *Command { + c.flags.Var(newUint32Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Uint32sFlag - Adds an uint32s flag to the command +func (c *Command) Uint32sFlag(name, description string, variable *[]uint32) *Command { + c.flags.Var(newUint32sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// UInt64Flag - Adds an uint64 flag to the command +func (c *Command) UInt64Flag(name, description string, variable *uint64) *Command { + c.flags.Uint64Var(variable, name, *variable, description) + c.flagCount++ + return c +} + +// Uint64sFlag - Adds an uint64s flag to the command +func (c *Command) Uint64sFlag(name, description string, variable *[]uint64) *Command { + c.flags.Var(newUint64sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Float64Flag - Adds a float64 flag to the command +func (c *Command) Float64Flag(name, description string, variable *float64) *Command { + c.flags.Float64Var(variable, name, *variable, description) + c.flagCount++ + return c +} + +// Float32Flag - Adds a float32 flag to the command +func (c *Command) Float32Flag(name, description string, variable *float32) *Command { + c.flags.Var(newFloat32Value(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Float32sFlag - Adds a float32s flag to the command +func (c *Command) Float32sFlag(name, description string, variable *[]float32) *Command { + c.flags.Var(newFloat32sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +// Float64sFlag - Adds a float64s flag to the command +func (c *Command) Float64sFlag(name, description string, variable *[]float64) *Command { + c.flags.Var(newFloat64sValue(*variable, variable), name, description) + c.flagCount++ + return c +} + +type boolsFlagVar []bool + +func (f *boolsFlagVar) String() string { return fmt.Sprint([]bool(*f)) } + +func (f *boolsFlagVar) Set(value string) error { + if value == "" { + *f = append(*f, false) + return nil + } + b, err := strconv.ParseBool(value) + if err != nil { + return err + } + *f = append(*f, b) + return nil +} + +func (f *boolsFlagVar) IsBoolFlag() bool { + return true +} + +func newBoolsValue(val []bool, p *[]bool) *boolsFlagVar { + *p = val + return (*boolsFlagVar)(p) +} + +type stringsFlagVar []string + +func (f *stringsFlagVar) String() string { return fmt.Sprint([]string(*f)) } + +func (f *stringsFlagVar) Set(value string) error { + *f = append(*f, value) + return nil +} + +func newStringsValue(val []string, p *[]string) *stringsFlagVar { + *p = val + return (*stringsFlagVar)(p) +} + +type intsFlagVar []int + +func (f *intsFlagVar) String() string { return fmt.Sprint([]int(*f)) } + +func (f *intsFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newIntsValue(val []int, p *[]int) *intsFlagVar { + *p = val + return (*intsFlagVar)(p) +} + +type int8Value int8 + +func newInt8Value(val int8, p *int8) *int8Value { + *p = val + return (*int8Value)(p) +} + +func (f *int8Value) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = int8Value(i) + return nil +} + +func (f *int8Value) String() string { return fmt.Sprint(int8(*f)) } + +type int8sFlagVar []int8 + +func (f *int8sFlagVar) String() string { return fmt.Sprint([]int8(*f)) } + +func (f *int8sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, int8(i)) + return nil +} + +func newInt8sValue(val []int8, p *[]int8) *int8sFlagVar { + *p = val + return (*int8sFlagVar)(p) +} + +type int16Value int16 + +func newInt16Value(val int16, p *int16) *int16Value { + *p = val + return (*int16Value)(p) +} + +func (f *int16Value) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = int16Value(i) + return nil +} + +func (f *int16Value) String() string { return fmt.Sprint(int16(*f)) } + +type int16sFlagVar []int16 + +func (f *int16sFlagVar) String() string { return fmt.Sprint([]int16(*f)) } + +func (f *int16sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, int16(i)) + return nil +} + +func newInt16sValue(val []int16, p *[]int16) *int16sFlagVar { + *p = val + return (*int16sFlagVar)(p) +} + +type int32Value int32 + +func newInt32Value(val int32, p *int32) *int32Value { + *p = val + return (*int32Value)(p) +} + +func (f *int32Value) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = int32Value(i) + return nil +} + +func (f *int32Value) String() string { return fmt.Sprint(int32(*f)) } + +type int32sFlagVar []int32 + +func (f *int32sFlagVar) String() string { return fmt.Sprint([]int32(*f)) } + +func (f *int32sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, int32(i)) + return nil +} + +func newInt32sValue(val []int32, p *[]int32) *int32sFlagVar { + *p = val + return (*int32sFlagVar)(p) +} + +type int64sFlagVar []int64 + +func (f *int64sFlagVar) String() string { return fmt.Sprint([]int64(*f)) } + +func (f *int64sFlagVar) Set(value string) error { + i, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newInt64sValue(val []int64, p *[]int64) *int64sFlagVar { + *p = val + return (*int64sFlagVar)(p) +} + +type uintsFlagVar []uint + +func (f *uintsFlagVar) String() string { + return fmt.Sprint([]uint(*f)) +} + +func (f *uintsFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint(i)) + return nil +} + +func newUintsValue(val []uint, p *[]uint) *uintsFlagVar { + *p = val + return (*uintsFlagVar)(p) +} + +type uint8FlagVar uint8 + +func newUint8Value(val uint8, p *uint8) *uint8FlagVar { + *p = val + return (*uint8FlagVar)(p) +} + +func (f *uint8FlagVar) String() string { + return fmt.Sprint(uint8(*f)) +} + +func (f *uint8FlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = uint8FlagVar(i) + return nil +} + +type uint8sFlagVar []uint8 + +func (f *uint8sFlagVar) String() string { + return fmt.Sprint([]uint8(*f)) +} + +func (f *uint8sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint8(i)) + return nil +} + +func newUint8sValue(val []uint8, p *[]uint8) *uint8sFlagVar { + *p = val + return (*uint8sFlagVar)(p) +} + +type uint16FlagVar uint16 + +func newUint16Value(val uint16, p *uint16) *uint16FlagVar { + *p = val + return (*uint16FlagVar)(p) +} + +func (f *uint16FlagVar) String() string { + return fmt.Sprint(uint16(*f)) +} + +func (f *uint16FlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = uint16FlagVar(i) + return nil +} + +type uint16sFlagVar []uint16 + +func (f *uint16sFlagVar) String() string { + return fmt.Sprint([]uint16(*f)) +} + +func (f *uint16sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint16(i)) + return nil +} + +func newUint16sValue(val []uint16, p *[]uint16) *uint16sFlagVar { + *p = val + return (*uint16sFlagVar)(p) +} + +type uint32FlagVar uint32 + +func newUint32Value(val uint32, p *uint32) *uint32FlagVar { + *p = val + return (*uint32FlagVar)(p) +} + +func (f *uint32FlagVar) String() string { + return fmt.Sprint(uint32(*f)) +} + +func (f *uint32FlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = uint32FlagVar(i) + return nil +} + +type uint32sFlagVar []uint32 + +func (f *uint32sFlagVar) String() string { + return fmt.Sprint([]uint32(*f)) +} + +func (f *uint32sFlagVar) Set(value string) error { + i, err := strconv.Atoi(value) + if err != nil { + return err + } + *f = append(*f, uint32(i)) + return nil +} + +func newUint32sValue(val []uint32, p *[]uint32) *uint32sFlagVar { + *p = val + return (*uint32sFlagVar)(p) +} + +type uint64sFlagVar []uint64 + +func (f *uint64sFlagVar) String() string { return fmt.Sprint([]uint64(*f)) } + +func (f *uint64sFlagVar) Set(value string) error { + i, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newUint64sValue(val []uint64, p *[]uint64) *uint64sFlagVar { + *p = val + return (*uint64sFlagVar)(p) +} + +type float32sFlagVar []float32 + +func (f *float32sFlagVar) String() string { return fmt.Sprint([]float32(*f)) } + +func (f *float32sFlagVar) Set(value string) error { + i, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + *f = append(*f, float32(i)) + return nil +} + +func newFloat32sValue(val []float32, p *[]float32) *float32sFlagVar { + *p = val + return (*float32sFlagVar)(p) +} + +type float32FlagVar float32 + +func (f *float32FlagVar) String() string { return fmt.Sprint(float32(*f)) } + +func (f *float32FlagVar) Set(value string) error { + i, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + *f = float32FlagVar(i) + return nil +} + +func newFloat32Value(val float32, p *float32) *float32FlagVar { + *p = val + return (*float32FlagVar)(p) +} + +type float64sFlagVar []float64 + +func (f *float64sFlagVar) String() string { return fmt.Sprint([]float64(*f)) } + +func (f *float64sFlagVar) Set(value string) error { + i, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + *f = append(*f, i) + return nil +} + +func newFloat64sValue(val []float64, p *[]float64) *float64sFlagVar { + *p = val + return (*float64sFlagVar)(p) +} + +// LongDescription - Sets the long description for the command +func (c *Command) LongDescription(longdescription string) *Command { + c.longdescription = longdescription + return c +} + +// OtherArgs - Returns the non-flag arguments passed to the subcommand. NOTE: This should only be called within the context of an action. +func (c *Command) OtherArgs() []string { + return c.flags.Args() +} + +func (c *Command) NewChildCommandFunction(name string, description string, fn any) *Command { + result := c.NewChildCommand(name, description) + // use reflection to determine if this is a function + // if not, panic + t := reflect.TypeOf(fn) + if t.Kind() != reflect.Func { + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + + // Check the function has 1 input ant it's a struct pointer + fnValue := reflect.ValueOf(fn) + if t.NumIn() != 1 { + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + // Check the input is a struct pointer + if t.In(0).Kind() != reflect.Ptr { + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + if t.In(0).Elem().Kind() != reflect.Struct { + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + // Check only 1 output and it's an error + if t.NumOut() != 1 { + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + if t.Out(0) != reflect.TypeOf((*error)(nil)).Elem() { + panic("NewChildCommandFunction '" + name + "' requires a function with the signature 'func(*struct) error'") + } + flags := reflect.New(t.In(0).Elem()) + result.Action(func() error { + result := fnValue.Call([]reflect.Value{flags})[0].Interface() + if result != nil { + return result.(error) + } + return nil + }) + result.AddFlags(flags.Interface()) + return result +} + +func (c *Command) parsePositionalArgs(args []string) error { + for index, posArg := range args { + // Check the map for a field for this arg + key := strconv.Itoa(index + 1) + field, ok := c.positionalArgsMap[key] + if !ok { + continue + } + fieldType := field.Type() + switch fieldType.Kind() { + case reflect.Bool: + // set value of field to true + field.SetBool(true) + case reflect.String: + field.SetString(posArg) + case reflect.Int64, reflect.Int32, reflect.Int16, reflect.Int8, reflect.Int: + value, err := strconv.ParseInt(posArg, 10, 64) + if err != nil { + return err + } + field.SetInt(value) + case reflect.Uint64, reflect.Uint32, reflect.Uint16, reflect.Uint8, reflect.Uint: + value, err := strconv.ParseUint(posArg, 10, 64) + if err != nil { + return err + } + field.SetUint(value) + case reflect.Float64, reflect.Float32: + value, err := strconv.ParseFloat(posArg, 64) + if err != nil { + return err + } + field.SetFloat(value) + case reflect.Slice: + c.addSliceField(field, posArg, c.sliceSeparator[key]) + default: + return E("cli.parsePositionalArgs", "unsupported type for positional argument: "+fieldType.Name(), nil) + } + } + return nil +} diff --git a/pkg/core/config.go b/pkg/core/config.go new file mode 100644 index 0000000..f2b64a0 --- /dev/null +++ b/pkg/core/config.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Settings, feature flags, and typed configuration for the Core framework. + +package core + +import ( + "sync" +) + +// ConfigVar is a variable that can be set, unset, and queried for its state. +type ConfigVar[T any] struct { + val T + set bool +} + +func (v *ConfigVar[T]) Get() T { return v.val } +func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true } +func (v *ConfigVar[T]) IsSet() bool { return v.set } +func (v *ConfigVar[T]) Unset() { + v.set = false + var zero T + v.val = zero +} + +func NewConfigVar[T any](val T) ConfigVar[T] { + return ConfigVar[T]{val: val, set: true} +} + +// ConfigOpts holds configuration data. +type ConfigOpts struct { + Settings map[string]any + Features map[string]bool +} + +func (o *ConfigOpts) init() { + if o.Settings == nil { + o.Settings = make(map[string]any) + } + if o.Features == nil { + o.Features = make(map[string]bool) + } +} + +// Config holds configuration settings and feature flags. +type Config struct { + *ConfigOpts + mu sync.RWMutex +} + +// Set stores a configuration value by key. +func (e *Config) Set(key string, val any) { + e.mu.Lock() + e.ConfigOpts.init() + e.Settings[key] = val + e.mu.Unlock() +} + +// Get retrieves a configuration value by key. +func (e *Config) Get(key string) (any, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + if e.ConfigOpts == nil || e.Settings == nil { + return nil, false + } + val, ok := e.Settings[key] + return val, ok +} + +func (e *Config) String(key string) string { return ConfigGet[string](e, key) } +func (e *Config) Int(key string) int { return ConfigGet[int](e, key) } +func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) } + +// ConfigGet retrieves a typed configuration value. +func ConfigGet[T any](e *Config, key string) T { + val, ok := e.Get(key) + if !ok { + var zero T + return zero + } + typed, _ := val.(T) + return typed +} + +// --- Feature Flags --- + +func (e *Config) Enable(feature string) { + e.mu.Lock() + e.ConfigOpts.init() + e.Features[feature] = true + e.mu.Unlock() +} + +func (e *Config) Disable(feature string) { + e.mu.Lock() + e.ConfigOpts.init() + e.Features[feature] = false + e.mu.Unlock() +} + +func (e *Config) Enabled(feature string) bool { + e.mu.RLock() + v := e.Features[feature] + e.mu.RUnlock() + return v +} + +func (e *Config) EnabledFeatures() []string { + e.mu.RLock() + defer e.mu.RUnlock() + var result []string + for k, v := range e.Features { + if v { + result = append(result, k) + } + } + return result +} diff --git a/pkg/core/contract.go b/pkg/core/contract.go new file mode 100644 index 0000000..7c2f604 --- /dev/null +++ b/pkg/core/contract.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Contracts, options, and type definitions for the Core framework. + +package core + +import ( + "context" + "embed" + "fmt" + "reflect" + "strings" +) + +// Contract specifies operational guarantees for Core and its services. +type Contract struct { + DontPanic bool + DisableLogging bool +} + +// Option is a function that configures the Core. +type Option func(*Core) error + +// Message is the type for IPC broadcasts (fire-and-forget). +type Message any + +// Query is the type for read-only IPC requests. +type Query any + +// Task is the type for IPC requests that perform side effects. +type Task any + +// TaskWithID is an optional interface for tasks that need to know their assigned ID. +type TaskWithID interface { + Task + SetTaskID(id string) + GetTaskID() string +} + +// QueryHandler handles Query requests. Returns (result, handled, error). +type QueryHandler func(*Core, Query) (any, bool, error) + +// TaskHandler handles Task requests. Returns (result, handled, error). +type TaskHandler func(*Core, Task) (any, bool, error) + +// Startable is implemented by services that need startup initialisation. +type Startable interface { + OnStartup(ctx context.Context) error +} + +// Stoppable is implemented by services that need shutdown cleanup. +type Stoppable interface { + OnShutdown(ctx context.Context) error +} + +// ConfigService provides access to application configuration. +type ConfigService interface { + Get(key string, out any) error + Set(key string, v any) error +} + +// --- Action Messages --- + +type ActionServiceStartup struct{} +type ActionServiceShutdown struct{} + +type ActionTaskStarted struct { + TaskID string + Task Task +} + +type ActionTaskProgress struct { + TaskID string + Task Task + Progress float64 + Message string +} + +type ActionTaskCompleted struct { + TaskID string + Task Task + Result any + Error error +} + +// --- Constructor --- + +// New creates a Core instance with the provided options. +func New(opts ...Option) (*Core, error) { + c := &Core{ + app: &App{}, + fs: &Fs{root: "/"}, + cfg: &Config{ConfigOpts: &ConfigOpts{}}, + err: &ErrPan{}, + log: &ErrLog{&ErrOpts{Log: defaultLog}}, + cli: &Cli{opts: &CliOpts{}}, + srv: &Service{}, + lock: &Lock{}, + ipc: &Ipc{}, + i18n: &I18n{}, + } + + for _, o := range opts { + if err := o(c); err != nil { + return nil, err + } + } + + c.LockApply() + return c, nil +} + +// --- With* Options --- + +// WithService registers a service with auto-discovered name and IPC handler. +func WithService(factory func(*Core) (any, error)) Option { + return func(c *Core) error { + serviceInstance, err := factory(c) + if err != nil { + return E("core.WithService", "failed to create service", err) + } + if serviceInstance == nil { + return E("core.WithService", "service factory returned nil instance", nil) + } + + typeOfService := reflect.TypeOf(serviceInstance) + if typeOfService.Kind() == reflect.Ptr { + typeOfService = typeOfService.Elem() + } + pkgPath := typeOfService.PkgPath() + parts := strings.Split(pkgPath, "/") + name := strings.ToLower(parts[len(parts)-1]) + if name == "" { + return E("core.WithService", fmt.Sprintf("service name could not be discovered for type %T (PkgPath is empty)", serviceInstance), nil) + } + + instanceValue := reflect.ValueOf(serviceInstance) + handlerMethod := instanceValue.MethodByName("HandleIPCEvents") + if handlerMethod.IsValid() { + if handler, ok := handlerMethod.Interface().(func(*Core, Message) error); ok { + c.RegisterAction(handler) + } else { + return E("core.WithService", fmt.Sprintf("service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name), nil) + } + } + + result := c.Service(name, serviceInstance) + if err, ok := result.(error); ok { + return err + } + return nil + } +} + +// WithName registers a service with an explicit name. +func WithName(name string, factory func(*Core) (any, error)) Option { + return func(c *Core) error { + serviceInstance, err := factory(c) + if err != nil { + return E("core.WithName", fmt.Sprintf("failed to create service %q", name), err) + } + result := c.Service(name, serviceInstance) + if err, ok := result.(error); ok { + return err + } + return nil + } +} + +// WithApp injects the GUI runtime (e.g., Wails App). +func WithApp(runtime any) Option { + return func(c *Core) error { + c.app.Runtime = runtime + return nil + } +} + +// WithAssets mounts embedded assets. +func WithAssets(efs embed.FS) Option { + return func(c *Core) error { + sub, err := Mount(efs, ".") + if err != nil { + return E("core.WithAssets", "failed to mount assets", err) + } + c.emb = sub + return nil + } +} + + +// WithServiceLock prevents service registration after initialisation. +// Order-independent — lock is applied after all options are processed. +func WithServiceLock() Option { + return func(c *Core) error { + c.LockEnable() + return nil + } +} diff --git a/pkg/core/core.go b/pkg/core/core.go index eb7c64b..ad5aa4d 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -1,396 +1,71 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package core is a dependency injection and service lifecycle framework for Go. +// This file defines the Core struct, accessors, and IPC/error wrappers. + package core import ( - "context" - "embed" - "errors" - "fmt" - "reflect" - "slices" - "strings" "sync" + "sync/atomic" ) -var ( - instance *Core - instanceMu sync.RWMutex -) +// --- Core Struct --- -// New initialises a Core instance using the provided options and performs the necessary setup. -// It is the primary entry point for creating a new Core application. -// -// Example: -// -// core, err := core.New( -// core.WithService(&MyService{}), -// core.WithAssets(assets), -// ) -func New(opts ...Option) (*Core, error) { - c := &Core{ - Features: &Features{}, - svc: newServiceManager(), - } - c.bus = newMessageBus(c) +// Core is the central application object that manages services, assets, and communication. +type Core struct { + app *App // c.App() — Application identity + optional GUI runtime + emb *Embed // c.Embed() — Mounted embedded assets (read-only) + fs *Fs // c.Fs() — Local filesystem I/O (sandboxable) + cfg *Config // c.Config() — Configuration, settings, feature flags + err *ErrPan // c.Error() — Panic recovery and crash reporting + log *ErrLog // c.Log() — Structured logging + error wrapping + cli *Cli // c.Cli() — CLI command framework + srv *Service // c.Service("name") — Service registry and lifecycle + lock *Lock // c.Lock("name") — Named mutexes + ipc *Ipc // c.IPC() — Message bus for IPC + i18n *I18n // c.I18n() — Internationalisation and locale collection - for _, o := range opts { - if err := o(c); err != nil { - return nil, err - } - } - - c.svc.applyLock() - return c, nil + taskIDCounter atomic.Uint64 + wg sync.WaitGroup + shutdown atomic.Bool } -// WithService creates an Option that registers a service. It automatically discovers -// the service name from its package path and registers its IPC handler if it -// implements a method named `HandleIPCEvents`. -// -// Example: -// -// // In myapp/services/calculator.go -// package services -// -// type Calculator struct{} -// -// func (s *Calculator) Add(a, b int) int { return a + b } -// -// // In main.go -// import "myapp/services" -// -// core.New(core.WithService(services.NewCalculator)) -func WithService(factory func(*Core) (any, error)) Option { - return func(c *Core) error { - serviceInstance, err := factory(c) +// --- Accessors --- - if err != nil { - return fmt.Errorf("core: failed to create service: %w", err) - } - if serviceInstance == nil { - return fmt.Errorf("core: service factory returned nil instance") - } +func (c *Core) App() *App { return c.app } +func (c *Core) Embed() *Embed { return c.emb } +func (c *Core) Fs() *Fs { return c.fs } +func (c *Core) Config() *Config { return c.cfg } +func (c *Core) Error() *ErrPan { return c.err } +func (c *Core) Log() *ErrLog { return c.log } +func (c *Core) Cli() *Cli { return c.cli } +func (c *Core) IPC() *Ipc { return c.ipc } +func (c *Core) I18n() *I18n { return c.i18n } +func (c *Core) Core() *Core { return c } - // --- Service Name Discovery --- - typeOfService := reflect.TypeOf(serviceInstance) - if typeOfService.Kind() == reflect.Ptr { - typeOfService = typeOfService.Elem() - } - pkgPath := typeOfService.PkgPath() - parts := strings.Split(pkgPath, "/") - name := strings.ToLower(parts[len(parts)-1]) - if name == "" { - return fmt.Errorf("core: service name could not be discovered for type %T (PkgPath is empty)", serviceInstance) - } +// --- IPC (uppercase aliases) --- - // --- IPC Handler Discovery --- - instanceValue := reflect.ValueOf(serviceInstance) - handlerMethod := instanceValue.MethodByName("HandleIPCEvents") - if handlerMethod.IsValid() { - if handler, ok := handlerMethod.Interface().(func(*Core, Message) error); ok { - c.RegisterAction(handler) - } else { - return fmt.Errorf("core: service %q has HandleIPCEvents but wrong signature; expected func(*Core, Message) error", name) - } - } +func (c *Core) ACTION(msg Message) error { return c.Action(msg) } +func (c *Core) QUERY(q Query) (any, bool, error) { return c.Query(q) } +func (c *Core) QUERYALL(q Query) ([]any, error) { return c.QueryAll(q) } +func (c *Core) PERFORM(t Task) (any, bool, error) { return c.Perform(t) } - return c.RegisterService(name, serviceInstance) - } +// --- Error+Log --- + +// LogError logs an error and returns a wrapped error. +func (c *Core) LogError(err error, op, msg string) error { + return c.log.Error(err, op, msg) } -// WithName creates an option that registers a service with a specific name. -// This is useful when the service name cannot be inferred from the package path, -// such as when using anonymous functions as factories. -// Note: Unlike WithService, this does not automatically discover or register -// IPC handlers. If your service needs IPC handling, implement HandleIPCEvents -// and register it manually. -func WithName(name string, factory func(*Core) (any, error)) Option { - return func(c *Core) error { - serviceInstance, err := factory(c) - if err != nil { - return fmt.Errorf("core: failed to create service '%s': %w", name, err) - } - return c.RegisterService(name, serviceInstance) - } +// LogWarn logs a warning and returns a wrapped error. +func (c *Core) LogWarn(err error, op, msg string) error { + return c.log.Warn(err, op, msg) } -// WithApp creates an Option that injects the GUI runtime (e.g., Wails App) into the Core. -// This is essential for services that need to interact with the GUI runtime. -func WithApp(app any) Option { - return func(c *Core) error { - c.App = app - return nil - } +// Must logs and panics if err is not nil. +func (c *Core) Must(err error, op, msg string) { + c.log.Must(err, op, msg) } -// WithAssets creates an Option that registers the application's embedded assets. -// This is necessary for the application to be able to serve its frontend. -func WithAssets(fs embed.FS) Option { - return func(c *Core) error { - c.assets = fs - return nil - } -} - -// WithServiceLock creates an Option that prevents any further services from being -// registered after the Core has been initialized. This is a security measure to -// prevent late-binding of services that could have unintended consequences. -func WithServiceLock() Option { - return func(c *Core) error { - c.svc.enableLock() - return nil - } -} - -// --- Core Methods --- - -// ServiceStartup is the entry point for the Core service's startup lifecycle. -// It is called by the GUI runtime when the application starts. -func (c *Core) ServiceStartup(ctx context.Context, options any) error { - startables := c.svc.getStartables() - - var agg error - for _, s := range startables { - if err := ctx.Err(); err != nil { - return errors.Join(agg, err) - } - if err := s.OnStartup(ctx); err != nil { - agg = errors.Join(agg, err) - } - } - - if err := c.ACTION(ActionServiceStartup{}); err != nil { - agg = errors.Join(agg, err) - } - - return agg -} - -// ServiceShutdown is the entry point for the Core service's shutdown lifecycle. -// It is called by the GUI runtime when the application shuts down. -func (c *Core) ServiceShutdown(ctx context.Context) error { - c.shutdown.Store(true) - - var agg error - if err := c.ACTION(ActionServiceShutdown{}); err != nil { - agg = errors.Join(agg, err) - } - - stoppables := c.svc.getStoppables() - for _, s := range slices.Backward(stoppables) { - if err := ctx.Err(); err != nil { - agg = errors.Join(agg, err) - break // don't return — must still wait for background tasks below - } - if err := s.OnShutdown(ctx); err != nil { - agg = errors.Join(agg, err) - } - } - - // Wait for background tasks (PerformAsync), respecting context deadline. - done := make(chan struct{}) - go func() { - c.wg.Wait() - close(done) - }() - select { - case <-done: - case <-ctx.Done(): - agg = errors.Join(agg, ctx.Err()) - } - - return agg -} - -// ACTION dispatches a message to all registered IPC handlers. -// This is the primary mechanism for services to communicate with each other. -func (c *Core) ACTION(msg Message) error { - return c.bus.action(msg) -} - -// RegisterAction adds a new IPC handler to the Core. -func (c *Core) RegisterAction(handler func(*Core, Message) error) { - c.bus.registerAction(handler) -} - -// RegisterActions adds multiple IPC handlers to the Core. -func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { - c.bus.registerActions(handlers...) -} - -// QUERY dispatches a query to handlers until one responds. -// Returns (result, handled, error). If no handler responds, handled is false. -func (c *Core) QUERY(q Query) (any, bool, error) { - return c.bus.query(q) -} - -// QUERYALL dispatches a query to all handlers and collects all responses. -// Returns all results from handlers that responded. -func (c *Core) QUERYALL(q Query) ([]any, error) { - return c.bus.queryAll(q) -} - -// PERFORM dispatches a task to handlers until one executes it. -// Returns (result, handled, error). If no handler responds, handled is false. -func (c *Core) PERFORM(t Task) (any, bool, error) { - return c.bus.perform(t) -} - -// PerformAsync dispatches a task to be executed in a background goroutine. -// It returns a unique task ID that can be used to track the task's progress. -// The result of the task will be broadcasted via an ActionTaskCompleted message. -func (c *Core) PerformAsync(t Task) string { - if c.shutdown.Load() { - return "" - } - - taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1)) - - // If the task supports it, inject the ID - if tid, ok := t.(TaskWithID); ok { - tid.SetTaskID(taskID) - } - - // Broadcast task started - _ = c.ACTION(ActionTaskStarted{ - TaskID: taskID, - Task: t, - }) - - c.wg.Go(func() { - result, handled, err := c.PERFORM(t) - if !handled && err == nil { - err = fmt.Errorf("no handler found for task type %T", t) - } - - // Broadcast task completed - _ = c.ACTION(ActionTaskCompleted{ - TaskID: taskID, - Task: t, - Result: result, - Error: err, - }) - }) - - return taskID -} - -// Progress broadcasts a progress update for a background task. -func (c *Core) Progress(taskID string, progress float64, message string, t Task) { - _ = c.ACTION(ActionTaskProgress{ - TaskID: taskID, - Task: t, - Progress: progress, - Message: message, - }) -} - -// RegisterQuery adds a query handler to the Core. -func (c *Core) RegisterQuery(handler QueryHandler) { - c.bus.registerQuery(handler) -} - -// RegisterTask adds a task handler to the Core. -func (c *Core) RegisterTask(handler TaskHandler) { - c.bus.registerTask(handler) -} - -// RegisterService adds a new service to the Core. -func (c *Core) RegisterService(name string, api any) error { - return c.svc.registerService(name, api) -} - -// Service retrieves a registered service by name. -// It returns nil if the service is not found. -func (c *Core) Service(name string) any { - return c.svc.service(name) -} - -// ServiceFor retrieves a registered service by name and asserts its type to the given interface T. -func ServiceFor[T any](c *Core, name string) (T, error) { - var zero T - raw := c.Service(name) - if raw == nil { - return zero, fmt.Errorf("service '%s' not found", name) - } - typed, ok := raw.(T) - if !ok { - return zero, fmt.Errorf("service '%s' is of type %T, but expected %T", name, raw, zero) - } - return typed, nil -} - -// MustServiceFor retrieves a registered service by name and asserts its type to the given interface T. -// It panics if the service is not found or cannot be cast to T. -func MustServiceFor[T any](c *Core, name string) T { - svc, err := ServiceFor[T](c, name) - if err != nil { - panic(err) - } - return svc -} - -// App returns the global application instance. -// It panics if the Core has not been initialized via SetInstance. -// This is typically used by GUI runtimes that need global access. -func App() any { - instanceMu.RLock() - inst := instance - instanceMu.RUnlock() - if inst == nil { - panic("core.App() called before core.SetInstance()") - } - return inst.App -} - -// SetInstance sets the global Core instance for App() access. -// This is typically called by GUI runtimes during initialization. -func SetInstance(c *Core) { - instanceMu.Lock() - instance = c - instanceMu.Unlock() -} - -// GetInstance returns the global Core instance, or nil if not set. -// Use this for non-panicking access to the global instance. -func GetInstance() *Core { - instanceMu.RLock() - inst := instance - instanceMu.RUnlock() - return inst -} - -// ClearInstance resets the global Core instance to nil. -// This is primarily useful for testing to ensure a clean state between tests. -func ClearInstance() { - instanceMu.Lock() - instance = nil - instanceMu.Unlock() -} - -// Config returns the registered Config service. -func (c *Core) Config() Config { - return MustServiceFor[Config](c, "config") -} - -// Display returns the registered Display service. -func (c *Core) Display() Display { - return MustServiceFor[Display](c, "display") -} - -// Workspace returns the registered Workspace service. -func (c *Core) Workspace() Workspace { - return MustServiceFor[Workspace](c, "workspace") -} - -// Crypt returns the registered Crypt service. -func (c *Core) Crypt() Crypt { - return MustServiceFor[Crypt](c, "crypt") -} - -// Core returns self, implementing the CoreProvider interface. -func (c *Core) Core() *Core { return c } - -// Assets returns the embedded filesystem containing the application's assets. -func (c *Core) Assets() embed.FS { - return c.assets -} +// --- Global Instance --- diff --git a/pkg/core/e.go b/pkg/core/e.go deleted file mode 100644 index edd2028..0000000 --- a/pkg/core/e.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package core provides a standardized error handling mechanism for the Core library. -// It allows for wrapping errors with contextual information, making it easier to -// trace the origin of an error and provide meaningful feedback. -// -// The design of this package is influenced by the need for a simple, yet powerful -// way to handle errors that can occur in different layers of the application, -// from low-level file operations to high-level service interactions. -// -// The key features of this package are: -// - Error wrapping: The Op and an optional Msg field provide context about -// where and why an error occurred. -// - Stack traces: By wrapping errors, we can build a logical stack trace -// that is more informative than a raw stack trace. -// - Consistent error handling: Encourages a uniform approach to error -// handling across the entire codebase. -package core - -import ( - "fmt" -) - -// Error represents a standardized error with operational context. -type Error struct { - // Op is the operation being performed, e.g., "config.Load". - Op string - // Msg is a human-readable message explaining the error. - Msg string - // Err is the underlying error that was wrapped. - Err error -} - -// E is a helper function to create a new Error. -// This is the primary way to create errors that will be consumed by the system. -// For example: -// -// return e.E("config.Load", "failed to load config file", err) -// -// The 'op' parameter should be in the format of 'package.function' or 'service.method'. -// The 'msg' parameter should be a human-readable message that can be displayed to the user. -// The 'err' parameter is the underlying error that is being wrapped. -func E(op, msg string, err error) error { - if err == nil { - return &Error{Op: op, Msg: msg} - } - return &Error{Op: op, Msg: msg, Err: err} -} - -// Error returns the string representation of the error. -func (e *Error) Error() string { - if e.Err != nil { - return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) - } - return fmt.Sprintf("%s: %s", e.Op, e.Msg) -} - -// Unwrap provides compatibility for Go's errors.Is and errors.As functions. -func (e *Error) Unwrap() error { - return e.Err -} diff --git a/pkg/core/embed.go b/pkg/core/embed.go new file mode 100644 index 0000000..259a7dc --- /dev/null +++ b/pkg/core/embed.go @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Embedded assets for the Core framework. +// +// Embed provides scoped filesystem access for go:embed and any fs.FS. +// Also includes build-time asset packing (AST scanner + compressor) +// and template-based directory extraction. +// +// Usage (mount): +// +// sub, _ := core.Mount(myFS, "lib/persona") +// content, _ := sub.ReadString("secops/developer.md") +// +// Usage (extract): +// +// core.Extract(fsys, "/tmp/workspace", data) +// +// Usage (pack): +// +// refs, _ := core.ScanAssets([]string{"main.go"}) +// source, _ := core.GeneratePack(refs) +package core + +import ( + "bytes" + "compress/gzip" + "embed" + "encoding/base64" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "text/template" +) + +// --- Runtime: Asset Registry --- + +// AssetGroup holds a named collection of packed assets. +type AssetGroup struct { + assets map[string]string // name → compressed data +} + +var ( + assetGroups = make(map[string]*AssetGroup) + assetGroupsMu sync.RWMutex +) + +// AddAsset registers a packed asset at runtime (called from generated init()). +func AddAsset(group, name, data string) { + assetGroupsMu.Lock() + defer assetGroupsMu.Unlock() + + g, ok := assetGroups[group] + if !ok { + g = &AssetGroup{assets: make(map[string]string)} + assetGroups[group] = g + } + g.assets[name] = data +} + +// GetAsset retrieves and decompresses a packed asset. +func GetAsset(group, name string) (string, error) { + assetGroupsMu.RLock() + g, ok := assetGroups[group] + assetGroupsMu.RUnlock() + if !ok { + return "", E("core.GetAsset", fmt.Sprintf("asset group %q not found", group), nil) + } + data, ok := g.assets[name] + if !ok { + return "", E("core.GetAsset", fmt.Sprintf("asset %q not found in group %q", name, group), nil) + } + return decompress(data) +} + +// GetAssetBytes retrieves a packed asset as bytes. +func GetAssetBytes(group, name string) ([]byte, error) { + s, err := GetAsset(group, name) + return []byte(s), err +} + +// --- Build-time: AST Scanner --- + +// AssetRef is a reference to an asset found in source code. +type AssetRef struct { + Name string + Path string + Group string + FullPath string +} + +// ScannedPackage holds all asset references from a set of source files. +type ScannedPackage struct { + PackageName string + BaseDir string + Groups []string + Assets []AssetRef +} + +// ScanAssets parses Go source files and finds asset references. +// Looks for calls to: core.GetAsset("group", "name"), core.AddAsset, etc. +func ScanAssets(filenames []string) ([]ScannedPackage, error) { + packageMap := make(map[string]*ScannedPackage) + var scanErr error + + for _, filename := range filenames { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filename, nil, parser.AllErrors) + if err != nil { + return nil, err + } + + baseDir := filepath.Dir(filename) + pkg, ok := packageMap[baseDir] + if !ok { + pkg = &ScannedPackage{BaseDir: baseDir} + packageMap[baseDir] = pkg + } + pkg.PackageName = node.Name.Name + + ast.Inspect(node, func(n ast.Node) bool { + if scanErr != nil { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + + // Look for core.GetAsset or mewn.String patterns + if ident.Name == "core" || ident.Name == "mewn" { + switch sel.Sel.Name { + case "GetAsset", "GetAssetBytes", "String", "MustString", "Bytes", "MustBytes": + if len(call.Args) >= 1 { + if lit, ok := call.Args[len(call.Args)-1].(*ast.BasicLit); ok { + path := strings.Trim(lit.Value, "\"") + group := "." + if len(call.Args) >= 2 { + if glit, ok := call.Args[0].(*ast.BasicLit); ok { + group = strings.Trim(glit.Value, "\"") + } + } + fullPath, err := filepath.Abs(filepath.Join(baseDir, group, path)) + if err != nil { + scanErr = Wrap(err, "core.ScanAssets", fmt.Sprintf("could not determine absolute path for asset %q in group %q", path, group)) + return false + } + pkg.Assets = append(pkg.Assets, AssetRef{ + Name: path, + + Group: group, + FullPath: fullPath, + }) + } + } + case "Group": + // Variable assignment: g := core.Group("./assets") + if len(call.Args) == 1 { + if lit, ok := call.Args[0].(*ast.BasicLit); ok { + path := strings.Trim(lit.Value, "\"") + fullPath, err := filepath.Abs(filepath.Join(baseDir, path)) + if err != nil { + scanErr = Wrap(err, "core.ScanAssets", fmt.Sprintf("could not determine absolute path for group %q", path)) + return false + } + pkg.Groups = append(pkg.Groups, fullPath) + // Track for variable resolution + } + } + } + } + + return true + }) + if scanErr != nil { + return nil, scanErr + } + } + + var result []ScannedPackage + for _, pkg := range packageMap { + result = append(result, *pkg) + } + return result, nil +} + +// GeneratePack creates Go source code that embeds the scanned assets. +func GeneratePack(pkg ScannedPackage) (string, error) { + var b strings.Builder + + b.WriteString(fmt.Sprintf("package %s\n\n", pkg.PackageName)) + b.WriteString("// Code generated by core pack. DO NOT EDIT.\n\n") + + if len(pkg.Assets) == 0 && len(pkg.Groups) == 0 { + return b.String(), nil + } + + b.WriteString("import \"forge.lthn.ai/core/go/pkg/core\"\n\n") + b.WriteString("func init() {\n") + + // Pack groups (entire directories) + packed := make(map[string]bool) + for _, groupPath := range pkg.Groups { + files, err := getAllFiles(groupPath) + if err != nil { + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to scan asset group %q", groupPath)) + } + for _, file := range files { + if packed[file] { + continue + } + data, err := compressFile(file) + if err != nil { + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q in group %q", file, groupPath)) + } + localPath := strings.TrimPrefix(file, groupPath+"/") + relGroup, err := filepath.Rel(pkg.BaseDir, groupPath) + if err != nil { + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("could not determine relative path for group %q (base %q)", groupPath, pkg.BaseDir)) + } + b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data)) + packed[file] = true + } + } + + // Pack individual assets + for _, asset := range pkg.Assets { + if packed[asset.FullPath] { + continue + } + data, err := compressFile(asset.FullPath) + if err != nil { + return "", Wrap(err, "core.GeneratePack", fmt.Sprintf("failed to compress asset %q", asset.FullPath)) + } + b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data)) + packed[asset.FullPath] = true + } + + b.WriteString("}\n") + return b.String(), nil +} + +// --- Compression --- + +func compressFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return compress(string(data)) +} + +func compress(input string) (string, error) { + var buf bytes.Buffer + b64 := base64.NewEncoder(base64.StdEncoding, &buf) + gz, err := gzip.NewWriterLevel(b64, gzip.BestCompression) + if err != nil { + return "", err + } + if _, err := gz.Write([]byte(input)); err != nil { + _ = gz.Close() + _ = b64.Close() + return "", err + } + if err := gz.Close(); err != nil { + _ = b64.Close() + return "", err + } + if err := b64.Close(); err != nil { + return "", err + } + return buf.String(), nil +} + +func decompress(input string) (string, error) { + b64 := base64.NewDecoder(base64.StdEncoding, strings.NewReader(input)) + gz, err := gzip.NewReader(b64) + if err != nil { + return "", err + } + + data, err := io.ReadAll(gz) + if err != nil { + return "", err + } + if err := gz.Close(); err != nil { + return "", err + } + return string(data), nil +} + +func getAllFiles(dir string) ([]string, error) { + var result []string + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + result = append(result, path) + } + return nil + }) + return result, err +} + +// --- Embed: Scoped Filesystem Mount --- + +// Embed wraps an fs.FS with a basedir for scoped access. +// All paths are relative to basedir. +type Embed struct { + basedir string + fsys fs.FS + embedFS *embed.FS // original embed.FS for type-safe access via EmbedFS() +} + +// Mount creates a scoped view of an fs.FS anchored at basedir. +// Works with embed.FS, os.DirFS, or any fs.FS implementation. +func Mount(fsys fs.FS, basedir string) (*Embed, error) { + s := &Embed{fsys: fsys, basedir: basedir} + + // If it's an embed.FS, keep a reference for EmbedFS() + if efs, ok := fsys.(embed.FS); ok { + s.embedFS = &efs + } + + // Verify the basedir exists + if _, err := s.ReadDir("."); err != nil { + return nil, err + } + return s, nil +} + +// MountEmbed creates a scoped view of an embed.FS. +func MountEmbed(efs embed.FS, basedir string) (*Embed, error) { + return Mount(efs, basedir) +} + +func (s *Embed) path(name string) string { + return filepath.ToSlash(filepath.Join(s.basedir, name)) +} + +// Open opens the named file for reading. +func (s *Embed) Open(name string) (fs.File, error) { + return s.fsys.Open(s.path(name)) +} + +// ReadDir reads the named directory. +func (s *Embed) ReadDir(name string) ([]fs.DirEntry, error) { + return fs.ReadDir(s.fsys, s.path(name)) +} + +// ReadFile reads the named file. +func (s *Embed) ReadFile(name string) ([]byte, error) { + return fs.ReadFile(s.fsys, s.path(name)) +} + +// ReadString reads the named file as a string. +func (s *Embed) ReadString(name string) (string, error) { + data, err := s.ReadFile(name) + if err != nil { + return "", err + } + return string(data), nil +} + +// Sub returns a new Embed anchored at a subdirectory within this mount. +func (s *Embed) Sub(subDir string) (*Embed, error) { + sub, err := fs.Sub(s.fsys, s.path(subDir)) + if err != nil { + return nil, err + } + return &Embed{fsys: sub, basedir: "."}, nil +} + +// FS returns the underlying fs.FS. +func (s *Embed) FS() fs.FS { + return s.fsys +} + +// EmbedFS returns the underlying embed.FS if mounted from one. +// Returns zero embed.FS if mounted from a non-embed source. +func (s *Embed) EmbedFS() embed.FS { + if s.embedFS != nil { + return *s.embedFS + } + return embed.FS{} +} + +// BaseDir returns the basedir this Embed is anchored at. +func (s *Embed) BaseDir() string { + return s.basedir +} + +// --- Template Extraction --- + +// ExtractOptions configures template extraction. +type ExtractOptions struct { + // TemplateFilters identifies template files by substring match. + // Default: [".tmpl"] + TemplateFilters []string + + // IgnoreFiles is a set of filenames to skip during extraction. + IgnoreFiles map[string]struct{} + + // RenameFiles maps original filenames to new names. + RenameFiles map[string]string +} + +// Extract copies a template directory from an fs.FS to targetDir, +// processing Go text/template in filenames and file contents. +// +// Files containing a template filter substring (default: ".tmpl") have +// their contents processed through text/template with the given data. +// The filter is stripped from the output filename. +// +// Directory and file names can contain Go template expressions: +// {{.Name}}/main.go → myproject/main.go +// +// Data can be any struct or map[string]string for template substitution. +func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error { + opt := ExtractOptions{ + TemplateFilters: []string{".tmpl"}, + IgnoreFiles: make(map[string]struct{}), + RenameFiles: make(map[string]string), + } + if len(opts) > 0 { + if len(opts[0].TemplateFilters) > 0 { + opt.TemplateFilters = opts[0].TemplateFilters + } + if opts[0].IgnoreFiles != nil { + opt.IgnoreFiles = opts[0].IgnoreFiles + } + if opts[0].RenameFiles != nil { + opt.RenameFiles = opts[0].RenameFiles + } + } + + // Ensure target directory exists + targetDir, err := filepath.Abs(targetDir) + if err != nil { + return err + } + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + // Categorise files + var dirs []string + var templateFiles []string + var standardFiles []string + + err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." { + return nil + } + if d.IsDir() { + dirs = append(dirs, path) + return nil + } + filename := filepath.Base(path) + if _, ignored := opt.IgnoreFiles[filename]; ignored { + return nil + } + if isTemplate(filename, opt.TemplateFilters) { + templateFiles = append(templateFiles, path) + } else { + standardFiles = append(standardFiles, path) + } + return nil + }) + if err != nil { + return err + } + + // Create directories (names may contain templates) + for _, dir := range dirs { + target := renderPath(filepath.Join(targetDir, dir), data) + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + } + + // Process template files + for _, path := range templateFiles { + tmpl, err := template.ParseFS(fsys, path) + if err != nil { + return err + } + + targetFile := renderPath(filepath.Join(targetDir, path), data) + + // Strip template filters from filename + dir := filepath.Dir(targetFile) + name := filepath.Base(targetFile) + for _, filter := range opt.TemplateFilters { + name = strings.ReplaceAll(name, filter, "") + } + if renamed := opt.RenameFiles[name]; renamed != "" { + name = renamed + } + targetFile = filepath.Join(dir, name) + + f, err := os.Create(targetFile) + if err != nil { + return err + } + if err := tmpl.Execute(f, data); err != nil { + f.Close() + return err + } + f.Close() + } + + // Copy standard files + for _, path := range standardFiles { + targetPath := path + name := filepath.Base(path) + if renamed := opt.RenameFiles[name]; renamed != "" { + targetPath = filepath.Join(filepath.Dir(path), renamed) + } + target := renderPath(filepath.Join(targetDir, targetPath), data) + if err := copyFile(fsys, path, target); err != nil { + return err + } + } + + return nil +} + +func isTemplate(filename string, filters []string) bool { + for _, f := range filters { + if strings.Contains(filename, f) { + return true + } + } + return false +} + +func renderPath(path string, data any) string { + if data == nil { + return path + } + tmpl, err := template.New("path").Parse(path) + if err != nil { + return path + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return path + } + return buf.String() +} + +func copyFile(fsys fs.FS, source, target string) error { + s, err := fsys.Open(source) + if err != nil { + return err + } + defer s.Close() + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + d, err := os.Create(target) + if err != nil { + return err + } + defer d.Close() + + _, err = io.Copy(d, s) + return err +} diff --git a/pkg/core/error.go b/pkg/core/error.go new file mode 100644 index 0000000..efdd594 --- /dev/null +++ b/pkg/core/error.go @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Structured errors, crash recovery, and reporting for the Core framework. +// Provides E() for error creation, Wrap()/WrapCode() for chaining, +// and Err for panic recovery and crash reporting. + +package core + +import ( + "encoding/json" + "errors" + "fmt" + "iter" + "maps" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "sync" + "time" +) + +// ErrSink is the shared interface for error reporting. +// Implemented by ErrLog (structured logging) and ErrPan (panic recovery). +type ErrSink interface { + Error(msg string, keyvals ...any) + Warn(msg string, keyvals ...any) +} + +var _ ErrSink = (*Log)(nil) + +// Err represents a structured error with operational context. +// It implements the error interface and supports unwrapping. +type Err struct { + Op string // Operation being performed (e.g., "user.Save") + Msg string // Human-readable message + Err error // Underlying error (optional) + Code string // Error code (optional, e.g., "VALIDATION_FAILED") +} + +// Error implements the error interface. +func (e *Err) Error() string { + var prefix string + if e.Op != "" { + prefix = e.Op + ": " + } + if e.Err != nil { + if e.Code != "" { + return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err) + } + return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err) + } + if e.Code != "" { + return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code) + } + return fmt.Sprintf("%s%s", prefix, e.Msg) +} + +// Unwrap returns the underlying error for use with errors.Is and errors.As. +func (e *Err) Unwrap() error { + return e.Err +} + +// --- Error Creation Functions --- + +// E creates a new Err with operation context. +// The underlying error can be nil for creating errors without a cause. +// +// Example: +// +// return log.E("user.Save", "failed to save user", err) +// return log.E("api.Call", "rate limited", nil) // No underlying cause +func E(op, msg string, err error) error { + return &Err{Op: op, Msg: msg, Err: err} +} + +// Wrap wraps an error with operation context. +// Returns nil if err is nil, to support conditional wrapping. +// Preserves error Code if the wrapped error is an *Err. +// +// Example: +// +// return log.Wrap(err, "db.Query", "database query failed") +func Wrap(err error, op, msg string) error { + if err == nil { + return nil + } + // Preserve Code from wrapped *Err + var logErr *Err + if As(err, &logErr) && logErr.Code != "" { + return &Err{Op: op, Msg: msg, Err: err, Code: logErr.Code} + } + return &Err{Op: op, Msg: msg, Err: err} +} + +// WrapCode wraps an error with operation context and error code. +// Returns nil only if both err is nil AND code is empty. +// Useful for API errors that need machine-readable codes. +// +// Example: +// +// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email") +func WrapCode(err error, code, op, msg string) error { + if err == nil && code == "" { + return nil + } + return &Err{Op: op, Msg: msg, Err: err, Code: code} +} + +// NewCode creates an error with just code and message (no underlying error). +// Useful for creating sentinel errors with codes. +// +// Example: +// +// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found") +func NewCode(code, msg string) error { + return &Err{Msg: msg, Code: code} +} + +// --- Standard Library Wrappers --- + +// Is reports whether any error in err's tree matches target. +// Wrapper around errors.Is for convenience. +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As finds the first error in err's tree that matches target. +// Wrapper around errors.As for convenience. +func As(err error, target any) bool { + return errors.As(err, target) +} + +// NewError creates a simple error with the given text. +// Wrapper around errors.New for convenience. +func NewError(text string) error { + return errors.New(text) +} + +// Join combines multiple errors into one. +// Wrapper around errors.Join for convenience. +func Join(errs ...error) error { + return errors.Join(errs...) +} + +// --- Error Introspection Helpers --- + +// Op extracts the operation name from an error. +// Returns empty string if the error is not an *Err. +func Op(err error) string { + var e *Err + if As(err, &e) { + return e.Op + } + return "" +} + +// ErrCode extracts the error code from an error. +// Returns empty string if the error is not an *Err or has no code. +func ErrCode(err error) string { + var e *Err + if As(err, &e) { + return e.Code + } + return "" +} + +// Message extracts the message from an error. +// Returns the error's Error() string if not an *Err. +func ErrorMessage(err error) string { + if err == nil { + return "" + } + var e *Err + if As(err, &e) { + return e.Msg + } + return err.Error() +} + +// Root returns the root cause of an error chain. +// Unwraps until no more wrapped errors are found. +func Root(err error) error { + if err == nil { + return nil + } + for { + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + return err + } + err = unwrapped + } +} + +// AllOps returns an iterator over all operational contexts in the error chain. +// It traverses the error tree using errors.Unwrap. +func AllOps(err error) iter.Seq[string] { + return func(yield func(string) bool) { + for err != nil { + if e, ok := err.(*Err); ok { + if e.Op != "" { + if !yield(e.Op) { + return + } + } + } + err = errors.Unwrap(err) + } + } +} + +// StackTrace returns the logical stack trace (chain of operations) from an error. +// It returns an empty slice if no operational context is found. +func StackTrace(err error) []string { + var stack []string + for op := range AllOps(err) { + stack = append(stack, op) + } + return stack +} + +// FormatStackTrace returns a pretty-printed logical stack trace. +func FormatStackTrace(err error) string { + var ops []string + for op := range AllOps(err) { + ops = append(ops, op) + } + if len(ops) == 0 { + return "" + } + return strings.Join(ops, " -> ") +} + +// --- ErrLog: Log-and-Return Error Helpers --- + +// ErrOpts holds shared options for error subsystems. +type ErrOpts struct { + Log *Log +} + +// ErrLog combines error creation with logging. +// Primary action: return an error. Secondary: log it. +type ErrLog struct { + *ErrOpts +} + +// NewErrLog creates an ErrLog (consumer convenience). +func NewErrLog(opts *ErrOpts) *ErrLog { + return &ErrLog{opts} +} + +func (el *ErrLog) log() *Log { + if el.ErrOpts != nil && el.Log != nil { + return el.Log + } + return defaultLog +} + +// Error logs at Error level and returns a wrapped error. +func (el *ErrLog) Error(err error, op, msg string) error { + if err == nil { + return nil + } + wrapped := Wrap(err, op, msg) + el.log().Error(msg, "op", op, "err", err) + return wrapped +} + +// Warn logs at Warn level and returns a wrapped error. +func (el *ErrLog) Warn(err error, op, msg string) error { + if err == nil { + return nil + } + wrapped := Wrap(err, op, msg) + el.log().Warn(msg, "op", op, "err", err) + return wrapped +} + +// Must logs and panics if err is not nil. +func (el *ErrLog) Must(err error, op, msg string) { + if err != nil { + el.log().Error(msg, "op", op, "err", err) + panic(Wrap(err, op, msg)) + } +} + +// --- Crash Recovery & Reporting --- + +// CrashReport represents a single crash event. +type CrashReport struct { + Timestamp time.Time `json:"timestamp"` + Error string `json:"error"` + Stack string `json:"stack"` + System CrashSystem `json:"system,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +// CrashSystem holds system information at crash time. +type CrashSystem struct { + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"go_version"` +} + +// ErrPan manages panic recovery and crash reporting. +type ErrPan struct { + filePath string + meta map[string]string + onCrash func(CrashReport) +} + +// PanOpts configures an ErrPan. +type PanOpts struct { + // FilePath is the crash report JSON output path. Empty disables file output. + FilePath string + // Meta is metadata included in every crash report. + Meta map[string]string + // OnCrash is a callback invoked on every crash. + OnCrash func(CrashReport) +} + +// NewErrPan creates an ErrPan (consumer convenience). +func NewErrPan(opts ...PanOpts) *ErrPan { + h := &ErrPan{} + if len(opts) > 0 { + o := opts[0] + h.filePath = o.FilePath + if o.Meta != nil { + h.meta = maps.Clone(o.Meta) + } + h.onCrash = o.OnCrash + } + return h +} + +// Recover captures a panic and creates a crash report. +// Use as: defer c.Error().Recover() +func (h *ErrPan) Recover() { + if h == nil { + return + } + r := recover() + if r == nil { + return + } + + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + + report := CrashReport{ + Timestamp: time.Now(), + Error: err.Error(), + Stack: string(debug.Stack()), + System: CrashSystem{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Version: runtime.Version(), + }, + Meta: maps.Clone(h.meta), + } + + if h.onCrash != nil { + h.onCrash(report) + } + + if h.filePath != "" { + h.appendReport(report) + } +} + +// SafeGo runs a function in a goroutine with panic recovery. +func (h *ErrPan) SafeGo(fn func()) { + go func() { + defer h.Recover() + fn() + }() +} + +// Reports returns the last n crash reports from the file. +func (h *ErrPan) Reports(n int) ([]CrashReport, error) { + if h.filePath == "" { + return nil, nil + } + crashMu.Lock() + defer crashMu.Unlock() + data, err := os.ReadFile(h.filePath) + if err != nil { + return nil, err + } + var reports []CrashReport + if err := json.Unmarshal(data, &reports); err != nil { + return nil, err + } + if n <= 0 || len(reports) <= n { + return reports, nil + } + return reports[len(reports)-n:], nil +} + +var crashMu sync.Mutex + +func (h *ErrPan) appendReport(report CrashReport) { + crashMu.Lock() + defer crashMu.Unlock() + + var reports []CrashReport + if data, err := os.ReadFile(h.filePath); err == nil { + if err := json.Unmarshal(data, &reports); err != nil { + reports = nil + } + } + + reports = append(reports, report) + if data, err := json.MarshalIndent(reports, "", " "); err == nil { + _ = os.MkdirAll(filepath.Dir(h.filePath), 0755) + _ = os.WriteFile(h.filePath, data, 0600) + } +} diff --git a/pkg/core/fs.go b/pkg/core/fs.go new file mode 100644 index 0000000..d977046 --- /dev/null +++ b/pkg/core/fs.go @@ -0,0 +1,269 @@ +// Sandboxed local filesystem I/O for the Core framework. +package core + +import ( + "fmt" + "io" + "io/fs" + "os" + "os/user" + "path/filepath" + "strings" + "time" +) + +// Fs is a sandboxed local filesystem backend. +type Fs struct { + root string +} + + +// path sanitises and returns the full path. +// Absolute paths are sandboxed under root (unless root is "/"). +func (m *Fs) path(p string) string { + if p == "" { + return m.root + } + + // If the path is relative and the medium is rooted at "/", + // treat it as relative to the current working directory. + // This makes io.Local behave more like the standard 'os' package. + if m.root == "/" && !filepath.IsAbs(p) { + cwd, _ := os.Getwd() + return filepath.Join(cwd, p) + } + + // Use filepath.Clean with a leading slash to resolve all .. and . internally + // before joining with the root. This is a standard way to sandbox paths. + clean := filepath.Clean("/" + p) + + // If root is "/", allow absolute paths through + if m.root == "/" { + return clean + } + + // Strip leading "/" so Join works correctly with root + return filepath.Join(m.root, clean[1:]) +} + +// validatePath ensures the path is within the sandbox, following symlinks if they exist. +func (m *Fs) validatePath(p string) (string, error) { + if m.root == "/" { + return m.path(p), nil + } + + // Split the cleaned path into components + parts := strings.Split(filepath.Clean("/"+p), string(os.PathSeparator)) + current := m.root + + for _, part := range parts { + if part == "" { + continue + } + + next := filepath.Join(current, part) + realNext, err := filepath.EvalSymlinks(next) + if err != nil { + if os.IsNotExist(err) { + // Part doesn't exist, we can't follow symlinks anymore. + // Since the path is already Cleaned and current is safe, + // appending a component to current will not escape. + current = next + continue + } + return "", err + } + + // Verify the resolved part is still within the root + rel, err := filepath.Rel(m.root, realNext) + if err != nil || strings.HasPrefix(rel, "..") { + // Security event: sandbox escape attempt + username := "unknown" + if u, err := user.Current(); err == nil { + username = u.Username + } + fmt.Fprintf(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s\n", + time.Now().Format(time.RFC3339), m.root, p, realNext, username) + return "", os.ErrPermission // Path escapes sandbox + } + current = realNext + } + + return current, nil +} + +// Read returns file contents as string. +func (m *Fs) Read(p string) (string, error) { + full, err := m.validatePath(p) + if err != nil { + return "", err + } + data, err := os.ReadFile(full) + if err != nil { + return "", err + } + return string(data), nil +} + +// Write saves content to file, creating parent directories as needed. +// Files are created with mode 0644. For sensitive files (keys, secrets), +// use WriteMode with 0600. +func (m *Fs) Write(p, content string) error { + return m.WriteMode(p, content, 0644) +} + +// WriteMode saves content to file with explicit permissions. +// Use 0600 for sensitive files (encryption output, private keys, auth hashes). +func (m *Fs) WriteMode(p, content string, mode os.FileMode) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return err + } + return os.WriteFile(full, []byte(content), mode) +} + +// EnsureDir creates directory if it doesn't exist. +func (m *Fs) EnsureDir(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + return os.MkdirAll(full, 0755) +} + +// IsDir returns true if path is a directory. +func (m *Fs) IsDir(p string) bool { + if p == "" { + return false + } + full, err := m.validatePath(p) + if err != nil { + return false + } + info, err := os.Stat(full) + return err == nil && info.IsDir() +} + +// IsFile returns true if path is a regular file. +func (m *Fs) IsFile(p string) bool { + if p == "" { + return false + } + full, err := m.validatePath(p) + if err != nil { + return false + } + info, err := os.Stat(full) + return err == nil && info.Mode().IsRegular() +} + +// Exists returns true if path exists. +func (m *Fs) Exists(p string) bool { + full, err := m.validatePath(p) + if err != nil { + return false + } + _, err = os.Stat(full) + return err == nil +} + +// List returns directory entries. +func (m *Fs) List(p string) ([]fs.DirEntry, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.ReadDir(full) +} + +// Stat returns file info. +func (m *Fs) Stat(p string) (fs.FileInfo, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.Stat(full) +} + +// Open opens the named file for reading. +func (m *Fs) Open(p string) (fs.File, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.Open(full) +} + +// Create creates or truncates the named file. +func (m *Fs) Create(p string) (io.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.Create(full) +} + +// Append opens the named file for appending, creating it if it doesn't exist. +func (m *Fs) Append(p string) (io.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) +} + +// ReadStream returns a reader for the file content. +func (m *Fs) ReadStream(path string) (io.ReadCloser, error) { + return m.Open(path) +} + +// WriteStream returns a writer for the file content. +func (m *Fs) WriteStream(path string) (io.WriteCloser, error) { + return m.Create(path) +} + +// Delete removes a file or empty directory. +func (m *Fs) Delete(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if full == "/" || full == os.Getenv("HOME") { + return E("core.Delete", "refusing to delete protected path: "+full, nil) + } + return os.Remove(full) +} + +// DeleteAll removes a file or directory recursively. +func (m *Fs) DeleteAll(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if full == "/" || full == os.Getenv("HOME") { + return E("core.DeleteAll", "refusing to delete protected path: "+full, nil) + } + return os.RemoveAll(full) +} + +// Rename moves a file or directory. +func (m *Fs) Rename(oldPath, newPath string) error { + oldFull, err := m.validatePath(oldPath) + if err != nil { + return err + } + newFull, err := m.validatePath(newPath) + if err != nil { + return err + } + return os.Rename(oldFull, newFull) +} diff --git a/pkg/core/i18n.go b/pkg/core/i18n.go new file mode 100644 index 0000000..e8ff836 --- /dev/null +++ b/pkg/core/i18n.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Internationalisation for the Core framework. +// I18n collects locale mounts from services and delegates +// translation to a registered Translator implementation (e.g., go-i18n). + +package core + +import ( + "sync" +) + +// Translator defines the interface for translation services. +// Implemented by go-i18n's Srv. +type Translator interface { + // T translates a message by its ID with optional arguments. + T(messageID string, args ...any) string + // SetLanguage sets the active language (BCP47 tag, e.g., "en-GB", "de"). + SetLanguage(lang string) error + // Language returns the current language code. + Language() string + // AvailableLanguages returns all loaded language codes. + AvailableLanguages() []string +} + +// LocaleProvider is implemented by services that ship their own translation files. +// Core discovers this interface during service registration and collects the +// locale mounts. The i18n service loads them during startup. +// +// Usage in a service package: +// +// //go:embed locales +// var localeFS embed.FS +// +// func (s *MyService) Locales() *Embed { +// m, _ := Mount(localeFS, "locales") +// return m +// } +type LocaleProvider interface { + Locales() *Embed +} + +// I18n manages locale collection and translation dispatch. +type I18n struct { + mu sync.RWMutex + locales []*Embed // collected from LocaleProvider services + translator Translator // registered implementation (nil until set) +} + + +// AddLocales adds locale mounts (called during service registration). +func (i *I18n) AddLocales(mounts ...*Embed) { + i.mu.Lock() + i.locales = append(i.locales, mounts...) + i.mu.Unlock() +} + +// Locales returns all collected locale mounts. +func (i *I18n) Locales() []*Embed { + i.mu.RLock() + out := make([]*Embed, len(i.locales)) + copy(out, i.locales) + i.mu.RUnlock() + return out +} + +// SetTranslator registers the translation implementation. +// Called by go-i18n's Srv during startup. +func (i *I18n) SetTranslator(t Translator) { + i.mu.Lock() + i.translator = t + i.mu.Unlock() +} + +// Translator returns the registered translation implementation, or nil. +func (i *I18n) Translator() Translator { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + return t +} + +// T translates a message. Returns the key as-is if no translator is registered. +func (i *I18n) T(messageID string, args ...any) string { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.T(messageID, args...) + } + return messageID +} + +// SetLanguage sets the active language. No-op if no translator is registered. +func (i *I18n) SetLanguage(lang string) error { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.SetLanguage(lang) + } + return nil +} + +// Language returns the current language code, or "en" if no translator. +func (i *I18n) Language() string { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.Language() + } + return "en" +} + +// AvailableLanguages returns all loaded language codes. +func (i *I18n) AvailableLanguages() []string { + i.mu.RLock() + t := i.translator + i.mu.RUnlock() + if t != nil { + return t.AvailableLanguages() + } + return []string{"en"} +} diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go deleted file mode 100644 index 036b4b2..0000000 --- a/pkg/core/interfaces.go +++ /dev/null @@ -1,162 +0,0 @@ -package core - -import ( - "context" - "embed" - goio "io" - "slices" - "sync" - "sync/atomic" -) - -// This file defines the public API contracts (interfaces) for the services -// in the Core framework. Services depend on these interfaces, not on -// concrete implementations. - -// Contract specifies the operational guarantees that the Core and its services must adhere to. -// This is used for configuring panic handling and other resilience features. -type Contract struct { - // DontPanic, if true, instructs the Core to recover from panics and return an error instead. - DontPanic bool - // DisableLogging, if true, disables all logging from the Core and its services. - DisableLogging bool -} - -// Features provides a way to check if a feature is enabled. -// This is used for feature flagging and conditional logic. -type Features struct { - // Flags is a list of enabled feature flags. - Flags []string -} - -// IsEnabled returns true if the given feature is enabled. -func (f *Features) IsEnabled(feature string) bool { - return slices.Contains(f.Flags, feature) -} - -// Option is a function that configures the Core. -// This is used to apply settings and register services during initialization. -type Option func(*Core) error - -// Message is the interface for all messages that can be sent through the Core's IPC system. -// Any struct can be a message, allowing for structured data to be passed between services. -// Used with ACTION for fire-and-forget broadcasts. -type Message any - -// Query is the interface for read-only requests that return data. -// Used with QUERY (first responder) or QUERYALL (all responders). -type Query any - -// Task is the interface for requests that perform side effects. -// Used with PERFORM (first responder executes). -type Task any - -// TaskWithID is an optional interface for tasks that need to know their assigned ID. -// This is useful for tasks that want to report progress back to the frontend. -type TaskWithID interface { - Task - SetTaskID(id string) - GetTaskID() string -} - -// QueryHandler handles Query requests. Returns (result, handled, error). -// If handled is false, the query will be passed to the next handler. -type QueryHandler func(*Core, Query) (any, bool, error) - -// TaskHandler handles Task requests. Returns (result, handled, error). -// If handled is false, the task will be passed to the next handler. -type TaskHandler func(*Core, Task) (any, bool, error) - -// Startable is an interface for services that need to perform initialization. -type Startable interface { - OnStartup(ctx context.Context) error -} - -// Stoppable is an interface for services that need to perform cleanup. -type Stoppable interface { - OnShutdown(ctx context.Context) error -} - -// Core is the central application object that manages services, assets, and communication. -type Core struct { - App any // GUI runtime (e.g., Wails App) - set by WithApp option - assets embed.FS - Features *Features - svc *serviceManager - bus *messageBus - - taskIDCounter atomic.Uint64 - wg sync.WaitGroup - shutdown atomic.Bool -} - -// Config provides access to application configuration. -type Config interface { - // Get retrieves a configuration value by key and stores it in the 'out' variable. - Get(key string, out any) error - // Set stores a configuration value by key. - Set(key string, v any) error -} - -// WindowOption is an interface for applying configuration options to a window. -type WindowOption interface { - Apply(any) -} - -// Display provides access to windowing and visual elements. -type Display interface { - // OpenWindow creates a new window with the given options. - OpenWindow(opts ...WindowOption) error -} - -// Workspace provides management for encrypted user workspaces. -type Workspace interface { - // CreateWorkspace creates a new encrypted workspace. - CreateWorkspace(identifier, password string) (string, error) - // SwitchWorkspace changes the active workspace. - SwitchWorkspace(name string) error - // WorkspaceFileGet retrieves the content of a file from the active workspace. - WorkspaceFileGet(filename string) (string, error) - // WorkspaceFileSet saves content to a file in the active workspace. - WorkspaceFileSet(filename, content string) error -} - -// Crypt provides PGP-based encryption, signing, and key management. -type Crypt interface { - // CreateKeyPair generates a new PGP keypair. - CreateKeyPair(name, passphrase string) (string, error) - // EncryptPGP encrypts data for a recipient. - EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) - // DecryptPGP decrypts a PGP message. - DecryptPGP(recipientPath, message, passphrase string, opts ...any) (string, error) -} - -// ActionServiceStartup is a message sent when the application's services are starting up. -// This provides a hook for services to perform initialization tasks. -type ActionServiceStartup struct{} - -// ActionServiceShutdown is a message sent when the application is shutting down. -// This allows services to perform cleanup tasks, such as saving state or closing resources. -type ActionServiceShutdown struct{} - -// ActionTaskStarted is a message sent when a background task has started. -type ActionTaskStarted struct { - TaskID string - Task Task -} - -// ActionTaskProgress is a message sent when a task has progress updates. -type ActionTaskProgress struct { - TaskID string - Task Task - Progress float64 // 0.0 to 1.0 - Message string -} - -// ActionTaskCompleted is a message sent when a task has completed. -type ActionTaskCompleted struct { - TaskID string - Task Task - Result any - Error error -} diff --git a/pkg/core/ipc.go b/pkg/core/ipc.go new file mode 100644 index 0000000..aa66d0e --- /dev/null +++ b/pkg/core/ipc.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Message bus for the Core framework. +// Dispatches actions (fire-and-forget), queries (first responder), +// and tasks (first executor) between registered handlers. + +package core + +import ( + "errors" + "slices" + "sync" +) + +// Ipc holds IPC dispatch data. +type Ipc struct { + ipcMu sync.RWMutex + ipcHandlers []func(*Core, Message) error + + queryMu sync.RWMutex + queryHandlers []QueryHandler + + taskMu sync.RWMutex + taskHandlers []TaskHandler +} + +func (c *Core) Action(msg Message) error { + c.ipc.ipcMu.RLock() + handlers := slices.Clone(c.ipc.ipcHandlers) + c.ipc.ipcMu.RUnlock() + + var agg error + for _, h := range handlers { + if err := h(c, msg); err != nil { + agg = errors.Join(agg, err) + } + } + return agg +} + +func (c *Core) Query(q Query) (any, bool, error) { + c.ipc.queryMu.RLock() + handlers := slices.Clone(c.ipc.queryHandlers) + c.ipc.queryMu.RUnlock() + + for _, h := range handlers { + result, handled, err := h(c, q) + if handled { + return result, true, err + } + } + return nil, false, nil +} + +func (c *Core) QueryAll(q Query) ([]any, error) { + c.ipc.queryMu.RLock() + handlers := slices.Clone(c.ipc.queryHandlers) + c.ipc.queryMu.RUnlock() + + var results []any + var agg error + for _, h := range handlers { + result, handled, err := h(c, q) + if err != nil { + agg = errors.Join(agg, err) + } + if handled && result != nil { + results = append(results, result) + } + } + return results, agg +} + +func (c *Core) RegisterQuery(handler QueryHandler) { + c.ipc.queryMu.Lock() + c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler) + c.ipc.queryMu.Unlock() +} diff --git a/pkg/core/lock.go b/pkg/core/lock.go new file mode 100644 index 0000000..4c085b3 --- /dev/null +++ b/pkg/core/lock.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Synchronisation, locking, and lifecycle snapshots for the Core framework. + +package core + +import ( + "slices" + "sync" +) + +// package-level mutex infrastructure +var ( + lockMu sync.Mutex + lockMap = make(map[string]*sync.RWMutex) +) + +// Lock is the DTO for a named mutex. +type Lock struct { + Name string + Mu *sync.RWMutex +} + +// Lock returns a named Lock, creating the mutex if needed. +func (c *Core) Lock(name string) *Lock { + lockMu.Lock() + m, ok := lockMap[name] + if !ok { + m = &sync.RWMutex{} + lockMap[name] = m + } + lockMu.Unlock() + return &Lock{Name: name, Mu: m} +} + +// LockEnable marks that the service lock should be applied after initialisation. +func (c *Core) LockEnable(name ...string) { + n := "srv" + if len(name) > 0 { + n = name[0] + } + c.Lock(n).Mu.Lock() + defer c.Lock(n).Mu.Unlock() + c.srv.lockEnabled = true +} + +// LockApply activates the service lock if it was enabled. +func (c *Core) LockApply(name ...string) { + n := "srv" + if len(name) > 0 { + n = name[0] + } + c.Lock(n).Mu.Lock() + defer c.Lock(n).Mu.Unlock() + if c.srv.lockEnabled { + c.srv.locked = true + } +} + +// Startables returns a snapshot of services implementing Startable. +func (c *Core) Startables() []Startable { + c.Lock("srv").Mu.RLock() + out := slices.Clone(c.srv.startables) + c.Lock("srv").Mu.RUnlock() + return out +} + +// Stoppables returns a snapshot of services implementing Stoppable. +func (c *Core) Stoppables() []Stoppable { + c.Lock("srv").Mu.RLock() + out := slices.Clone(c.srv.stoppables) + c.Lock("srv").Mu.RUnlock() + return out +} diff --git a/pkg/core/log.go b/pkg/core/log.go new file mode 100644 index 0000000..276917b --- /dev/null +++ b/pkg/core/log.go @@ -0,0 +1,397 @@ +// Structured logging for the Core framework. +// +// core.SetLevel(core.LevelDebug) +// core.Info("server started", "port", 8080) +// core.Error("failed to connect", "err", err) +package core + +import ( + "fmt" + goio "io" + "os" + "os/user" + "slices" + "sync" + "time" +) + +// Level defines logging verbosity. +type Level int + +// Logging level constants ordered by increasing verbosity. +const ( + // LevelQuiet suppresses all log output. + LevelQuiet Level = iota + // LevelError shows only error messages. + LevelError + // LevelWarn shows warnings and errors. + LevelWarn + // LevelInfo shows informational messages, warnings, and errors. + LevelInfo + // LevelDebug shows all messages including debug details. + LevelDebug +) + +// String returns the level name. +func (l Level) String() string { + switch l { + case LevelQuiet: + return "quiet" + case LevelError: + return "error" + case LevelWarn: + return "warn" + case LevelInfo: + return "info" + case LevelDebug: + return "debug" + default: + return "unknown" + } +} + +// Log provides structured logging. +type Log struct { + mu sync.RWMutex + level Level + output goio.Writer + + // RedactKeys is a list of keys whose values should be masked in logs. + redactKeys []string + + // Style functions for formatting (can be overridden) + StyleTimestamp func(string) string + StyleDebug func(string) string + StyleInfo func(string) string + StyleWarn func(string) string + StyleError func(string) string + StyleSecurity func(string) string +} + +// RotationLogOpts defines the log rotation and retention policy. +type RotationLogOpts struct { + // Filename is the log file path. If empty, rotation is disabled. + Filename string + + // MaxSize is the maximum size of the log file in megabytes before it gets rotated. + // It defaults to 100 megabytes. + MaxSize int + + // MaxAge is the maximum number of days to retain old log files based on their + // file modification time. It defaults to 28 days. + // Note: set to a negative value to disable age-based retention. + MaxAge int + + // MaxBackups is the maximum number of old log files to retain. + // It defaults to 5 backups. + MaxBackups int + + // Compress determines if the rotated log files should be compressed using gzip. + // It defaults to true. + Compress bool +} + +// LogOpts configures a Log. +type LogOpts struct { + Level Level + // Output is the destination for log messages. If Rotation is provided, + // Output is ignored and logs are written to the rotating file instead. + Output goio.Writer + // Rotation enables log rotation to file. If provided, Filename must be set. + Rotation *RotationLogOpts + // RedactKeys is a list of keys whose values should be masked in logs. + RedactKeys []string +} + +// RotationWriterFactory creates a rotating writer from options. +// Set this to enable log rotation (provided by core/go-io integration). +var RotationWriterFactory func(RotationLogOpts) goio.WriteCloser + +// New creates a new Log with the given options. +func NewLog(opts LogOpts) *Log { + output := opts.Output + if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil { + output = RotationWriterFactory(*opts.Rotation) + } + if output == nil { + output = os.Stderr + } + + return &Log{ + level: opts.Level, + output: output, + redactKeys: slices.Clone(opts.RedactKeys), + StyleTimestamp: identity, + StyleDebug: identity, + StyleInfo: identity, + StyleWarn: identity, + StyleError: identity, + StyleSecurity: identity, + } +} + +func identity(s string) string { return s } + +// SetLevel changes the log level. +func (l *Log) SetLevel(level Level) { + l.mu.Lock() + l.level = level + l.mu.Unlock() +} + +// Level returns the current log level. +func (l *Log) Level() Level { + l.mu.RLock() + defer l.mu.RUnlock() + return l.level +} + +// SetOutput changes the output writer. +func (l *Log) SetOutput(w goio.Writer) { + l.mu.Lock() + l.output = w + l.mu.Unlock() +} + +// SetRedactKeys sets the keys to be redacted. +func (l *Log) SetRedactKeys(keys ...string) { + l.mu.Lock() + l.redactKeys = slices.Clone(keys) + l.mu.Unlock() +} + +func (l *Log) shouldLog(level Level) bool { + l.mu.RLock() + defer l.mu.RUnlock() + return level <= l.level +} + +func (l *Log) log(level Level, prefix, msg string, keyvals ...any) { + l.mu.RLock() + output := l.output + styleTimestamp := l.StyleTimestamp + redactKeys := l.redactKeys + l.mu.RUnlock() + + timestamp := styleTimestamp(time.Now().Format("15:04:05")) + + // Copy keyvals to avoid mutating the caller's slice + keyvals = append([]any(nil), keyvals...) + + // Automatically extract context from error if present in keyvals + origLen := len(keyvals) + for i := 0; i < origLen; i += 2 { + if i+1 < origLen { + if err, ok := keyvals[i+1].(error); ok { + if op := Op(err); op != "" { + // Check if op is already in keyvals + hasOp := false + for j := 0; j < len(keyvals); j += 2 { + if k, ok := keyvals[j].(string); ok && k == "op" { + hasOp = true + break + } + } + if !hasOp { + keyvals = append(keyvals, "op", op) + } + } + if stack := FormatStackTrace(err); stack != "" { + // Check if stack is already in keyvals + hasStack := false + for j := 0; j < len(keyvals); j += 2 { + if k, ok := keyvals[j].(string); ok && k == "stack" { + hasStack = true + break + } + } + if !hasStack { + keyvals = append(keyvals, "stack", stack) + } + } + } + } + } + + // Format key-value pairs + var kvStr string + if len(keyvals) > 0 { + kvStr = " " + for i := 0; i < len(keyvals); i += 2 { + if i > 0 { + kvStr += " " + } + key := keyvals[i] + var val any + if i+1 < len(keyvals) { + val = keyvals[i+1] + } + + // Redaction logic + keyStr := fmt.Sprintf("%v", key) + if slices.Contains(redactKeys, keyStr) { + val = "[REDACTED]" + } + + // Secure formatting to prevent log injection + if s, ok := val.(string); ok { + kvStr += fmt.Sprintf("%v=%q", key, s) + } else { + kvStr += fmt.Sprintf("%v=%v", key, val) + } + } + } + + _, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, kvStr) +} + +// Debug logs a debug message with optional key-value pairs. +func (l *Log) Debug(msg string, keyvals ...any) { + if l.shouldLog(LevelDebug) { + l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...) + } +} + +// Info logs an info message with optional key-value pairs. +func (l *Log) Info(msg string, keyvals ...any) { + if l.shouldLog(LevelInfo) { + l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...) + } +} + +// Warn logs a warning message with optional key-value pairs. +func (l *Log) Warn(msg string, keyvals ...any) { + if l.shouldLog(LevelWarn) { + l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...) + } +} + +// Error logs an error message with optional key-value pairs. +func (l *Log) Error(msg string, keyvals ...any) { + if l.shouldLog(LevelError) { + l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...) + } +} + +// Security logs a security event with optional key-value pairs. +// It uses LevelError to ensure security events are visible even in restrictive +// log configurations. +func (l *Log) Security(msg string, keyvals ...any) { + if l.shouldLog(LevelError) { + l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...) + } +} + +// Username returns the current system username. +// It uses os/user for reliability and falls back to environment variables. +func Username() string { + if u, err := user.Current(); err == nil { + return u.Username + } + // Fallback for environments where user lookup might fail + if u := os.Getenv("USER"); u != "" { + return u + } + return os.Getenv("USERNAME") +} + +// --- Default logger --- + +var defaultLog = NewLog(LogOpts{Level: LevelInfo}) + +// Default returns the default logger. +func Default() *Log { + return defaultLog +} + +// SetDefault sets the default logger. +func SetDefault(l *Log) { + defaultLog = l +} + +// SetLevel sets the default logger's level. +func SetLevel(level Level) { + defaultLog.SetLevel(level) +} + +// SetRedactKeys sets the default logger's redaction keys. +func SetRedactKeys(keys ...string) { + defaultLog.SetRedactKeys(keys...) +} + +// Debug logs to the default logger. +func Debug(msg string, keyvals ...any) { + defaultLog.Debug(msg, keyvals...) +} + +// Info logs to the default logger. +func Info(msg string, keyvals ...any) { + defaultLog.Info(msg, keyvals...) +} + +// Warn logs to the default logger. +func Warn(msg string, keyvals ...any) { + defaultLog.Warn(msg, keyvals...) +} + +// Error logs to the default logger. +func Error(msg string, keyvals ...any) { + defaultLog.Error(msg, keyvals...) +} + +// Security logs to the default logger. +func Security(msg string, keyvals ...any) { + defaultLog.Security(msg, keyvals...) +} + +// --- LogErr: Error-Aware Logger --- + +// LogErr logs structured information extracted from errors. +// Primary action: log. Secondary: extract error context. +type LogErr struct { + log *Log +} + +// NewLogErr creates a LogErr bound to the given logger. +func NewLogErr(log *Log) *LogErr { + return &LogErr{log: log} +} + +// Log extracts context from an Err and logs it at Error level. +func (le *LogErr) Log(err error) { + if err == nil { + return + } + le.log.Error(ErrorMessage(err), "op", Op(err), "code", ErrCode(err), "stack", FormatStackTrace(err)) +} + +// --- LogPan: Panic-Aware Logger --- + +// LogPan logs panic context without crash file management. +// Primary action: log. Secondary: recover panics. +type LogPan struct { + log *Log +} + +// NewLogPan creates a LogPan bound to the given logger. +func NewLogPan(log *Log) *LogPan { + return &LogPan{log: log} +} + +// Recover captures a panic and logs it. Does not write crash files. +// Use as: defer core.NewLogPan(logger).Recover() +func (lp *LogPan) Recover() { + r := recover() + if r == nil { + return + } + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + lp.log.Error("panic recovered", + "err", err, + "op", Op(err), + "stack", FormatStackTrace(err), + ) +} diff --git a/pkg/core/message_bus.go b/pkg/core/message_bus.go deleted file mode 100644 index 4f81e77..0000000 --- a/pkg/core/message_bus.go +++ /dev/null @@ -1,120 +0,0 @@ -package core - -import ( - "errors" - "slices" - "sync" -) - -// messageBus owns the IPC action, query, and task dispatch. -// It is an unexported component used internally by Core. -type messageBus struct { - core *Core - - ipcMu sync.RWMutex - ipcHandlers []func(*Core, Message) error - - queryMu sync.RWMutex - queryHandlers []QueryHandler - - taskMu sync.RWMutex - taskHandlers []TaskHandler -} - -// newMessageBus creates an empty message bus bound to the given Core. -func newMessageBus(c *Core) *messageBus { - return &messageBus{core: c} -} - -// action dispatches a message to all registered IPC handlers. -func (b *messageBus) action(msg Message) error { - b.ipcMu.RLock() - handlers := slices.Clone(b.ipcHandlers) - b.ipcMu.RUnlock() - - var agg error - for _, h := range handlers { - if err := h(b.core, msg); err != nil { - agg = errors.Join(agg, err) - } - } - return agg -} - -// registerAction adds a single IPC handler. -func (b *messageBus) registerAction(handler func(*Core, Message) error) { - b.ipcMu.Lock() - b.ipcHandlers = append(b.ipcHandlers, handler) - b.ipcMu.Unlock() -} - -// registerActions adds multiple IPC handlers. -func (b *messageBus) registerActions(handlers ...func(*Core, Message) error) { - b.ipcMu.Lock() - b.ipcHandlers = append(b.ipcHandlers, handlers...) - b.ipcMu.Unlock() -} - -// query dispatches a query to handlers until one responds. -func (b *messageBus) query(q Query) (any, bool, error) { - b.queryMu.RLock() - handlers := slices.Clone(b.queryHandlers) - b.queryMu.RUnlock() - - for _, h := range handlers { - result, handled, err := h(b.core, q) - if handled { - return result, true, err - } - } - return nil, false, nil -} - -// queryAll dispatches a query to all handlers and collects all responses. -func (b *messageBus) queryAll(q Query) ([]any, error) { - b.queryMu.RLock() - handlers := slices.Clone(b.queryHandlers) - b.queryMu.RUnlock() - - var results []any - var agg error - for _, h := range handlers { - result, handled, err := h(b.core, q) - if err != nil { - agg = errors.Join(agg, err) - } - if handled && result != nil { - results = append(results, result) - } - } - return results, agg -} - -// registerQuery adds a query handler. -func (b *messageBus) registerQuery(handler QueryHandler) { - b.queryMu.Lock() - b.queryHandlers = append(b.queryHandlers, handler) - b.queryMu.Unlock() -} - -// perform dispatches a task to handlers until one executes it. -func (b *messageBus) perform(t Task) (any, bool, error) { - b.taskMu.RLock() - handlers := slices.Clone(b.taskHandlers) - b.taskMu.RUnlock() - - for _, h := range handlers { - result, handled, err := h(b.core, t) - if handled { - return result, true, err - } - } - return nil, false, nil -} - -// registerTask adds a task handler. -func (b *messageBus) registerTask(handler TaskHandler) { - b.taskMu.Lock() - b.taskHandlers = append(b.taskHandlers, handler) - b.taskMu.Unlock() -} diff --git a/pkg/core/message_bus_test.go b/pkg/core/message_bus_test.go deleted file mode 100644 index 493c265..0000000 --- a/pkg/core/message_bus_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package core - -import ( - "errors" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMessageBus_Action_Good(t *testing.T) { - c, _ := New() - - var received []Message - c.bus.registerAction(func(_ *Core, msg Message) error { - received = append(received, msg) - return nil - }) - c.bus.registerAction(func(_ *Core, msg Message) error { - received = append(received, msg) - return nil - }) - - err := c.bus.action("hello") - assert.NoError(t, err) - assert.Len(t, received, 2) -} - -func TestMessageBus_Action_Bad(t *testing.T) { - c, _ := New() - - err1 := errors.New("handler1 failed") - err2 := errors.New("handler2 failed") - - c.bus.registerAction(func(_ *Core, msg Message) error { return err1 }) - c.bus.registerAction(func(_ *Core, msg Message) error { return nil }) - c.bus.registerAction(func(_ *Core, msg Message) error { return err2 }) - - err := c.bus.action("test") - assert.Error(t, err) - assert.ErrorIs(t, err, err1) - assert.ErrorIs(t, err, err2) -} - -func TestMessageBus_RegisterAction_Good(t *testing.T) { - c, _ := New() - - var coreRef *Core - c.bus.registerAction(func(core *Core, msg Message) error { - coreRef = core - return nil - }) - - _ = c.bus.action(nil) - assert.Same(t, c, coreRef, "handler should receive the Core reference") -} - -func TestMessageBus_Query_Good(t *testing.T) { - c, _ := New() - - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return "first", true, nil - }) - - result, handled, err := c.bus.query(TestQuery{Value: "test"}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "first", result) -} - -func TestMessageBus_QueryAll_Good(t *testing.T) { - c, _ := New() - - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return "a", true, nil - }) - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return nil, false, nil // skips - }) - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { - return "b", true, nil - }) - - results, err := c.bus.queryAll(TestQuery{}) - assert.NoError(t, err) - assert.Equal(t, []any{"a", "b"}, results) -} - -func TestMessageBus_Perform_Good(t *testing.T) { - c, _ := New() - - c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) { - return "done", true, nil - }) - - result, handled, err := c.bus.perform(TestTask{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "done", result) -} - -func TestMessageBus_ConcurrentAccess_Good(t *testing.T) { - c, _ := New() - - var wg sync.WaitGroup - const goroutines = 20 - - // Concurrent register + dispatch - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.bus.registerAction(func(_ *Core, msg Message) error { return nil }) - }() - go func() { - defer wg.Done() - _ = c.bus.action("ping") - }() - } - - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.bus.registerQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil }) - }() - go func() { - defer wg.Done() - _, _ = c.bus.queryAll(TestQuery{}) - }() - } - - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.bus.registerTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil }) - }() - go func() { - defer wg.Done() - _, _, _ = c.bus.perform(TestTask{}) - }() - } - - wg.Wait() -} - -func TestMessageBus_Action_NoHandlers(t *testing.T) { - c, _ := New() - // Should not error if no handlers are registered - err := c.bus.action("no one listening") - assert.NoError(t, err) -} - -func TestMessageBus_Query_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.bus.query(TestQuery{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestMessageBus_QueryAll_NoHandlers(t *testing.T) { - c, _ := New() - results, err := c.bus.queryAll(TestQuery{}) - assert.NoError(t, err) - assert.Empty(t, results) -} - -func TestMessageBus_Perform_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.bus.perform(TestTask{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} diff --git a/pkg/core/runtime.go b/pkg/core/runtime.go new file mode 100644 index 0000000..edfa068 --- /dev/null +++ b/pkg/core/runtime.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Runtime helpers for the Core framework. +// ServiceRuntime is embedded by consumer services. +// Runtime is the GUI binding container (e.g., Wails). + +package core + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" +) + +// --- ServiceRuntime (embedded by consumer services) --- + +// ServiceRuntime is embedded in services to provide access to the Core and typed options. +type ServiceRuntime[T any] struct { + core *Core + opts T +} + +// NewServiceRuntime creates a ServiceRuntime for a service constructor. +func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] { + return &ServiceRuntime[T]{core: c, opts: opts} +} + +func (r *ServiceRuntime[T]) Core() *Core { return r.core } +func (r *ServiceRuntime[T]) Opts() T { return r.opts } +func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() } + +// --- Lifecycle --- + +// ServiceStartup runs the startup lifecycle for all registered services. +func (c *Core) ServiceStartup(ctx context.Context, options any) error { + startables := c.Startables() + var agg error + for _, s := range startables { + if err := ctx.Err(); err != nil { + return errors.Join(agg, err) + } + if err := s.OnStartup(ctx); err != nil { + agg = errors.Join(agg, err) + } + } + if err := c.ACTION(ActionServiceStartup{}); err != nil { + agg = errors.Join(agg, err) + } + return agg +} + +// ServiceShutdown runs the shutdown lifecycle for all registered services. +func (c *Core) ServiceShutdown(ctx context.Context) error { + c.shutdown.Store(true) + var agg error + if err := c.ACTION(ActionServiceShutdown{}); err != nil { + agg = errors.Join(agg, err) + } + stoppables := c.Stoppables() + for _, s := range slices.Backward(stoppables) { + if err := ctx.Err(); err != nil { + agg = errors.Join(agg, err) + break + } + if err := s.OnShutdown(ctx); err != nil { + agg = errors.Join(agg, err) + } + } + done := make(chan struct{}) + go func() { + c.wg.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + agg = errors.Join(agg, ctx.Err()) + } + return agg +} + +// --- Runtime DTO (GUI binding) --- + +// Runtime is the container for GUI runtimes (e.g., Wails). +type Runtime struct { + app any + Core *Core +} + +// ServiceFactory defines a function that creates a service instance. +type ServiceFactory func() (any, error) + +// NewWithFactories creates a Runtime with the provided service factories. +func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, error) { + coreOpts := []Option{WithApp(app)} + names := slices.Sorted(maps.Keys(factories)) + for _, name := range names { + factory := factories[name] + if factory == nil { + return nil, E("core.NewWithFactories", fmt.Sprintf("factory is nil for service %q", name), nil) + } + svc, err := factory() + if err != nil { + return nil, E("core.NewWithFactories", fmt.Sprintf("failed to create service %q", name), err) + } + svcCopy := svc + coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil })) + } + coreInstance, err := New(coreOpts...) + if err != nil { + return nil, err + } + return &Runtime{app: app, Core: coreInstance}, nil +} + +// NewRuntime creates a Runtime with no custom services. +func NewRuntime(app any) (*Runtime, error) { + return NewWithFactories(app, map[string]ServiceFactory{}) +} + +func (r *Runtime) ServiceName() string { return "Core" } +func (r *Runtime) ServiceStartup(ctx context.Context, options any) error { + return r.Core.ServiceStartup(ctx, options) +} +func (r *Runtime) ServiceShutdown(ctx context.Context) error { + if r.Core != nil { + return r.Core.ServiceShutdown(ctx) + } + return nil +} diff --git a/pkg/core/runtime_pkg.go b/pkg/core/runtime_pkg.go deleted file mode 100644 index 7071e9c..0000000 --- a/pkg/core/runtime_pkg.go +++ /dev/null @@ -1,113 +0,0 @@ -package core - -import ( - "context" - "fmt" - "maps" - "slices" -) - -// ServiceRuntime is a helper struct embedded in services to provide access to the core application. -// It is generic and can be parameterized with a service-specific options struct. -type ServiceRuntime[T any] struct { - core *Core - opts T -} - -// NewServiceRuntime creates a new ServiceRuntime instance for a service. -// This is typically called by a service's constructor. -func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] { - return &ServiceRuntime[T]{ - core: c, - opts: opts, - } -} - -// Core returns the central core instance, providing access to all registered services. -func (r *ServiceRuntime[T]) Core() *Core { - return r.core -} - -// Opts returns the service-specific options. -func (r *ServiceRuntime[T]) Opts() T { - return r.opts -} - -// Config returns the registered Config service from the core application. -// This is a convenience method for accessing the application's configuration. -func (r *ServiceRuntime[T]) Config() Config { - return r.core.Config() -} - -// Runtime is the container that holds all instantiated services. -// Its fields are the concrete types, allowing GUI runtimes to bind them directly. -// This struct is the primary entry point for the application. -type Runtime struct { - app any // GUI runtime (e.g., Wails App) - Core *Core -} - -// ServiceFactory defines a function that creates a service instance. -// This is used to decouple the service creation from the runtime initialization. -type ServiceFactory func() (any, error) - -// NewWithFactories creates a new Runtime instance using the provided service factories. -// This is the most flexible way to create a new Runtime, as it allows for -// the registration of any number of services. -func NewWithFactories(app any, factories map[string]ServiceFactory) (*Runtime, error) { - coreOpts := []Option{ - WithApp(app), - } - - names := slices.Sorted(maps.Keys(factories)) - - for _, name := range names { - factory := factories[name] - if factory == nil { - return nil, fmt.Errorf("failed to create service %s: factory is nil", name) - } - svc, err := factory() - if err != nil { - return nil, fmt.Errorf("failed to create service %s: %w", name, err) - } - svcCopy := svc - coreOpts = append(coreOpts, WithName(name, func(c *Core) (any, error) { return svcCopy, nil })) - } - - coreInstance, err := New(coreOpts...) - if err != nil { - return nil, err - } - - return &Runtime{ - app: app, - Core: coreInstance, - }, nil -} - -// NewRuntime creates and wires together all application services. -// This is the simplest way to create a new Runtime, but it does not allow for -// the registration of any custom services. -func NewRuntime(app any) (*Runtime, error) { - return NewWithFactories(app, map[string]ServiceFactory{}) -} - -// ServiceName returns the name of the service. This is used by GUI runtimes to identify the service. -func (r *Runtime) ServiceName() string { - return "Core" -} - -// ServiceStartup is called by the GUI runtime at application startup. -// This is where the Core's startup lifecycle is initiated. -func (r *Runtime) ServiceStartup(ctx context.Context, options any) error { - return r.Core.ServiceStartup(ctx, options) -} - -// ServiceShutdown is called by the GUI runtime at application shutdown. -// This is where the Core's shutdown lifecycle is initiated. -func (r *Runtime) ServiceShutdown(ctx context.Context) error { - if r.Core != nil { - return r.Core.ServiceShutdown(ctx) - } - return nil -} diff --git a/pkg/core/service.go b/pkg/core/service.go new file mode 100644 index 0000000..526b755 --- /dev/null +++ b/pkg/core/service.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Service registry, lifecycle tracking, and runtime helpers for the Core framework. + +package core + +import "fmt" + +// --- Service Registry DTO --- + +// Service holds service registry data. +type Service struct { + Services map[string]any + startables []Startable + stoppables []Stoppable + lockEnabled bool + locked bool +} + + +// --- Core service methods --- + +// Service gets or registers a service. +// +// c.Service() // returns *Service +// c.Service("auth") // returns the "auth" service +// c.Service("auth", myService) // registers "auth" +func (c *Core) Service(args ...any) any { + switch len(args) { + case 0: + return c.srv + case 1: + name, _ := args[0].(string) + c.Lock("srv").Mu.RLock() + v, ok := c.srv.Services[name] + c.Lock("srv").Mu.RUnlock() + if !ok { + return nil + } + return v + default: + name, _ := args[0].(string) + if name == "" { + return E("core.Service", "service name cannot be empty", nil) + } + c.Lock("srv").Mu.Lock() + defer c.Lock("srv").Mu.Unlock() + if c.srv.locked { + return E("core.Service", fmt.Sprintf("service %q is not permitted by the serviceLock setting", name), nil) + } + if _, exists := c.srv.Services[name]; exists { + return E("core.Service", fmt.Sprintf("service %q already registered", name), nil) + } + svc := args[1] + if c.srv.Services == nil { + c.srv.Services = make(map[string]any) + } + c.srv.Services[name] = svc + if st, ok := svc.(Startable); ok { + c.srv.startables = append(c.srv.startables, st) + } + if st, ok := svc.(Stoppable); ok { + c.srv.stoppables = append(c.srv.stoppables, st) + } + if lp, ok := svc.(LocaleProvider); ok { + c.i18n.AddLocales(lp.Locales()) + } + return nil + } +} + diff --git a/pkg/core/service_manager.go b/pkg/core/service_manager.go deleted file mode 100644 index 0105cf7..0000000 --- a/pkg/core/service_manager.go +++ /dev/null @@ -1,96 +0,0 @@ -package core - -import ( - "errors" - "fmt" - "slices" - "sync" -) - -// serviceManager owns the service registry and lifecycle tracking. -// It is an unexported component used internally by Core. -type serviceManager struct { - mu sync.RWMutex - services map[string]any - startables []Startable - stoppables []Stoppable - lockEnabled bool // WithServiceLock was called - locked bool // lock applied after New() completes -} - -// newServiceManager creates an empty service manager. -func newServiceManager() *serviceManager { - return &serviceManager{ - services: make(map[string]any), - } -} - -// registerService adds a named service to the registry. -// It also appends to startables/stoppables if the service implements those interfaces. -func (m *serviceManager) registerService(name string, svc any) error { - if name == "" { - return errors.New("core: service name cannot be empty") - } - m.mu.Lock() - defer m.mu.Unlock() - if m.locked { - return fmt.Errorf("core: service %q is not permitted by the serviceLock setting", name) - } - if _, exists := m.services[name]; exists { - return fmt.Errorf("core: service %q already registered", name) - } - m.services[name] = svc - - if s, ok := svc.(Startable); ok { - m.startables = append(m.startables, s) - } - if s, ok := svc.(Stoppable); ok { - m.stoppables = append(m.stoppables, s) - } - - return nil -} - -// service retrieves a registered service by name, or nil if not found. -func (m *serviceManager) service(name string) any { - m.mu.RLock() - svc, ok := m.services[name] - m.mu.RUnlock() - if !ok { - return nil - } - return svc -} - -// enableLock marks that the lock should be applied after initialisation. -func (m *serviceManager) enableLock() { - m.mu.Lock() - defer m.mu.Unlock() - m.lockEnabled = true -} - -// applyLock activates the service lock if it was enabled. -// Called once during New() after all options have been processed. -func (m *serviceManager) applyLock() { - m.mu.Lock() - defer m.mu.Unlock() - if m.lockEnabled { - m.locked = true - } -} - -// getStartables returns a snapshot copy of the startables slice. -func (m *serviceManager) getStartables() []Startable { - m.mu.RLock() - out := slices.Clone(m.startables) - m.mu.RUnlock() - return out -} - -// getStoppables returns a snapshot copy of the stoppables slice. -func (m *serviceManager) getStoppables() []Stoppable { - m.mu.RLock() - out := slices.Clone(m.stoppables) - m.mu.RUnlock() - return out -} diff --git a/pkg/core/service_manager_test.go b/pkg/core/service_manager_test.go deleted file mode 100644 index fe408c4..0000000 --- a/pkg/core/service_manager_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package core - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestServiceManager_RegisterService_Good(t *testing.T) { - m := newServiceManager() - - err := m.registerService("svc1", &MockService{Name: "one"}) - assert.NoError(t, err) - - got := m.service("svc1") - assert.NotNil(t, got) - assert.Equal(t, "one", got.(*MockService).GetName()) -} - -func TestServiceManager_RegisterService_Bad(t *testing.T) { - m := newServiceManager() - - // Empty name - err := m.registerService("", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot be empty") - - // Duplicate - err = m.registerService("dup", &MockService{}) - assert.NoError(t, err) - err = m.registerService("dup", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already registered") - - // Locked - m2 := newServiceManager() - m2.enableLock() - m2.applyLock() - err = m2.registerService("late", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "serviceLock") -} - -func TestServiceManager_ServiceNotFound_Good(t *testing.T) { - m := newServiceManager() - assert.Nil(t, m.service("nonexistent")) -} - -func TestServiceManager_Startables_Good(t *testing.T) { - m := newServiceManager() - - s1 := &MockStartable{} - s2 := &MockStartable{} - - _ = m.registerService("s1", s1) - _ = m.registerService("s2", s2) - - startables := m.getStartables() - assert.Len(t, startables, 2) - - // Verify order matches registration order - assert.Same(t, s1, startables[0]) - assert.Same(t, s2, startables[1]) - - // Verify it's a copy — mutating the slice doesn't affect internal state - startables[0] = nil - assert.Len(t, m.getStartables(), 2) - assert.NotNil(t, m.getStartables()[0]) -} - -func TestServiceManager_Stoppables_Good(t *testing.T) { - m := newServiceManager() - - s1 := &MockStoppable{} - s2 := &MockStoppable{} - - _ = m.registerService("s1", s1) - _ = m.registerService("s2", s2) - - stoppables := m.getStoppables() - assert.Len(t, stoppables, 2) - - // Stoppables are returned in registration order; Core.ServiceShutdown reverses them - assert.Same(t, s1, stoppables[0]) - assert.Same(t, s2, stoppables[1]) -} - -func TestServiceManager_Lock_Good(t *testing.T) { - m := newServiceManager() - - // Register before lock — should succeed - err := m.registerService("early", &MockService{}) - assert.NoError(t, err) - - // Enable and apply lock - m.enableLock() - m.applyLock() - - // Register after lock — should fail - err = m.registerService("late", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "serviceLock") - - // Early service is still accessible - assert.NotNil(t, m.service("early")) -} - -func TestServiceManager_LockNotAppliedWithoutEnable_Good(t *testing.T) { - m := newServiceManager() - m.applyLock() // applyLock without enableLock should be a no-op - - err := m.registerService("svc", &MockService{}) - assert.NoError(t, err) -} - -type mockFullLifecycle struct{} - -func (m *mockFullLifecycle) OnStartup(_ context.Context) error { return nil } -func (m *mockFullLifecycle) OnShutdown(_ context.Context) error { return nil } - -func TestServiceManager_LifecycleBoth_Good(t *testing.T) { - m := newServiceManager() - - svc := &mockFullLifecycle{} - err := m.registerService("both", svc) - assert.NoError(t, err) - - // Should appear in both startables and stoppables - assert.Len(t, m.getStartables(), 1) - assert.Len(t, m.getStoppables(), 1) -} diff --git a/pkg/core/task.go b/pkg/core/task.go new file mode 100644 index 0000000..5420d8b --- /dev/null +++ b/pkg/core/task.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Background task dispatch for the Core framework. + +package core + +import ( + "fmt" + "slices" +) + +// TaskState holds background task state. +type TaskState struct { + ID string + Task Task + Result any + Error error +} + +// PerformAsync dispatches a task in a background goroutine. +func (c *Core) PerformAsync(t Task) string { + if c.shutdown.Load() { + return "" + } + taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1)) + if tid, ok := t.(TaskWithID); ok { + tid.SetTaskID(taskID) + } + _ = c.ACTION(ActionTaskStarted{TaskID: taskID, Task: t}) + c.wg.Go(func() { + result, handled, err := c.PERFORM(t) + if !handled && err == nil { + err = E("core.PerformAsync", fmt.Sprintf("no handler found for task type %T", t), nil) + } + _ = c.ACTION(ActionTaskCompleted{TaskID: taskID, Task: t, Result: result, Error: err}) + }) + return taskID +} + +// Progress broadcasts a progress update for a background task. +func (c *Core) Progress(taskID string, progress float64, message string, t Task) { + _ = c.ACTION(ActionTaskProgress{TaskID: taskID, Task: t, Progress: progress, Message: message}) +} + +func (c *Core) Perform(t Task) (any, bool, error) { + c.ipc.taskMu.RLock() + handlers := slices.Clone(c.ipc.taskHandlers) + c.ipc.taskMu.RUnlock() + + for _, h := range handlers { + result, handled, err := h(c, t) + if handled { + return result, true, err + } + } + return nil, false, nil +} + +func (c *Core) RegisterAction(handler func(*Core, Message) error) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler) + c.ipc.ipcMu.Unlock() +} + +func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { + c.ipc.ipcMu.Lock() + c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...) + c.ipc.ipcMu.Unlock() +} + +func (c *Core) RegisterTask(handler TaskHandler) { + c.ipc.taskMu.Lock() + c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler) + c.ipc.taskMu.Unlock() +} diff --git a/pkg/log/log.go b/pkg/log/log.go deleted file mode 100644 index 14e5467..0000000 --- a/pkg/log/log.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package log re-exports go-log and provides framework integration (Service) -// and log rotation (RotatingWriter) that depend on core/go internals. -// -// New code should import forge.lthn.ai/core/go-log directly. -package log - -import ( - "io" - - golog "forge.lthn.ai/core/go-log" -) - -// Type aliases — all go-log types available as log.X -type ( - Level = golog.Level - Logger = golog.Logger - Options = golog.Options - RotationOptions = golog.RotationOptions - Err = golog.Err -) - -// Level constants. -const ( - LevelQuiet = golog.LevelQuiet - LevelError = golog.LevelError - LevelWarn = golog.LevelWarn - LevelInfo = golog.LevelInfo - LevelDebug = golog.LevelDebug -) - -func init() { - // Wire rotation into go-log: when go-log's New() gets RotationOptions, - // it calls this factory to create the RotatingWriter (which needs go-io). - golog.RotationWriterFactory = func(opts RotationOptions) io.WriteCloser { - return NewRotatingWriter(opts, nil) - } -} - -// --- Logging functions (re-exported from go-log) --- - -var ( - New = golog.New - Default = golog.Default - SetDefault = golog.SetDefault - SetLevel = golog.SetLevel - Debug = golog.Debug - Info = golog.Info - Warn = golog.Warn - Error = golog.Error - Security = golog.Security - Username = golog.Username -) - -// --- Error functions (re-exported from go-log) --- - -var ( - E = golog.E - Wrap = golog.Wrap - WrapCode = golog.WrapCode - NewCode = golog.NewCode - Is = golog.Is - As = golog.As - NewError = golog.NewError - Join = golog.Join - Op = golog.Op - ErrCode = golog.ErrCode - Message = golog.Message - Root = golog.Root - StackTrace = golog.StackTrace - FormatStackTrace = golog.FormatStackTrace - LogError = golog.LogError - LogWarn = golog.LogWarn - Must = golog.Must -) diff --git a/pkg/log/rotation.go b/pkg/log/rotation.go deleted file mode 100644 index f226640..0000000 --- a/pkg/log/rotation.go +++ /dev/null @@ -1,170 +0,0 @@ -package log - -import ( - "fmt" - "io" - "sync" - "time" - - coreio "forge.lthn.ai/core/go-io" -) - -// RotatingWriter implements io.WriteCloser and provides log rotation. -type RotatingWriter struct { - opts RotationOptions - medium coreio.Medium - mu sync.Mutex - file io.WriteCloser - size int64 -} - -// NewRotatingWriter creates a new RotatingWriter with the given options and medium. -func NewRotatingWriter(opts RotationOptions, m coreio.Medium) *RotatingWriter { - if m == nil { - m = coreio.Local - } - if opts.MaxSize <= 0 { - opts.MaxSize = 100 // 100 MB - } - if opts.MaxBackups <= 0 { - opts.MaxBackups = 5 - } - if opts.MaxAge == 0 { - opts.MaxAge = 28 // 28 days - } else if opts.MaxAge < 0 { - opts.MaxAge = 0 // disabled - } - - return &RotatingWriter{ - opts: opts, - medium: m, - } -} - -// Write writes data to the current log file, rotating it if necessary. -func (w *RotatingWriter) Write(p []byte) (n int, err error) { - w.mu.Lock() - defer w.mu.Unlock() - - if w.file == nil { - if err := w.openExistingOrNew(); err != nil { - return 0, err - } - } - - if w.size+int64(len(p)) > int64(w.opts.MaxSize)*1024*1024 { - if err := w.rotate(); err != nil { - return 0, err - } - } - - n, err = w.file.Write(p) - if err == nil { - w.size += int64(n) - } - return n, err -} - -// Close closes the current log file. -func (w *RotatingWriter) Close() error { - w.mu.Lock() - defer w.mu.Unlock() - return w.close() -} - -func (w *RotatingWriter) close() error { - if w.file == nil { - return nil - } - err := w.file.Close() - w.file = nil - return err -} - -func (w *RotatingWriter) openExistingOrNew() error { - info, err := w.medium.Stat(w.opts.Filename) - if err == nil { - w.size = info.Size() - f, err := w.medium.Append(w.opts.Filename) - if err != nil { - return err - } - w.file = f - return nil - } - - f, err := w.medium.Create(w.opts.Filename) - if err != nil { - return err - } - w.file = f - w.size = 0 - return nil -} - -func (w *RotatingWriter) rotate() error { - if err := w.close(); err != nil { - return err - } - - if err := w.rotateFiles(); err != nil { - // Try to reopen current file even if rotation failed - _ = w.openExistingOrNew() - return err - } - - if err := w.openExistingOrNew(); err != nil { - return err - } - - w.cleanup() - - return nil -} - -func (w *RotatingWriter) rotateFiles() error { - // Rotate existing backups: log.N -> log.N+1 - for i := w.opts.MaxBackups; i >= 1; i-- { - oldPath := w.backupPath(i) - newPath := w.backupPath(i + 1) - - if w.medium.Exists(oldPath) { - if i+1 > w.opts.MaxBackups { - _ = w.medium.Delete(oldPath) - } else { - _ = w.medium.Rename(oldPath, newPath) - } - } - } - - // log -> log.1 - return w.medium.Rename(w.opts.Filename, w.backupPath(1)) -} - -func (w *RotatingWriter) backupPath(n int) string { - return fmt.Sprintf("%s.%d", w.opts.Filename, n) -} - -func (w *RotatingWriter) cleanup() { - // 1. Remove backups beyond MaxBackups - // This is already partially handled by rotateFiles but we can be thorough - for i := w.opts.MaxBackups + 1; ; i++ { - path := w.backupPath(i) - if !w.medium.Exists(path) { - break - } - _ = w.medium.Delete(path) - } - - // 2. Remove backups older than MaxAge - if w.opts.MaxAge > 0 { - cutoff := time.Now().AddDate(0, 0, -w.opts.MaxAge) - for i := 1; i <= w.opts.MaxBackups; i++ { - path := w.backupPath(i) - info, err := w.medium.Stat(path) - if err == nil && info.ModTime().Before(cutoff) { - _ = w.medium.Delete(path) - } - } - } -} diff --git a/pkg/log/rotation_test.go b/pkg/log/rotation_test.go deleted file mode 100644 index 97a012e..0000000 --- a/pkg/log/rotation_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package log - -import ( - "strings" - "testing" - "time" - - "forge.lthn.ai/core/go-io" -) - -func TestRotatingWriter_Basic(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, // 1 MB - MaxBackups: 3, - } - - w := NewRotatingWriter(opts, m) - defer w.Close() - - msg := "test message\n" - _, err := w.Write([]byte(msg)) - if err != nil { - t.Fatalf("failed to write: %v", err) - } - w.Close() - - content, err := m.Read("test.log") - if err != nil { - t.Fatalf("failed to read from medium: %v", err) - } - if content != msg { - t.Errorf("expected %q, got %q", msg, content) - } -} - -func TestRotatingWriter_Rotation(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, // 1 MB - MaxBackups: 2, - } - - w := NewRotatingWriter(opts, m) - defer w.Close() - - // 1. Write almost 1MB - largeMsg := strings.Repeat("a", 1024*1024-10) - _, _ = w.Write([]byte(largeMsg)) - - // 2. Write more to trigger rotation - _, _ = w.Write([]byte("trigger rotation\n")) - w.Close() - - // Check if test.log.1 exists and contains the large message - if !m.Exists("test.log.1") { - t.Error("expected test.log.1 to exist") - } - - // Check if test.log exists and contains the new message - content, _ := m.Read("test.log") - if !strings.Contains(content, "trigger rotation") { - t.Errorf("expected test.log to contain new message, got %q", content) - } -} - -func TestRotatingWriter_Retention(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, - MaxBackups: 2, - } - - w := NewRotatingWriter(opts, m) - defer w.Close() - - // Trigger rotation 4 times to test retention of only the latest backups - for i := 1; i <= 4; i++ { - _, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1))) - } - w.Close() - - // Should have test.log, test.log.1, test.log.2 - // test.log.3 should have been deleted because MaxBackups is 2 - if !m.Exists("test.log") { - t.Error("expected test.log to exist") - } - if !m.Exists("test.log.1") { - t.Error("expected test.log.1 to exist") - } - if !m.Exists("test.log.2") { - t.Error("expected test.log.2 to exist") - } - if m.Exists("test.log.3") { - t.Error("expected test.log.3 NOT to exist") - } -} - -func TestRotatingWriter_Append(t *testing.T) { - m := io.NewMockMedium() - _ = m.Write("test.log", "existing content\n") - - opts := RotationOptions{ - Filename: "test.log", - } - - w := NewRotatingWriter(opts, m) - _, _ = w.Write([]byte("new content\n")) - _ = w.Close() - - content, _ := m.Read("test.log") - expected := "existing content\nnew content\n" - if content != expected { - t.Errorf("expected %q, got %q", expected, content) - } -} - -func TestRotatingWriter_AgeRetention(t *testing.T) { - m := io.NewMockMedium() - opts := RotationOptions{ - Filename: "test.log", - MaxSize: 1, - MaxBackups: 5, - MaxAge: 7, // 7 days - } - - w := NewRotatingWriter(opts, m) - - // Create some backup files - m.Write("test.log.1", "recent") - m.ModTimes["test.log.1"] = time.Now() - - m.Write("test.log.2", "old") - m.ModTimes["test.log.2"] = time.Now().AddDate(0, 0, -10) // 10 days old - - // Trigger rotation to run cleanup - _, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1))) - w.Close() - - if !m.Exists("test.log.1") { - t.Error("expected test.log.1 (now test.log.2) to exist as it's recent") - } - // Note: test.log.1 becomes test.log.2 after rotation, etc. - // But wait, my cleanup runs AFTER rotation. - // Initial state: - // test.log.1 (now) - // test.log.2 (-10d) - // Write triggers rotation: - // test.log -> test.log.1 - // test.log.1 -> test.log.2 - // test.log.2 -> test.log.3 - // Then cleanup runs: - // test.log.1 (now) - keep - // test.log.2 (now) - keep - // test.log.3 (-10d) - delete (since MaxAge is 7) - - if m.Exists("test.log.3") { - t.Error("expected test.log.3 to be deleted as it's too old") - } -} diff --git a/pkg/log/service.go b/pkg/log/service.go deleted file mode 100644 index 263a7b1..0000000 --- a/pkg/log/service.go +++ /dev/null @@ -1,57 +0,0 @@ -package log - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Service wraps Logger for Core framework integration. -type Service struct { - *core.ServiceRuntime[Options] - *Logger -} - -// NewService creates a log service factory for Core. -func NewService(opts Options) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - logger := New(opts) - - return &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - Logger: logger, - }, nil - } -} - -// OnStartup registers query and task handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -// QueryLevel returns the current log level. -type QueryLevel struct{} - -// TaskSetLevel changes the log level. -type TaskSetLevel struct { - Level Level -} - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q.(type) { - case QueryLevel: - return s.Level(), true, nil - } - return nil, false, nil -} - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch m := t.(type) { - case TaskSetLevel: - s.SetLevel(m.Level) - return nil, true, nil - } - return nil, false, nil -} diff --git a/pkg/core/async_test.go b/tests/async_test.go similarity index 90% rename from pkg/core/async_test.go rename to tests/async_test.go index f29ff9e..d9b589f 100644 --- a/pkg/core/async_test.go +++ b/tests/async_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "errors" "sync/atomic" @@ -13,10 +14,10 @@ import ( func TestCore_PerformAsync_Good(t *testing.T) { c, _ := New() - + var completed atomic.Bool var resultReceived any - + c.RegisterAction(func(c *Core, msg Message) error { if tc, ok := msg.(ActionTaskCompleted); ok { resultReceived = tc.Result @@ -24,36 +25,36 @@ func TestCore_PerformAsync_Good(t *testing.T) { } return nil }) - + c.RegisterTask(func(c *Core, task Task) (any, bool, error) { return "async-result", true, nil }) - + taskID := c.PerformAsync(TestTask{}) assert.NotEmpty(t, taskID) - + // Wait for completion assert.Eventually(t, func() bool { return completed.Load() }, 1*time.Second, 10*time.Millisecond) - + assert.Equal(t, "async-result", resultReceived) } func TestCore_PerformAsync_Shutdown(t *testing.T) { c, _ := New() _ = c.ServiceShutdown(context.Background()) - + taskID := c.PerformAsync(TestTask{}) assert.Empty(t, taskID, "PerformAsync should return empty string if already shut down") } func TestCore_Progress_Good(t *testing.T) { c, _ := New() - + var progressReceived float64 var messageReceived string - + c.RegisterAction(func(c *Core, msg Message) error { if tp, ok := msg.(ActionTaskProgress); ok { progressReceived = tp.Progress @@ -61,9 +62,9 @@ func TestCore_Progress_Good(t *testing.T) { } return nil }) - + c.Progress("task-1", 0.5, "halfway", TestTask{}) - + assert.Equal(t, 0.5, progressReceived) assert.Equal(t, "halfway", messageReceived) } @@ -74,7 +75,7 @@ func TestCore_WithService_UnnamedType(t *testing.T) { s := "primitive" return &s, nil } - + _, err := New(WithService(factory)) require.Error(t, err) assert.Contains(t, err.Error(), "service name could not be discovered") @@ -82,11 +83,11 @@ func TestCore_WithService_UnnamedType(t *testing.T) { func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) { rt, _ := NewRuntime(nil) - + // Register a service that fails startup errSvc := &MockStartable{err: errors.New("startup failed")} _ = rt.Core.RegisterService("error-svc", errSvc) - + err := rt.ServiceStartup(context.Background(), nil) assert.Error(t, err) assert.Contains(t, err.Error(), "startup failed") @@ -94,46 +95,47 @@ func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) { func TestCore_ServiceStartup_ContextCancellation(t *testing.T) { c, _ := New() - + ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately - + s1 := &MockStartable{} _ = c.RegisterService("s1", s1) - + err := c.ServiceStartup(ctx, nil) assert.Error(t, err) assert.ErrorIs(t, err, context.Canceled) - assert.False(t, s1.started, "Service should not have started if context was cancelled before loop") + assert.False(t, s1.started, "Srv should not have started if context was cancelled before loop") } func TestCore_ServiceShutdown_ContextCancellation(t *testing.T) { c, _ := New() - + ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately - + s1 := &MockStoppable{} _ = c.RegisterService("s1", s1) - + err := c.ServiceShutdown(ctx) assert.Error(t, err) assert.ErrorIs(t, err, context.Canceled) - assert.False(t, s1.stopped, "Service should not have stopped if context was cancelled before loop") + assert.False(t, s1.stopped, "Srv should not have stopped if context was cancelled before loop") } type TaskWithIDImpl struct { id string } + func (t *TaskWithIDImpl) SetTaskID(id string) { t.id = id } -func (t *TaskWithIDImpl) GetTaskID() string { return t.id } +func (t *TaskWithIDImpl) GetTaskID() string { return t.id } func TestCore_PerformAsync_InjectsID(t *testing.T) { c, _ := New() c.RegisterTask(func(c *Core, t Task) (any, bool, error) { return nil, true, nil }) - + task := &TaskWithIDImpl{} taskID := c.PerformAsync(task) - + assert.Equal(t, taskID, task.GetTaskID()) } diff --git a/pkg/core/bench_test.go b/tests/bench_test.go similarity index 92% rename from pkg/core/bench_test.go rename to tests/bench_test.go index 2337c6e..a59aa82 100644 --- a/pkg/core/bench_test.go +++ b/tests/bench_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "testing" ) diff --git a/pkg/core/core_extra_test.go b/tests/core_extra_test.go similarity index 94% rename from pkg/core/core_extra_test.go rename to tests/core_extra_test.go index 38da57f..408476e 100644 --- a/pkg/core/core_extra_test.go +++ b/tests/core_extra_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "testing" "github.com/stretchr/testify/assert" diff --git a/pkg/core/core_lifecycle_test.go b/tests/core_lifecycle_test.go similarity index 98% rename from pkg/core/core_lifecycle_test.go rename to tests/core_lifecycle_test.go index 6b1a302..6f2fadf 100644 --- a/pkg/core/core_lifecycle_test.go +++ b/tests/core_lifecycle_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "errors" "testing" diff --git a/pkg/core/core_test.go b/tests/core_test.go similarity index 83% rename from pkg/core/core_test.go rename to tests/core_test.go index 07c43cf..2966089 100644 --- a/pkg/core/core_test.go +++ b/tests/core_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "embed" "io" @@ -33,7 +34,7 @@ func TestCore_WithService_Good(t *testing.T) { } c, err := New(WithService(factory)) assert.NoError(t, err) - svc := c.Service("core") + svc := c.Service().Get("core") assert.NotNil(t, svc) mockSvc, ok := svc.(*MockService) assert.True(t, ok) @@ -54,10 +55,6 @@ type MockConfigService struct{} func (m *MockConfigService) Get(key string, out any) error { return nil } func (m *MockConfigService) Set(key string, v any) error { return nil } -type MockDisplayService struct{} - -func (m *MockDisplayService) OpenWindow(opts ...WindowOption) error { return nil } - func TestCore_Services_Good(t *testing.T) { c, err := New() assert.NoError(t, err) @@ -65,29 +62,12 @@ func TestCore_Services_Good(t *testing.T) { err = c.RegisterService("config", &MockConfigService{}) assert.NoError(t, err) - err = c.RegisterService("display", &MockDisplayService{}) - assert.NoError(t, err) + svc := c.Service("config") + assert.NotNil(t, svc) + // Cfg() returns Cfg (always available, not a service) cfg := c.Config() assert.NotNil(t, cfg) - - d := c.Display() - assert.NotNil(t, d) -} - -func TestCore_Services_Ugly(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // Config panics when service not registered - assert.Panics(t, func() { - c.Config() - }) - - // Display panics when service not registered - assert.Panics(t, func() { - c.Display() - }) } func TestCore_App_Good(t *testing.T) { @@ -95,21 +75,21 @@ func TestCore_App_Good(t *testing.T) { c, err := New(WithApp(app)) assert.NoError(t, err) - // To test the global App() function, we need to set the global instance. + // To test the global CoreGUI() function, we need to set the global instance. originalInstance := GetInstance() SetInstance(c) defer SetInstance(originalInstance) - assert.Equal(t, app, App()) + assert.Equal(t, app, CoreGUI()) } func TestCore_App_Ugly(t *testing.T) { - // This test ensures that calling App() before the core is initialized panics. + // This test ensures that calling CoreGUI() before the core is initialized panics. originalInstance := GetInstance() ClearInstance() defer SetInstance(originalInstance) assert.Panics(t, func() { - App() + CoreGUI() }) } @@ -119,24 +99,37 @@ func TestCore_Core_Good(t *testing.T) { assert.Equal(t, c, c.Core()) } -func TestFeatures_IsEnabled_Good(t *testing.T) { +func TestEtc_Features_Good(t *testing.T) { c, err := New() assert.NoError(t, err) - c.Features.Flags = []string{"feature1", "feature2"} + c.Config().Enable("feature1") + c.Config().Enable("feature2") - assert.True(t, c.Features.IsEnabled("feature1")) - assert.True(t, c.Features.IsEnabled("feature2")) - assert.False(t, c.Features.IsEnabled("feature3")) - assert.False(t, c.Features.IsEnabled("")) + assert.True(t, c.Config().Enabled("feature1")) + assert.True(t, c.Config().Enabled("feature2")) + assert.False(t, c.Config().Enabled("feature3")) + assert.False(t, c.Config().Enabled("")) } -func TestFeatures_IsEnabled_Edge(t *testing.T) { +func TestEtc_Settings_Good(t *testing.T) { c, _ := New() - c.Features.Flags = []string{" ", "foo"} - assert.True(t, c.Features.IsEnabled(" ")) - assert.True(t, c.Features.IsEnabled("foo")) - assert.False(t, c.Features.IsEnabled("FOO")) // Case sensitive check + c.Config().Set("api_url", "https://api.lthn.sh") + c.Config().Set("max_agents", 5) + + assert.Equal(t, "https://api.lthn.sh", c.Config().GetString("api_url")) + assert.Equal(t, 5, c.Config().GetInt("max_agents")) + assert.Equal(t, "", c.Config().GetString("missing")) +} + +func TestEtc_Features_Edge(t *testing.T) { + c, _ := New() + c.Config().Enable("foo") + assert.True(t, c.Config().Enabled("foo")) + assert.False(t, c.Config().Enabled("FOO")) // Case sensitive + + c.Config().Disable("foo") + assert.False(t, c.Config().Enabled("foo")) } func TestCore_ServiceLifecycle_Good(t *testing.T) { @@ -165,7 +158,7 @@ func TestCore_WithApp_Good(t *testing.T) { app := &mockApp{} c, err := New(WithApp(app)) assert.NoError(t, err) - assert.Equal(t, app, c.App) + assert.Equal(t, app, c.App().Runtime) } //go:embed testdata @@ -174,8 +167,7 @@ var testFS embed.FS func TestCore_WithAssets_Good(t *testing.T) { c, err := New(WithAssets(testFS)) assert.NoError(t, err) - assets := c.Assets() - file, err := assets.Open("testdata/test.txt") + file, err := c.Embed().Open("testdata/test.txt") assert.NoError(t, err) defer func() { _ = file.Close() }() content, err := io.ReadAll(file) diff --git a/pkg/core/e_test.go b/tests/e_test.go similarity index 90% rename from pkg/core/e_test.go rename to tests/e_test.go index 71b04c0..a468842 100644 --- a/pkg/core/e_test.go +++ b/tests/e_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" @@ -23,7 +25,7 @@ func TestE_Unwrap(t *testing.T) { assert.True(t, errors.Is(err, originalErr)) - var eErr *Error + var eErr *Err assert.True(t, errors.As(err, &eErr)) assert.Equal(t, "test.op", eErr.Op) } diff --git a/pkg/core/fuzz_test.go b/tests/fuzz_test.go similarity index 77% rename from pkg/core/fuzz_test.go rename to tests/fuzz_test.go index 93972e0..1a5501b 100644 --- a/pkg/core/fuzz_test.go +++ b/tests/fuzz_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" ) @@ -23,14 +24,14 @@ func FuzzE(f *testing.F) { } s := e.Error() - if s == "" { - t.Fatal("Error() returned empty string") + if s == "" && (op != "" || msg != "") { + t.Fatal("Error() returned empty string for non-empty op/msg") } // Round-trip: Unwrap should return the underlying error - var coreErr *Error + var coreErr *Err if !errors.As(e, &coreErr) { - t.Fatal("errors.As failed for *Error") + t.Fatal("errors.As failed for *Err") } if withErr && coreErr.Unwrap() == nil { t.Fatal("Unwrap() returned nil with underlying error") @@ -41,7 +42,7 @@ func FuzzE(f *testing.F) { }) } -// FuzzServiceRegistration exercises service name registration with arbitrary names. +// FuzzServiceRegistration exercises service registration with arbitrary names. func FuzzServiceRegistration(f *testing.F) { f.Add("myservice") f.Add("") @@ -50,9 +51,9 @@ func FuzzServiceRegistration(f *testing.F) { f.Add("service\x00null") f.Fuzz(func(t *testing.T, name string) { - sm := newServiceManager() + c, _ := New() - err := sm.registerService(name, struct{}{}) + err := c.RegisterService(name, struct{}{}) if name == "" { if err == nil { t.Fatal("expected error for empty name") @@ -64,13 +65,13 @@ func FuzzServiceRegistration(f *testing.F) { } // Retrieve should return the same service - got := sm.service(name) + got := c.Service(name) if got == nil { t.Fatalf("service %q not found after registration", name) } // Duplicate registration should fail - err = sm.registerService(name, struct{}{}) + err = c.RegisterService(name, struct{}{}) if err == nil { t.Fatalf("expected duplicate error for name %q", name) } @@ -84,19 +85,15 @@ func FuzzMessageDispatch(f *testing.F) { f.Add("test\nmultiline") f.Fuzz(func(t *testing.T, payload string) { - c := &Core{ - Features: &Features{}, - svc: newServiceManager(), - } - c.bus = newMessageBus(c) + c, _ := New() var received string - c.bus.registerAction(func(_ *Core, msg Message) error { + c.IPC().RegisterAction(func(_ *Core, msg Message) error { received = msg.(string) return nil }) - err := c.bus.action(payload) + err := c.IPC().Action(payload) if err != nil { t.Fatalf("action dispatch failed: %v", err) } diff --git a/pkg/core/ipc_test.go b/tests/ipc_test.go similarity index 97% rename from pkg/core/ipc_test.go rename to tests/ipc_test.go index e019297..cb0559c 100644 --- a/pkg/core/ipc_test.go +++ b/tests/ipc_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" "time" diff --git a/tests/message_bus_test.go b/tests/message_bus_test.go new file mode 100644 index 0000000..0a46031 --- /dev/null +++ b/tests/message_bus_test.go @@ -0,0 +1,176 @@ +package core_test + +import ( + . "forge.lthn.ai/core/go/pkg/core" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBus_Action_Good(t *testing.T) { + c, _ := New() + + var received []Message + c.IPC().RegisterAction(func(_ *Core, msg Message) error { + received = append(received, msg) + return nil + }) + c.IPC().RegisterAction(func(_ *Core, msg Message) error { + received = append(received, msg) + return nil + }) + + err := c.IPC().Action("hello") + assert.NoError(t, err) + assert.Len(t, received, 2) +} + +func TestBus_Action_Bad(t *testing.T) { + c, _ := New() + + err1 := errors.New("handler1 failed") + err2 := errors.New("handler2 failed") + + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err1 }) + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil }) + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err2 }) + + err := c.IPC().Action("test") + assert.Error(t, err) + assert.ErrorIs(t, err, err1) + assert.ErrorIs(t, err, err2) +} + +func TestBus_RegisterAction_Good(t *testing.T) { + c, _ := New() + + var coreRef *Core + c.IPC().RegisterAction(func(core *Core, msg Message) error { + coreRef = core + return nil + }) + + _ = c.IPC().Action(nil) + assert.Same(t, c, coreRef, "handler should receive the Core reference") +} + +func TestBus_Query_Good(t *testing.T) { + c, _ := New() + + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return "first", true, nil + }) + + result, handled, err := c.IPC().Query(TestQuery{Value: "test"}) + assert.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "first", result) +} + +func TestBus_QueryAll_Good(t *testing.T) { + c, _ := New() + + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return "a", true, nil + }) + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return nil, false, nil // skips + }) + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return "b", true, nil + }) + + results, err := c.IPC().QueryAll(TestQuery{}) + assert.NoError(t, err) + assert.Equal(t, []any{"a", "b"}, results) +} + +func TestBus_Perform_Good(t *testing.T) { + c, _ := New() + + c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) { + return "done", true, nil + }) + + result, handled, err := c.IPC().Perform(TestTask{}) + assert.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "done", result) +} + +func TestBus_ConcurrentAccess_Good(t *testing.T) { + c, _ := New() + + var wg sync.WaitGroup + const goroutines = 20 + + // Concurrent register + dispatch + for i := 0; i < goroutines; i++ { + wg.Add(2) + go func() { + defer wg.Done() + c.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil }) + }() + go func() { + defer wg.Done() + _ = c.IPC().Action("ping") + }() + } + + for i := 0; i < goroutines; i++ { + wg.Add(2) + go func() { + defer wg.Done() + c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil }) + }() + go func() { + defer wg.Done() + _, _ = c.IPC().QueryAll(TestQuery{}) + }() + } + + for i := 0; i < goroutines; i++ { + wg.Add(2) + go func() { + defer wg.Done() + c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil }) + }() + go func() { + defer wg.Done() + _, _, _ = c.IPC().Perform(TestTask{}) + }() + } + + wg.Wait() +} + +func TestBus_Action_NoHandlers(t *testing.T) { + c, _ := New() + err := c.IPC().Action("no one listening") + assert.NoError(t, err) +} + +func TestBus_Query_NoHandlers(t *testing.T) { + c, _ := New() + result, handled, err := c.IPC().Query(TestQuery{}) + assert.NoError(t, err) + assert.False(t, handled) + assert.Nil(t, result) +} + +func TestBus_QueryAll_NoHandlers(t *testing.T) { + c, _ := New() + results, err := c.IPC().QueryAll(TestQuery{}) + assert.NoError(t, err) + assert.Empty(t, results) +} + +func TestBus_Perform_NoHandlers(t *testing.T) { + c, _ := New() + result, handled, err := c.IPC().Perform(TestTask{}) + assert.NoError(t, err) + assert.False(t, handled) + assert.Nil(t, result) +} diff --git a/pkg/core/query_test.go b/tests/query_test.go similarity index 98% rename from pkg/core/query_test.go rename to tests/query_test.go index 43b00fb..e4118c2 100644 --- a/pkg/core/query_test.go +++ b/tests/query_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "errors" "testing" diff --git a/pkg/core/runtime_pkg_extra_test.go b/tests/runtime_pkg_extra_test.go similarity index 86% rename from pkg/core/runtime_pkg_extra_test.go rename to tests/runtime_pkg_extra_test.go index c63a4a1..ffa60bb 100644 --- a/pkg/core/runtime_pkg_extra_test.go +++ b/tests/runtime_pkg_extra_test.go @@ -1,6 +1,8 @@ -package core +package core_test + import ( + . "forge.lthn.ai/core/go/pkg/core" "testing" "github.com/stretchr/testify/assert" diff --git a/pkg/core/runtime_pkg_test.go b/tests/runtime_pkg_test.go similarity index 95% rename from pkg/core/runtime_pkg_test.go rename to tests/runtime_pkg_test.go index bc9b388..4970810 100644 --- a/pkg/core/runtime_pkg_test.go +++ b/tests/runtime_pkg_test.go @@ -1,6 +1,7 @@ -package core +package core_test import ( + . "forge.lthn.ai/core/go/pkg/core" "context" "testing" @@ -120,7 +121,7 @@ func TestNewServiceRuntime_Good(t *testing.T) { assert.NotNil(t, sr) assert.Equal(t, c, sr.Core()) - // We can't directly test sr.Config() without a registered config service, + // We can't directly test sr.Cfg() without a registered config service, // as it will panic. assert.Panics(t, func() { sr.Config() diff --git a/tests/service_manager_test.go b/tests/service_manager_test.go new file mode 100644 index 0000000..bfd1e99 --- /dev/null +++ b/tests/service_manager_test.go @@ -0,0 +1,116 @@ +package core_test + +import ( + . "forge.lthn.ai/core/go/pkg/core" + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestServiceManager_RegisterService_Good(t *testing.T) { + c, _ := New() + + err := c.RegisterService("svc1", &MockService{Name: "one"}) + assert.NoError(t, err) + + got := c.Service("svc1") + assert.NotNil(t, got) + assert.Equal(t, "one", got.(*MockService).GetName()) +} + +func TestServiceManager_RegisterService_Bad(t *testing.T) { + c, _ := New() + + // Empty name + err := c.RegisterService("", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be empty") + + // Duplicate + err = c.RegisterService("dup", &MockService{}) + assert.NoError(t, err) + err = c.RegisterService("dup", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + + // Locked + c2, _ := New(WithServiceLock()) + err = c2.RegisterService("late", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "serviceLock") +} + +func TestServiceManager_ServiceNotFound_Good(t *testing.T) { + c, _ := New() + assert.Nil(t, c.Service("nonexistent")) +} + +func TestServiceManager_Startables_Good(t *testing.T) { + s1 := &MockStartable{} + s2 := &MockStartable{} + + c, _ := New( + WithName("s1", func(_ *Core) (any, error) { return s1, nil }), + WithName("s2", func(_ *Core) (any, error) { return s2, nil }), + ) + + // Startup should call both + err := c.ServiceStartup(context.Background(), nil) + assert.NoError(t, err) +} + +func TestServiceManager_Stoppables_Good(t *testing.T) { + s1 := &MockStoppable{} + s2 := &MockStoppable{} + + c, _ := New( + WithName("s1", func(_ *Core) (any, error) { return s1, nil }), + WithName("s2", func(_ *Core) (any, error) { return s2, nil }), + ) + + // Shutdown should call both + err := c.ServiceShutdown(context.Background()) + assert.NoError(t, err) +} + +func TestServiceManager_Lock_Good(t *testing.T) { + c, _ := New( + WithName("early", func(_ *Core) (any, error) { return &MockService{}, nil }), + WithServiceLock(), + ) + + // Register after lock — should fail + err := c.RegisterService("late", &MockService{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "serviceLock") + + // Early service is still accessible + assert.NotNil(t, c.Service("early")) +} + +func TestServiceManager_LockNotAppliedWithoutEnable_Good(t *testing.T) { + // No WithServiceLock — should allow registration after New() + c, _ := New() + err := c.RegisterService("svc", &MockService{}) + assert.NoError(t, err) +} + +type mockFullLifecycle struct{} + +func (m *mockFullLifecycle) OnStartup(_ context.Context) error { return nil } +func (m *mockFullLifecycle) OnShutdown(_ context.Context) error { return nil } + +func TestServiceManager_LifecycleBoth_Good(t *testing.T) { + svc := &mockFullLifecycle{} + + c, _ := New( + WithName("both", func(_ *Core) (any, error) { return svc, nil }), + ) + + // Should participate in both startup and shutdown + err := c.ServiceStartup(context.Background(), nil) + assert.NoError(t, err) + err = c.ServiceShutdown(context.Background()) + assert.NoError(t, err) +} diff --git a/pkg/core/testdata/test.txt b/tests/testdata/test.txt similarity index 100% rename from pkg/core/testdata/test.txt rename to tests/testdata/test.txt