go/command.go
Snider aec61bc329 fix: Result.New handles (value, error) pairs correctly + embed test fixes
Root cause: Result.New didn't mark single-value results as OK=true,
breaking Mount/ReadDir/fs helpers that used Result{}.New(value, err).

Also: data_test.go and embed_test.go updated for Options struct,
doc comments updated across data.go, drive.go, command.go, contract.go.

All tests green. Coverage 82.2%.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-24 20:29:55 +00:00

208 lines
5.5 KiB
Go

// 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 (
"sync"
)
// 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
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
}
// 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
}
path := cmd.Path
if path == "" {
path = cmd.Name
}
return Concat("cmd.", Replace(path, "/", "."), ".description")
}
// Run executes the command's action with the given options.
//
// result := cmd.Run(core.NewOptions(core.Option{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}
}
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)
}
return cmd.Run(opts)
}
// Stop delegates to the lifecycle implementation.
func (cmd *Command) Stop() Result {
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()
}
return Result{}
}
// Reload delegates to the lifecycle implementation.
func (cmd *Command) Reload() Result {
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)
}
return Result{}
}
// --- Command Registry (on Core) ---
// 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}
}
if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") {
return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false}
}
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
}
}
}
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.
//
// paths := c.Commands()
func (c *Core) Commands() []string {
if c.commands == nil {
return nil
}
c.commands.mu.RLock()
defer c.commands.mu.RUnlock()
var paths []string
for k := range c.commands.commands {
paths = append(paths, k)
}
return paths
}
// 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]
}