feat: Command() and i18n.SetLanguage() return Result

Command(path, Command{Action: handler}) — typed struct input, Result output.
Command fields exported: Name, Description, Path, Action, Lifecycle, Flags, Hidden.

i18n.SetLanguage returns Result instead of error.

All public methods across core/go now return Result where applicable.

231 tests, 76.5% coverage.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-20 14:44:29 +00:00
parent 3bab201229
commit 9bcb367dd0
6 changed files with 116 additions and 148 deletions

View file

@ -118,7 +118,7 @@ func (cl *Cli) PrintHelp() {
defer cl.core.commands.mu.RUnlock()
for path, cmd := range cl.core.commands.commands {
if cmd.hidden {
if cmd.Hidden {
continue
}
desc := cl.core.I18n().T(cmd.I18nKey())

View file

@ -42,14 +42,14 @@ type CommandLifecycle interface {
// Command is the DTO for an executable operation.
type Command struct {
name string
description string // i18n key — derived from path if empty
path string // "deploy/to/homelab"
commands map[string]*Command // child commands
action CommandAction // business logic
lifecycle CommandLifecycle // optional — provided by go-process
flags Options // declared flags
hidden bool
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
}
@ -57,12 +57,12 @@ type Command struct {
//
// cmd with path "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
func (cmd *Command) I18nKey() string {
if cmd.description != "" {
return cmd.description
if cmd.Description != "" {
return cmd.Description
}
path := cmd.path
path := cmd.Path
if path == "" {
path = cmd.name
path = cmd.Name
}
return Concat("cmd.", Replace(path, "/", "."), ".description")
}
@ -71,48 +71,48 @@ func (cmd *Command) I18nKey() string {
//
// result := cmd.Run(core.Options{{K: "target", V: "homelab"}})
func (cmd *Command) Run(opts Options) Result {
if cmd.action == nil {
if cmd.Action == nil {
return Result{}
}
return cmd.action(opts)
return cmd.Action(opts)
}
// Start delegates to the lifecycle implementation if available.
func (cmd *Command) Start(opts Options) Result {
if cmd.lifecycle != nil {
return cmd.lifecycle.Start(opts)
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Start(opts)
}
return cmd.Run(opts)
}
// Stop delegates to the lifecycle implementation.
func (cmd *Command) Stop() Result {
if cmd.lifecycle != nil {
return cmd.lifecycle.Stop()
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Stop()
}
return Result{}
}
// Restart delegates to the lifecycle implementation.
func (cmd *Command) Restart() Result {
if cmd.lifecycle != nil {
return cmd.lifecycle.Restart()
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Restart()
}
return Result{}
}
// Reload delegates to the lifecycle implementation.
func (cmd *Command) Reload() Result {
if cmd.lifecycle != nil {
return cmd.lifecycle.Reload()
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Reload()
}
return Result{}
}
// Signal delegates to the lifecycle implementation.
func (cmd *Command) Signal(sig string) Result {
if cmd.lifecycle != nil {
return cmd.lifecycle.Signal(sig)
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Signal(sig)
}
return Result{}
}
@ -125,79 +125,54 @@ type commandRegistry struct {
mu sync.RWMutex
}
// CommandHandler registers or retrieves commands on Core.
// Same pattern as Service() — zero args returns registry, one arg gets, two args registers.
// Command gets or registers a command by path.
//
// c.Command("deploy", handler) // register
// c.Command("deploy/to/homelab", handler) // register nested
// cmd := c.Command("deploy") // get
func (c *Core) Command(args ...any) any {
// c.Command("deploy", Command{Action: handler})
// r := c.Command("deploy")
func (c *Core) Command(path string, command ...Command) Result {
if c.commands == nil {
c.commands = &commandRegistry{commands: make(map[string]*Command)}
}
switch len(args) {
case 0:
return c.commands
case 1:
path, _ := Arg(0, args...).Value.(string)
if len(command) == 0 {
c.commands.mu.RLock()
cmd := c.commands.commands[path]
cmd, ok := c.commands.commands[path]
c.commands.mu.RUnlock()
return cmd
default:
path, _ := Arg(0, args...).Value.(string)
if path == "" {
return E("core.Command", "command path cannot be empty", nil)
}
c.commands.mu.Lock()
defer c.commands.mu.Unlock()
cmd := &Command{
name: pathName(path),
path: path,
commands: make(map[string]*Command),
}
// Second arg: action function or Options
switch v := args[1].(type) {
case CommandAction:
cmd.action = v
case func(Options) Result:
cmd.action = v
case Options:
cmd.description = v.String("description")
cmd.hidden = v.Bool("hidden")
}
// Third arg if present: Options for metadata
if len(args) > 2 {
if opts, ok := args[2].(Options); ok {
cmd.description = opts.String("description")
cmd.hidden = opts.Bool("hidden")
}
}
c.commands.commands[path] = cmd
// 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 nil
return Result{Value: cmd, OK: ok}
}
if path == "" {
return Result{Value: E("core.Command", "command path cannot be empty", nil)}
}
c.commands.mu.Lock()
defer c.commands.mu.Unlock()
cmd := &command[0]
cmd.Name = pathName(path)
cmd.Path = path
if cmd.commands == nil {
cmd.commands = make(map[string]*Command)
}
c.commands.commands[path] = cmd
// 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 Result{OK: true}
}
// Commands returns all registered command paths.

View file

@ -92,14 +92,16 @@ func (i *I18n) T(messageID string, args ...any) string {
}
// SetLanguage sets the active language. No-op if no translator is registered.
func (i *I18n) SetLanguage(lang string) error {
func (i *I18n) SetLanguage(lang string) Result {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.SetLanguage(lang)
r := &Result{}
r.Result(nil, t.SetLanguage(lang))
return *r
}
return nil
return Result{OK: true}
}
// Language returns the current language code, or "en" if no translator.

View file

@ -28,10 +28,10 @@ func TestCli_SetBanner_Good(t *testing.T) {
func TestCli_Run_Good(t *testing.T) {
c := New()
executed := false
c.Command("hello", func(_ Options) Result {
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)
@ -41,10 +41,10 @@ func TestCli_Run_Good(t *testing.T) {
func TestCli_Run_Nested_Good(t *testing.T) {
c := New()
executed := false
c.Command("deploy/to/homelab", func(_ Options) Result {
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)
@ -53,10 +53,10 @@ func TestCli_Run_Nested_Good(t *testing.T) {
func TestCli_Run_WithFlags_Good(t *testing.T) {
c := New()
var received Options
c.Command("serve", func(opts Options) Result {
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"))
@ -64,15 +64,13 @@ func TestCli_Run_WithFlags_Good(t *testing.T) {
func TestCli_Run_NoCommand_Good(t *testing.T) {
c := New()
// No commands registered — should not panic
r := c.Cli().Run()
assert.False(t, r.OK)
}
func TestCli_PrintHelp_Good(t *testing.T) {
c := New(Options{{K: "name", V: "myapp"}})
c.Command("deploy", func(_ Options) Result { return Result{OK: true} })
c.Command("serve", func(_ Options) Result { return Result{OK: true} })
// Should not panic
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()
}

View file

@ -11,33 +11,32 @@ import (
func TestCommand_Register_Good(t *testing.T) {
c := New()
result := c.Command("deploy", func(_ Options) Result {
r := c.Command("deploy", Command{Action: func(_ Options) Result {
return Result{Value: "deployed", OK: true}
})
assert.Nil(t, result) // nil = success
}})
assert.True(t, r.OK)
}
func TestCommand_Get_Good(t *testing.T) {
c := New()
c.Command("deploy", func(_ Options) Result {
return Result{OK: true}
})
cmd := c.Command("deploy")
assert.NotNil(t, cmd)
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()
cmd := c.Command("nonexistent")
assert.Nil(t, cmd)
r := c.Command("nonexistent")
assert.False(t, r.OK)
}
func TestCommand_Run_Good(t *testing.T) {
c := New()
c.Command("greet", func(opts Options) Result {
return Result{Value: "hello " + opts.String("name"), OK: true}
})
cmd := c.Command("greet").(*Command)
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{{K: "name", V: "world"}})
assert.True(t, r.OK)
assert.Equal(t, "hello world", r.Value)
@ -45,8 +44,8 @@ func TestCommand_Run_Good(t *testing.T) {
func TestCommand_Run_NoAction_Good(t *testing.T) {
c := New()
c.Command("empty", Options{{K: "description", V: "no action"}})
cmd := c.Command("empty").(*Command)
c.Command("empty", Command{Description: "no action"})
cmd := c.Command("empty").Value.(*Command)
r := cmd.Run(Options{})
assert.False(t, r.OK)
}
@ -55,55 +54,51 @@ func TestCommand_Run_NoAction_Good(t *testing.T) {
func TestCommand_Nested_Good(t *testing.T) {
c := New()
c.Command("deploy/to/homelab", func(_ Options) Result {
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
return Result{Value: "deployed to homelab", OK: true}
})
}})
// Direct path lookup
cmd := c.Command("deploy/to/homelab")
assert.NotNil(t, cmd)
r := c.Command("deploy/to/homelab")
assert.True(t, r.OK)
// Parent auto-created
parent := c.Command("deploy")
assert.NotNil(t, parent)
mid := c.Command("deploy/to")
assert.NotNil(t, mid)
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", func(_ Options) Result { return Result{OK: true} })
c.Command("serve", func(_ Options) Result { return Result{OK: true} })
c.Command("deploy/to/homelab", func(_ Options) Result { return Result{OK: true} })
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") // auto-created parent
assert.Contains(t, paths, "deploy/to")
}
// --- I18n Key Derivation ---
func TestCommand_I18nKey_Good(t *testing.T) {
c := New()
c.Command("deploy/to/homelab", func(_ Options) Result { return Result{OK: true} })
cmd := c.Command("deploy/to/homelab").(*Command)
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", func(_ Options) Result { return Result{OK: true} }, Options{{K: "description", V: "custom.deploy.key"}})
cmd := c.Command("deploy").(*Command)
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", func(_ Options) Result { return Result{OK: true} })
cmd := c.Command("serve").(*Command)
c.Command("serve", Command{})
cmd := c.Command("serve").Value.(*Command)
assert.Equal(t, "cmd.serve.description", cmd.I18nKey())
}
@ -111,17 +106,15 @@ func TestCommand_I18nKey_Simple_Good(t *testing.T) {
func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) {
c := New()
c.Command("serve", func(_ Options) Result {
c.Command("serve", Command{Action: func(_ Options) Result {
return Result{Value: "running", OK: true}
})
cmd := c.Command("serve").(*Command)
}})
cmd := c.Command("serve").Value.(*Command)
// Start falls back to Run when no lifecycle impl
r := cmd.Start(Options{})
assert.True(t, r.OK)
assert.Equal(t, "running", r.Value)
// Stop/Restart/Reload/Signal return empty Result without lifecycle
assert.False(t, cmd.Stop().OK)
assert.False(t, cmd.Restart().OK)
assert.False(t, cmd.Reload().OK)
@ -132,6 +125,6 @@ func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) {
func TestCommand_EmptyPath_Bad(t *testing.T) {
c := New()
result := c.Command("", func(_ Options) Result { return Result{OK: true} })
assert.NotNil(t, result) // error
r := c.Command("", Command{})
assert.False(t, r.OK)
}

View file

@ -45,8 +45,8 @@ func TestI18n_T_NoTranslator_Good(t *testing.T) {
func TestI18n_SetLanguage_NoTranslator_Good(t *testing.T) {
c := New()
err := c.I18n().SetLanguage("de")
assert.NoError(t, err) // no-op without translator
r := c.I18n().SetLanguage("de")
assert.True(t, r.OK) // no-op without translator
}
func TestI18n_Language_NoTranslator_Good(t *testing.T) {