diff --git a/src/command/mod.rs b/src/command/mod.rs index cf30c06..bf8d288 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,16 +1,12 @@ //! Command layer — all model mutations go through this layer so they can be //! replayed, scripted, and tested without the TUI. //! -//! Each command is a JSON object: `{"op": "CommandName", ...args}`. -//! The headless CLI (--cmd / --script) routes through here, and the TUI -//! App also calls dispatch() for every user action that mutates state. +//! Commands are trait objects (`dyn Cmd`) that produce effects (`dyn Effect`). +//! The headless CLI (--cmd / --script) parses Forth-style text into effects +//! and applies them directly. pub mod cmd; -pub mod dispatch; pub mod keymap; pub mod parse; -pub mod types; -pub use dispatch::dispatch; pub use parse::parse_line; -pub use types::{Command, CommandResult}; diff --git a/src/command/parse.rs b/src/command/parse.rs index 5cf5bb3..8d633a0 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -5,14 +5,16 @@ //! Coordinate pairs use `/`: `Category/Item` //! Quoted strings supported: `"Profit = Revenue - Cost"` -use std::path::PathBuf; +use super::cmd::{default_registry, Cmd, CmdRegistry}; -use super::types::{CellValueArg, Command}; -use crate::view::Axis; +/// Parse a line into commands using the default registry. +pub fn parse_line(line: &str) -> Result>, String> { + let registry = default_registry(); + parse_line_with(®istry, line) +} -/// Parse a line into one or more commands. -/// Commands are separated by `.` on a single line. -pub fn parse_line(line: &str) -> Result, String> { +/// Parse a line into commands using a given registry. +pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result>, String> { let line = line.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with("//") { return Ok(vec![]); @@ -24,7 +26,13 @@ pub fn parse_line(line: &str) -> Result, String> { if segment.is_empty() { continue; } - commands.push(parse_command(segment)?); + let tokens = tokenize(segment); + if tokens.is_empty() { + continue; + } + let word = &tokens[0]; + let args = &tokens[1..]; + commands.push(registry.parse(word, args)?); } Ok(commands) } @@ -84,308 +92,71 @@ fn tokenize(input: &str) -> Vec { tokens } -/// Parse a single command from tokens. -fn parse_command(segment: &str) -> Result { - let tokens = tokenize(segment); - if tokens.is_empty() { - return Err("Empty command".to_string()); - } - - let word = tokens[0].as_str(); - let args = &tokens[1..]; - - match word { - "add-category" => { - require_args(word, args, 1)?; - Ok(Command::AddCategory { - name: args[0].clone(), - }) - } - - "add-item" => { - require_args(word, args, 2)?; - Ok(Command::AddItem { - category: args[0].clone(), - item: args[1].clone(), - }) - } - - "add-item-in-group" => { - require_args(word, args, 3)?; - Ok(Command::AddItemInGroup { - category: args[0].clone(), - item: args[1].clone(), - group: args[2].clone(), - }) - } - - "set-cell" => { - if args.len() < 2 { - return Err( - "set-cell requires a value and at least one Cat/Item coordinate".to_string(), - ); - } - let value = parse_cell_value(&args[0])?; - let coords = parse_coords(&args[1..])?; - Ok(Command::SetCell { coords, value }) - } - - "clear-cell" => { - if args.is_empty() { - return Err("clear-cell requires at least one Cat/Item coordinate".to_string()); - } - let coords = parse_coords(args)?; - Ok(Command::ClearCell { coords }) - } - - "add-formula" => { - require_args(word, args, 2)?; - Ok(Command::AddFormula { - target_category: args[0].clone(), - raw: args[1].clone(), - }) - } - - "remove-formula" => { - require_args(word, args, 2)?; - Ok(Command::RemoveFormula { - target_category: args[0].clone(), - target: args[1].clone(), - }) - } - - "create-view" => { - require_args(word, args, 1)?; - Ok(Command::CreateView { - name: args[0].clone(), - }) - } - - "delete-view" => { - require_args(word, args, 1)?; - Ok(Command::DeleteView { - name: args[0].clone(), - }) - } - - "switch-view" => { - require_args(word, args, 1)?; - Ok(Command::SwitchView { - name: args[0].clone(), - }) - } - - "set-axis" => { - require_args(word, args, 2)?; - let axis = parse_axis(&args[1])?; - Ok(Command::SetAxis { - category: args[0].clone(), - axis, - }) - } - - "set-page" => { - require_args(word, args, 2)?; - Ok(Command::SetPageSelection { - category: args[0].clone(), - item: args[1].clone(), - }) - } - - "toggle-group" => { - require_args(word, args, 2)?; - Ok(Command::ToggleGroup { - category: args[0].clone(), - group: args[1].clone(), - }) - } - - "hide-item" => { - require_args(word, args, 2)?; - Ok(Command::HideItem { - category: args[0].clone(), - item: args[1].clone(), - }) - } - - "show-item" => { - require_args(word, args, 2)?; - Ok(Command::ShowItem { - category: args[0].clone(), - item: args[1].clone(), - }) - } - - "save" => { - require_args(word, args, 1)?; - Ok(Command::Save { - path: args[0].clone(), - }) - } - - "load" => { - require_args(word, args, 1)?; - Ok(Command::Load { - path: args[0].clone(), - }) - } - - "export-csv" => { - require_args(word, args, 1)?; - Ok(Command::ExportCsv { - path: args[0].clone(), - }) - } - - "import-json" => { - if args.is_empty() { - return Err("import-json requires a path".to_string()); - } - Ok(Command::ImportJson { - path: PathBuf::from(&args[0]), - model_name: args.get(1).cloned(), - array_path: args.get(2).cloned(), - }) - } - - _ => Err(format!("Unknown command: {word}")), - } -} - -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(()) - } -} - -fn parse_coords(args: &[String]) -> Result, String> { - args.iter() - .map(|s| { - let (cat, item) = s - .split_once('/') - .ok_or_else(|| format!("Expected Cat/Item coordinate, got: {s}"))?; - Ok([cat.to_string(), item.to_string()]) - }) - .collect() -} - -fn parse_cell_value(s: &str) -> Result { - if let Ok(n) = s.parse::() { - Ok(CellValueArg::Number { number: n }) - } else { - Ok(CellValueArg::Text { - text: s.to_string(), - }) - } -} - -fn parse_axis(s: &str) -> Result { - match s.to_lowercase().as_str() { - "row" => Ok(Axis::Row), - "column" | "col" => Ok(Axis::Column), - "page" => Ok(Axis::Page), - "none" => Ok(Axis::None), - _ => Err(format!( - "Unknown axis: {s} (expected row, column, page, none)" - )), - } -} - #[cfg(test)] mod tests { use super::*; - use crate::command::types::CellValueArg; #[test] fn parse_add_category() { let cmds = parse_line("add-category Region").unwrap(); assert_eq!(cmds.len(), 1); - assert!(matches!(&cmds[0], Command::AddCategory { name } if name == "Region")); + assert_eq!(cmds[0].name(), "add-category"); } #[test] fn parse_add_item() { let cmds = parse_line("add-item Region East").unwrap(); - assert!(matches!(&cmds[0], Command::AddItem { category, item } - if category == "Region" && item == "East")); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name(), "add-item"); } #[test] fn parse_set_cell_number() { let cmds = parse_line("set-cell 100 Region/East Measure/Revenue").unwrap(); assert_eq!(cmds.len(), 1); - match &cmds[0] { - Command::SetCell { coords, value } => { - assert_eq!(coords.len(), 2); - assert_eq!(coords[0], ["Region", "East"]); - assert_eq!(coords[1], ["Measure", "Revenue"]); - assert!(matches!(value, CellValueArg::Number { number } if *number == 100.0)); - } - _ => panic!("Expected SetCell"), - } + assert_eq!(cmds[0].name(), "set-cell"); } #[test] fn parse_set_cell_text() { let cmds = parse_line("set-cell hello Region/East").unwrap(); - match &cmds[0] { - Command::SetCell { value, .. } => { - assert!(matches!(value, CellValueArg::Text { text } if text == "hello")); - } - _ => panic!("Expected SetCell"), - } + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name(), "set-cell"); } #[test] fn parse_multiple_commands_dot_separated() { let cmds = parse_line("add-category Region . add-item Region East").unwrap(); assert_eq!(cmds.len(), 2); - assert!(matches!(&cmds[0], Command::AddCategory { .. })); - assert!(matches!(&cmds[1], Command::AddItem { .. })); + assert_eq!(cmds[0].name(), "add-category"); + assert_eq!(cmds[1].name(), "add-item"); } #[test] fn parse_quoted_string() { - let cmds = parse_line(r#"add-formula Measure "Profit = Revenue - Cost""#).unwrap(); - match &cmds[0] { - Command::AddFormula { - target_category, - raw, - } => { - assert_eq!(target_category, "Measure"); - assert_eq!(raw, "Profit = Revenue - Cost"); - } - _ => panic!("Expected AddFormula"), - } + let cmds = + parse_line(r#"add-formula Measure "Profit = Revenue - Cost""#).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name(), "add-formula"); } #[test] fn parse_set_axis() { let cmds = parse_line("set-axis Payee row").unwrap(); - assert!(matches!(&cmds[0], Command::SetAxis { category, axis } - if category == "Payee" && *axis == Axis::Row)); + assert_eq!(cmds[0].name(), "set-axis"); } #[test] fn parse_set_axis_none() { let cmds = parse_line("set-axis Date none").unwrap(); - assert!(matches!(&cmds[0], Command::SetAxis { axis, .. } if *axis == Axis::None)); + assert_eq!(cmds[0].name(), "set-axis"); } #[test] fn parse_clear_cell() { let cmds = parse_line("clear-cell Region/East Measure/Revenue").unwrap(); - match &cmds[0] { - Command::ClearCell { coords } => { - assert_eq!(coords.len(), 2); - } - _ => panic!("Expected ClearCell"), - } + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name(), "clear-cell"); } #[test]