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); impl Cmd for $name { fn name(&self) -> &'static 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!( 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, _ctx: &CmdContext| -> Vec> { let category = &args[0]; args[1..] .iter() .map(|item| -> Box { 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, _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!( 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, _ctx: &CmdContext| -> Vec> { // 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, _ctx: &CmdContext| -> Vec> { 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, _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-as", |args: &[String]| require_args("save-as", args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { 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, _ctx: &CmdContext| -> Vec> { vec![ Box::new(effect::SetNumberFormat(args.join(" "))), effect::mark_dirty(), ] } ); effect_cmd!( ImportCmd, "import", |args: &[String]| require_args("import", args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::StartImportWizard(args[0].clone()))] } ); effect_cmd!( ExportCmd, "export", |_args: &[String]| -> Result<(), String> { Ok(()) }, |args: &Vec, _ctx: &CmdContext| -> Vec> { 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, _ctx: &CmdContext| -> Vec> { 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, _ctx: &CmdContext| -> Vec> { 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, _ctx: &CmdContext| -> Vec> { vec![effect::help_page_next()] } ); effect_cmd!( HelpPagePrevCmd, "help-page-prev", |_args: &[String]| -> Result<(), String> { Ok(()) }, |_args: &Vec, _ctx: &CmdContext| -> Vec> { vec![effect::help_page_prev()] } ); 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(), })] } );