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:
@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user