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:
parent
3bab201229
commit
9bcb367dd0
6 changed files with 116 additions and 148 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue