467 lines
13 KiB
Rust
467 lines
13 KiB
Rust
use crate::model::cell::CellValue;
|
|
use crate::ui::effect::{self, Effect};
|
|
use crate::view::Axis;
|
|
|
|
use super::core::{Cmd, CmdContext, require_args};
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::command::cmd::test_helpers::*;
|
|
|
|
#[test]
|
|
fn add_category_cmd_produces_add_category_effect() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = AddCategoryCmd(vec!["Region".to_string()]);
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 1);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(
|
|
dbg.contains("AddCategory"),
|
|
"Expected AddCategory, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_cell_cmd_parses_coords_correctly() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = SetCellCmd(vec![
|
|
"42".to_string(),
|
|
"Type/Food".to_string(),
|
|
"Month/Jan".to_string(),
|
|
]);
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 1);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}");
|
|
}
|
|
|
|
#[test]
|
|
fn set_axis_cmd_recognizes_column_alias() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]);
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 1);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}");
|
|
}
|
|
|
|
#[test]
|
|
fn write_cmd_without_args_saves() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = WriteCmd(vec![]);
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 1);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
|
}
|
|
|
|
#[test]
|
|
fn write_cmd_with_path_saves_as() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = WriteCmd(vec!["/tmp/out.improv".to_string()]);
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 1);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("SaveAs"), "Expected SaveAs, got: {dbg}");
|
|
}
|
|
}
|
|
|
|
// ── 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) -> &'static 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!(
|
|
AddItemsCmd,
|
|
"add-items",
|
|
|args: &[String]| {
|
|
if args.len() < 2 {
|
|
Err("add-items requires a category and at least one item".to_string())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
},
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
let category = &args[0];
|
|
args[1..]
|
|
.iter()
|
|
.map(|item| -> Box<dyn Effect> {
|
|
Box::new(effect::AddItem {
|
|
category: category.clone(),
|
|
item: item.clone(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
);
|
|
|
|
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!(
|
|
AddFormulaCmd,
|
|
"add-formula",
|
|
|args: &[String]| {
|
|
if args.is_empty() || args.len() > 2 {
|
|
return Err(format!(
|
|
"add-formula requires 1-2 argument(s), got {}",
|
|
args.len()
|
|
));
|
|
}
|
|
Ok(())
|
|
},
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
// 1 arg: formula text (target_category defaults to _Measure)
|
|
// 2 args: target_category, formula text
|
|
let (cat, raw) = if args.len() == 2 {
|
|
(args[0].clone(), args[1].clone())
|
|
} else {
|
|
("_Measure".to_string(), args[0].clone())
|
|
};
|
|
vec![Box::new(effect::AddFormula {
|
|
target_category: cat,
|
|
raw,
|
|
})]
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
ClearBufferCmd,
|
|
"clear-buffer",
|
|
|args: &[String]| require_args("clear-buffer", args, 1),
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::SetBuffer {
|
|
name: args[0].clone(),
|
|
value: String::new(),
|
|
})]
|
|
}
|
|
);
|
|
|
|
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-as",
|
|
|args: &[String]| require_args("save-as", args, 1),
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
SetFormatCmd,
|
|
"set-format",
|
|
|args: &[String]| require_args("set-format", args, 1),
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
vec![
|
|
Box::new(effect::SetNumberFormat(args.join(" "))),
|
|
effect::mark_dirty(),
|
|
]
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
ImportCmd,
|
|
"import",
|
|
|args: &[String]| require_args("import", args, 1),
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::StartImportWizard(args[0].clone()))]
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
ExportCmd,
|
|
"export",
|
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv");
|
|
vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(path)))]
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
WriteCmd,
|
|
"w",
|
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
if args.is_empty() {
|
|
vec![Box::new(effect::Save)]
|
|
} else {
|
|
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
|
|
}
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
HelpCmd,
|
|
"help",
|
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
vec![
|
|
effect::help_page_set(0),
|
|
effect::change_mode(crate::ui::app::AppMode::Help),
|
|
]
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
HelpPageNextCmd,
|
|
"help-page-next",
|
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
vec![effect::help_page_next()]
|
|
}
|
|
);
|
|
|
|
effect_cmd!(
|
|
HelpPagePrevCmd,
|
|
"help-page-prev",
|
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|
vec![effect::help_page_prev()]
|
|
}
|
|
);
|
|
|
|
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(),
|
|
})]
|
|
}
|
|
);
|