From 1e8bc7a1352799214af34cb0ce65453b6530d2f4 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 4 Apr 2026 10:56:34 -0700 Subject: [PATCH] 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) --- src/command/cmd.rs | 489 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 212f9f2..7071601 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -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, 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, String> { + for (n, f) in &self.entries { + if *n == name { + return f(args); + } + } + Err(format!("Unknown command: {name}")) + } + + pub fn names(&self) -> impl Iterator + '_ { + 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); + impl Cmd for $name { + fn name(&self) -> &str { + $cmd_name + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let args = &self.0; + #[allow(clippy::redundant_closure_call)] + ($exec)(args, ctx) + } + } + impl $name { + pub fn parse(args: &[String]) -> Result, 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, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::AddCategory(args[0].clone()))] + } +); + +effect_cmd!( + AddItemCmd, + "add-item", + |args: &[String]| require_args("add-item", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + let value = if let Ok(n) = args[0].parse::() { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::CreateView(args[0].clone()))] + } +); + +effect_cmd!( + DeleteViewCmd, + "delete-view", + |args: &[String]| require_args("delete-view", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::DeleteView(args[0].clone()))] + } +); + +effect_cmd!( + SwitchViewCmd, + "switch-view", + |args: &[String]| require_args("switch-view", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::SwitchView(args[0].clone()))] + } +); + +effect_cmd!( + SetAxisCmd, + "set-axis", + |args: &[String]| require_args("set-axis", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::SaveAs(std::path::PathBuf::from( + &args[0], + )))] + } +); + +effect_cmd!( + LoadModelCmd, + "load", + |args: &[String]| require_args("load", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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, _ctx: &CmdContext| -> Vec> { + 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::().map_err(|e| e.to_string())?; + let dc = args[1].parse::().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::().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::().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::().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::*;