feat(command): add trait-based command system with registry

Introduce a new trait-based command architecture that replaces the
previous JSON-based Command enum. The new system uses:

- Cmd trait: Commands are trait objects that produce Effects
- CmdRegistry: Central registry for parsing commands from text
- ParseFn: Function type for parsing string arguments into commands
- effect_cmd! macro: Helper macro for defining parseable commands

The registry allows commands to be registered by name and parsed from
Forth-style text arguments. This enables both TUI and headless modes
to use the same command parsing infrastructure.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
Edward Langley
2026-04-04 10:56:34 -07:00
parent 2be1eeae5d
commit 1e8bc7a135

View File

@ -43,6 +43,50 @@ pub trait Cmd: Debug + Send + Sync {
fn name(&self) -> &str;
}
/// A factory that parses string arguments into a boxed Cmd.
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
/// Registry of commands that can be constructed from text arguments.
pub struct CmdRegistry {
entries: Vec<(&'static str, ParseFn)>,
}
impl CmdRegistry {
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn register(&mut self, name: &'static str, parse: ParseFn) {
self.entries.push((name, parse));
}
pub fn parse(&self, name: &str, args: &[String]) -> Result<Box<dyn Cmd>, String> {
for (n, f) in &self.entries {
if *n == name {
return f(args);
}
}
Err(format!("Unknown command: {name}"))
}
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
self.entries.iter().map(|(n, _)| *n)
}
}
fn require_args(word: &str, args: &[String], n: usize) -> Result<(), String> {
if args.len() < n {
Err(format!(
"{word} requires {n} argument(s), got {}",
args.len()
))
} else {
Ok(())
}
}
// ── Navigation commands ──────────────────────────────────────────────────────
#[derive(Debug)]
@ -1548,6 +1592,451 @@ impl Cmd for CommandModeBackspace {
}
}
// ── Parseable model-mutation commands ────────────────────────────────────────
// These are thin Cmd wrappers around effects, constructible from string args.
// They share the same execution path as keymap-dispatched commands.
macro_rules! effect_cmd {
($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => {
#[derive(Debug)]
pub struct $name(pub Vec<String>);
impl Cmd for $name {
fn name(&self) -> &str {
$cmd_name
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let args = &self.0;
#[allow(clippy::redundant_closure_call)]
($exec)(args, ctx)
}
}
impl $name {
pub fn parse(args: &[String]) -> Result<Box<dyn Cmd>, String> {
#[allow(clippy::redundant_closure_call)]
($parse)(args)?;
Ok(Box::new($name(args.to_vec())))
}
}
};
}
effect_cmd!(
AddCategoryCmd,
"add-category",
|args: &[String]| require_args("add-category", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::AddCategory(args[0].clone()))]
}
);
effect_cmd!(
AddItemCmd,
"add-item",
|args: &[String]| require_args("add-item", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::AddItem {
category: args[0].clone(),
item: args[1].clone(),
})]
}
);
effect_cmd!(
AddItemInGroupCmd,
"add-item-in-group",
|args: &[String]| require_args("add-item-in-group", args, 3),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::AddItemInGroup {
category: args[0].clone(),
item: args[1].clone(),
group: args[2].clone(),
})]
}
);
effect_cmd!(
SetCellCmd,
"set-cell",
|args: &[String]| {
if args.len() < 2 {
Err("set-cell requires a value and at least one Cat/Item coordinate".to_string())
} else {
Ok(())
}
},
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
let value = if let Ok(n) = args[0].parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(args[0].clone())
};
let coords: Vec<(String, String)> = args[1..]
.iter()
.filter_map(|s| {
let (cat, item) = s.split_once('/')?;
Some((cat.to_string(), item.to_string()))
})
.collect();
let key = crate::model::cell::CellKey::new(coords);
vec![Box::new(effect::SetCell(key, value))]
}
);
effect_cmd!(
ClearCellCmd,
"clear-cell",
|args: &[String]| {
if args.is_empty() {
Err("clear-cell requires at least one Cat/Item coordinate".to_string())
} else {
Ok(())
}
},
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
let coords: Vec<(String, String)> = args
.iter()
.filter_map(|s| {
let (cat, item) = s.split_once('/')?;
Some((cat.to_string(), item.to_string()))
})
.collect();
let key = crate::model::cell::CellKey::new(coords);
vec![Box::new(effect::ClearCell(key))]
}
);
effect_cmd!(
AddFormulaCmd,
"add-formula",
|args: &[String]| require_args("add-formula", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::AddFormula {
target_category: args[0].clone(),
raw: args[1].clone(),
})]
}
);
effect_cmd!(
RemoveFormulaCmd,
"remove-formula",
|args: &[String]| require_args("remove-formula", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::RemoveFormula {
target_category: args[0].clone(),
target: args[1].clone(),
})]
}
);
effect_cmd!(
CreateViewCmd,
"create-view",
|args: &[String]| require_args("create-view", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::CreateView(args[0].clone()))]
}
);
effect_cmd!(
DeleteViewCmd,
"delete-view",
|args: &[String]| require_args("delete-view", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::DeleteView(args[0].clone()))]
}
);
effect_cmd!(
SwitchViewCmd,
"switch-view",
|args: &[String]| require_args("switch-view", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::SwitchView(args[0].clone()))]
}
);
effect_cmd!(
SetAxisCmd,
"set-axis",
|args: &[String]| require_args("set-axis", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
let axis = match args[1].to_lowercase().as_str() {
"row" => Axis::Row,
"column" | "col" => Axis::Column,
"page" => Axis::Page,
"none" => Axis::None,
_ => return vec![], // parse step already validated
};
vec![Box::new(effect::SetAxis {
category: args[0].clone(),
axis,
})]
}
);
effect_cmd!(
SetPageCmd,
"set-page",
|args: &[String]| require_args("set-page", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::SetPageSelection {
category: args[0].clone(),
item: args[1].clone(),
})]
}
);
effect_cmd!(
ToggleGroupCmd,
"toggle-group",
|args: &[String]| require_args("toggle-group", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::ToggleGroup {
category: args[0].clone(),
group: args[1].clone(),
})]
}
);
effect_cmd!(
HideItemCmd,
"hide-item",
|args: &[String]| require_args("hide-item", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::HideItem {
category: args[0].clone(),
item: args[1].clone(),
})]
}
);
effect_cmd!(
ShowItemCmd,
"show-item",
|args: &[String]| require_args("show-item", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::ShowItem {
category: args[0].clone(),
item: args[1].clone(),
})]
}
);
effect_cmd!(
SaveAsCmd,
"save",
|args: &[String]| require_args("save", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(
&args[0],
)))]
}
);
effect_cmd!(
LoadModelCmd,
"load",
|args: &[String]| require_args("load", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::LoadModel(std::path::PathBuf::from(
&args[0],
)))]
}
);
effect_cmd!(
ExportCsvCmd,
"export-csv",
|args: &[String]| require_args("export-csv", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(
&args[0],
)))]
}
);
effect_cmd!(
ImportJsonCmd,
"import-json",
|args: &[String]| {
if args.is_empty() {
Err("import-json requires a path".to_string())
} else {
Ok(())
}
},
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::ImportJsonHeadless {
path: std::path::PathBuf::from(&args[0]),
model_name: args.get(1).cloned(),
array_path: args.get(2).cloned(),
})]
}
);
/// Build the default command registry with all parseable commands.
pub fn default_registry() -> CmdRegistry {
let mut r = CmdRegistry::new();
// Model mutations (effect_cmd! wrappers)
r.register("add-category", AddCategoryCmd::parse);
r.register("add-item", AddItemCmd::parse);
r.register("add-item-in-group", AddItemInGroupCmd::parse);
r.register("set-cell", SetCellCmd::parse);
r.register("clear-cell", ClearCellCmd::parse);
r.register("add-formula", AddFormulaCmd::parse);
r.register("remove-formula", RemoveFormulaCmd::parse);
r.register("create-view", CreateViewCmd::parse);
r.register("delete-view", DeleteViewCmd::parse);
r.register("switch-view", SwitchViewCmd::parse);
r.register("set-axis", SetAxisCmd::parse);
r.register("set-page", SetPageCmd::parse);
r.register("toggle-group", ToggleGroupCmd::parse);
r.register("hide-item", HideItemCmd::parse);
r.register("show-item", ShowItemCmd::parse);
r.register("save", SaveAsCmd::parse);
r.register("load", LoadModelCmd::parse);
r.register("export-csv", ExportCsvCmd::parse);
r.register("import-json", ImportJsonCmd::parse);
// Navigation (zero-arg or simple-arg commands that already exist as Cmd structs)
r.register("move-selection", |args| {
require_args("move-selection", args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MoveSelection { dr, dc }))
});
r.register("jump-first-row", |_| Ok(Box::new(JumpToFirstRow)));
r.register("jump-last-row", |_| Ok(Box::new(JumpToLastRow)));
r.register("jump-first-col", |_| Ok(Box::new(JumpToFirstCol)));
r.register("jump-last-col", |_| Ok(Box::new(JumpToLastCol)));
r.register("scroll-rows", |args| {
require_args("scroll-rows", args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(ScrollRows(n)))
});
r.register("enter-advance", |_| Ok(Box::new(EnterAdvance)));
// Cell operations
r.register("yank", |_| Ok(Box::new(YankCell)));
r.register("paste", |_| Ok(Box::new(PasteCell)));
r.register("clear-selected-cell", |_| Ok(Box::new(ClearSelectedCell)));
// View operations
r.register("transpose", |_| Ok(Box::new(TransposeAxes)));
r.register("page-next", |_| Ok(Box::new(PageNext)));
r.register("page-prev", |_| Ok(Box::new(PagePrev)));
// Mode changes
r.register("force-quit", |_| Ok(Box::new(ForceQuit)));
r.register("save-and-quit", |_| Ok(Box::new(SaveAndQuit)));
r.register("save-cmd", |_| Ok(Box::new(SaveCmd)));
r.register("enter-search", |_| Ok(Box::new(EnterSearchMode)));
r.register("enter-edit", |_| Ok(Box::new(EnterEditMode)));
r.register("enter-export-prompt", |_| Ok(Box::new(EnterExportPrompt)));
r.register("enter-formula-edit", |_| Ok(Box::new(EnterFormulaEdit)));
r.register("enter-tile-select", |_| Ok(Box::new(EnterTileSelect)));
r.register("enter-mode", |args| {
require_args("enter-mode", args, 1)?;
let mode = match args[0].as_str() {
"normal" => AppMode::Normal,
"help" => AppMode::Help,
"formula-panel" => AppMode::FormulaPanel,
"category-panel" => AppMode::CategoryPanel,
"view-panel" => AppMode::ViewPanel,
"tile-select" => AppMode::TileSelect,
"command" => AppMode::CommandMode { buffer: String::new() },
other => return Err(format!("Unknown mode: {other}")),
};
Ok(Box::new(EnterMode(mode)))
});
// Search
r.register("search-navigate", |args| {
let forward = args.first().map(|s| s != "backward").unwrap_or(true);
Ok(Box::new(SearchNavigate(forward)))
});
r.register("exit-search", |_| Ok(Box::new(ExitSearchMode)));
// Panel operations
r.register("toggle-panel", |args| {
require_args("toggle-panel", args, 1)?;
let panel = match args[0].as_str() {
"formula" => effect::Panel::Formula,
"category" => effect::Panel::Category,
"view" => effect::Panel::View,
other => return Err(format!("Unknown panel: {other}")),
};
Ok(Box::new(TogglePanelAndFocus(panel)))
});
r.register("cycle-panel-focus", |_| Ok(Box::new(CyclePanelFocus)));
r.register("move-panel-cursor", |args| {
require_args("move-panel-cursor", args, 2)?;
let panel = match args[0].as_str() {
"formula" => effect::Panel::Formula,
"category" => effect::Panel::Category,
"view" => effect::Panel::View,
other => return Err(format!("Unknown panel: {other}")),
};
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MovePanelCursor { panel, delta }))
});
r.register("delete-formula-at-cursor", |_| Ok(Box::new(DeleteFormulaAtCursor)));
r.register("cycle-axis-at-cursor", |_| Ok(Box::new(CycleAxisAtCursor)));
r.register("open-item-add-at-cursor", |_| Ok(Box::new(OpenItemAddAtCursor)));
r.register("switch-view-at-cursor", |_| Ok(Box::new(SwitchViewAtCursor)));
r.register("create-and-switch-view", |_| Ok(Box::new(CreateAndSwitchView)));
r.register("delete-view-at-cursor", |_| Ok(Box::new(DeleteViewAtCursor)));
// Tile select
r.register("move-tile-cursor", |args| {
require_args("move-tile-cursor", args, 1)?;
let delta = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MoveTileCursor(delta)))
});
r.register("cycle-axis-for-tile", |_| Ok(Box::new(CycleAxisForTile)));
r.register("set-axis-for-tile", |args| {
require_args("set-axis-for-tile", args, 1)?;
let axis = match args[0].to_lowercase().as_str() {
"row" => Axis::Row,
"column" | "col" => Axis::Column,
"page" => Axis::Page,
"none" => Axis::None,
other => return Err(format!("Unknown axis: {other}")),
};
Ok(Box::new(SetAxisForTile(axis)))
});
// Grid operations
r.register("toggle-group-under-cursor", |_| Ok(Box::new(ToggleGroupUnderCursor)));
r.register("toggle-col-group-under-cursor", |_| Ok(Box::new(ToggleColGroupUnderCursor)));
r.register("hide-selected-row-item", |_| Ok(Box::new(HideSelectedRowItem)));
// Text buffer
r.register("append-char", |args| {
require_args("append-char", args, 1)?;
Ok(Box::new(AppendChar { buffer: args[0].clone() }))
});
r.register("pop-char", |args| {
require_args("pop-char", args, 1)?;
Ok(Box::new(PopChar { buffer: args[0].clone() }))
});
// Commit commands
r.register("commit-cell-edit", |_| Ok(Box::new(CommitCellEdit)));
r.register("commit-formula", |_| Ok(Box::new(CommitFormula)));
r.register("commit-category-add", |_| Ok(Box::new(CommitCategoryAdd)));
r.register("commit-item-add", |_| Ok(Box::new(CommitItemAdd)));
r.register("commit-export", |_| Ok(Box::new(CommitExport)));
r.register("execute-command", |_| Ok(Box::new(ExecuteCommand)));
// Wizard
r.register("handle-wizard-key", |_| Ok(Box::new(HandleWizardKey)));
r
}
#[cfg(test)]
mod tests {
use super::*;