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