diff --git a/pkg/core/cli.go b/pkg/core/cli.go index b500f8f..b180fe0 100644 --- a/pkg/core/cli.go +++ b/pkg/core/cli.go @@ -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()) diff --git a/pkg/core/command.go b/pkg/core/command.go index 5cbacf5..e8d7a90 100644 --- a/pkg/core/command.go +++ b/pkg/core/command.go @@ -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. diff --git a/pkg/core/i18n.go b/pkg/core/i18n.go index e8ff836..31f3744 100644 --- a/pkg/core/i18n.go +++ b/pkg/core/i18n.go @@ -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. diff --git a/tests/cli_test.go b/tests/cli_test.go index fa0091b..497dffb 100644 --- a/tests/cli_test.go +++ b/tests/cli_test.go @@ -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() } diff --git a/tests/command_test.go b/tests/command_test.go index a2a8e98..8efb609 100644 --- a/tests/command_test.go +++ b/tests/command_test.go @@ -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) } diff --git a/tests/i18n_test.go b/tests/i18n_test.go index d29a239..cc35859 100644 --- a/tests/i18n_test.go +++ b/tests/i18n_test.go @@ -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) {