diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 7071601..18cec24 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -238,23 +238,6 @@ impl Cmd for EnterMode { } } -#[derive(Debug)] -pub struct QuitCmd; -impl Cmd for QuitCmd { - fn name(&self) -> &str { - "quit" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if ctx.dirty { - vec![effect::set_status( - "Unsaved changes! Use :wq to save+quit or :q! to force quit", - )] - } else { - vec![effect::change_mode(AppMode::Quit)] - } - } -} - #[derive(Debug)] pub struct ForceQuit; impl Cmd for ForceQuit { @@ -1364,28 +1347,6 @@ impl Cmd for PopChar { } } -/// Initialize a named buffer (set it to a value) and change mode. -#[derive(Debug)] -pub struct InitBuffer { - pub buffer: String, - pub value: String, - pub mode: AppMode, -} -impl Cmd for InitBuffer { - fn name(&self) -> &str { - "init-buffer" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![ - Box::new(effect::SetBuffer { - name: self.buffer.clone(), - value: self.value.clone(), - }), - effect::change_mode(self.mode.clone()), - ] - } -} - // ── Commit commands (mode-specific buffer consumers) ──────────────────────── /// Commit a cell edit: parse buffer, set cell, advance cursor, return to Normal. diff --git a/src/command/dispatch.rs b/src/command/dispatch.rs deleted file mode 100644 index 2738be1..0000000 --- a/src/command/dispatch.rs +++ /dev/null @@ -1,254 +0,0 @@ -use std::path::PathBuf; - -use super::types::{CellValueArg, Command, CommandResult}; -use crate::formula::parse_formula; -use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind}; -use crate::model::cell::{CellKey, CellValue}; -use crate::model::Model; -use crate::persistence; - -/// Execute a command against the model, returning a result. -/// This is the single authoritative mutation path used by both the TUI and headless modes. -pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult { - match cmd { - Command::AddCategory { name } => match model.add_category(name) { - Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")), - Err(e) => CommandResult::err(e.to_string()), - }, - - Command::AddItem { category, item } => match model.category_mut(category) { - Some(cat) => { - cat.add_item(item); - CommandResult::ok() - } - None => CommandResult::err(format!("Category '{category}' not found")), - }, - - Command::AddItemInGroup { - category, - item, - group, - } => match model.category_mut(category) { - Some(cat) => { - cat.add_item_in_group(item, group); - CommandResult::ok() - } - None => CommandResult::err(format!("Category '{category}' not found")), - }, - - Command::SetCell { coords, value } => { - let kv: Vec<(String, String)> = coords - .iter() - .map(|pair| (pair[0].clone(), pair[1].clone())) - .collect(); - // Validate all categories exist before mutating anything - for (cat_name, _) in &kv { - if model.category(cat_name).is_none() { - return CommandResult::err(format!("Category '{cat_name}' not found")); - } - } - // Ensure items exist within their categories - for (cat_name, item_name) in &kv { - model.category_mut(cat_name).unwrap().add_item(item_name); - } - let key = CellKey::new(kv); - let cell_value = match value { - CellValueArg::Number { number } => CellValue::Number(*number), - CellValueArg::Text { text } => CellValue::Text(text.clone()), - }; - model.set_cell(key, cell_value); - CommandResult::ok() - } - - Command::ClearCell { coords } => { - let kv: Vec<(String, String)> = coords - .iter() - .map(|pair| (pair[0].clone(), pair[1].clone())) - .collect(); - let key = CellKey::new(kv); - model.clear_cell(&key); - CommandResult::ok() - } - - Command::AddFormula { - raw, - target_category, - } => { - match parse_formula(raw, target_category) { - Ok(formula) => { - // Ensure the target item exists in the target category - let target = formula.target.clone(); - let cat_name = formula.target_category.clone(); - if let Some(cat) = model.category_mut(&cat_name) { - cat.add_item(&target); - } - model.add_formula(formula); - CommandResult::ok_msg(format!("Formula '{raw}' added")) - } - Err(e) => CommandResult::err(format!("Parse error: {e}")), - } - } - - Command::RemoveFormula { - target, - target_category, - } => { - model.remove_formula(target, target_category); - CommandResult::ok() - } - - Command::CreateView { name } => { - model.create_view(name); - CommandResult::ok() - } - - Command::DeleteView { name } => match model.delete_view(name) { - Ok(_) => CommandResult::ok(), - Err(e) => CommandResult::err(e.to_string()), - }, - - Command::SwitchView { name } => match model.switch_view(name) { - Ok(_) => CommandResult::ok(), - Err(e) => CommandResult::err(e.to_string()), - }, - - Command::SetAxis { category, axis } => { - model.active_view_mut().set_axis(category, *axis); - CommandResult::ok() - } - - Command::SetPageSelection { category, item } => { - model.active_view_mut().set_page_selection(category, item); - CommandResult::ok() - } - - Command::ToggleGroup { category, group } => { - model - .active_view_mut() - .toggle_group_collapse(category, group); - CommandResult::ok() - } - - Command::HideItem { category, item } => { - model.active_view_mut().hide_item(category, item); - CommandResult::ok() - } - - Command::ShowItem { category, item } => { - model.active_view_mut().show_item(category, item); - CommandResult::ok() - } - - Command::Save { path } => match persistence::save(model, std::path::Path::new(path)) { - Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")), - Err(e) => CommandResult::err(e.to_string()), - }, - - Command::Load { path } => match persistence::load(std::path::Path::new(path)) { - Ok(mut loaded) => { - loaded.normalize_view_state(); - *model = loaded; - CommandResult::ok_msg(format!("Loaded from {path}")) - } - Err(e) => CommandResult::err(e.to_string()), - }, - - Command::ExportCsv { path } => { - let view_name = model.active_view.clone(); - match persistence::export_csv(model, &view_name, std::path::Path::new(path)) { - Ok(_) => CommandResult::ok_msg(format!("Exported to {path}")), - Err(e) => CommandResult::err(e.to_string()), - } - } - - Command::ImportJson { - path, - model_name, - array_path, - } => import_headless(model, path, model_name.as_deref(), array_path.as_deref()), - } -} - -fn import_headless( - model: &mut Model, - path: &PathBuf, - model_name: Option<&str>, - array_path: Option<&str>, -) -> CommandResult { - let is_csv = path - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("csv")); - - let records = if is_csv { - // Parse CSV file - match crate::import::csv_parser::parse_csv(path) { - Ok(recs) => recs, - Err(e) => return CommandResult::err(e.to_string()), - } - } else { - // Parse JSON file - let content = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(e) => return CommandResult::err(format!("Cannot read '{}': {e}", path.display())), - }; - let value: serde_json::Value = match serde_json::from_str(&content) { - Ok(v) => v, - Err(e) => return CommandResult::err(format!("JSON parse error: {e}")), - }; - - if let Some(ap) = array_path.filter(|s| !s.is_empty()) { - match extract_array_at_path(&value, ap) { - Some(arr) => arr.clone(), - None => return CommandResult::err(format!("No array at path '{ap}'")), - } - } else if let Some(arr) = value.as_array() { - arr.clone() - } else { - let paths = crate::import::analyzer::find_array_paths(&value); - if let Some(first) = paths.first() { - match extract_array_at_path(&value, first) { - Some(arr) => arr.clone(), - None => return CommandResult::err("Could not extract records array"), - } - } else { - return CommandResult::err("No array found in JSON"); - } - } - }; - - let proposals = analyze_records(&records); - - // Build via ImportPipeline - let raw = if is_csv { - serde_json::Value::Array(records.clone()) - } else { - // For JSON, we need the original parsed value - // Re-read and parse to get it (or pass it up from above) - serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default()) - .unwrap_or(serde_json::Value::Array(records.clone())) - }; - - let pipeline = crate::import::wizard::ImportPipeline { - raw, - array_paths: vec![], - selected_path: array_path.unwrap_or("").to_string(), - records, - proposals: proposals - .into_iter() - .map(|mut p| { - p.accepted = p.kind != FieldKind::Label; - p - }) - .collect(), - model_name: model_name.unwrap_or("Imported Model").to_string(), - formulas: vec![], - }; - - match pipeline.build_model() { - Ok(new_model) => { - *model = new_model; - CommandResult::ok_msg("Imported successfully") - } - Err(e) => CommandResult::err(e.to_string()), - } -} diff --git a/src/command/types.rs b/src/command/types.rs deleted file mode 100644 index 9c5ab45..0000000 --- a/src/command/types.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::path::PathBuf; - -use crate::view::Axis; -use serde::{Deserialize, Serialize}; - -/// All commands that can mutate a Model. -/// -/// Serialized as `{"op": "", ...rest}` where `rest` contains -/// the variant's fields flattened into the same JSON object. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "op")] -pub enum Command { - /// Add a category (dimension). - AddCategory { name: String }, - - /// Add an item to a category. - AddItem { category: String, item: String }, - - /// Add an item inside a named group. - AddItemInGroup { - category: String, - item: String, - group: String, - }, - - /// Set a cell value. `coords` is a list of `[category, item]` pairs. - SetCell { - coords: Vec<[String; 2]>, - #[serde(flatten)] - value: CellValueArg, - }, - - /// Clear a cell. - ClearCell { coords: Vec<[String; 2]> }, - - /// Add or replace a formula. - /// `raw` is the full formula string, e.g. "Profit = Revenue - Cost". - /// `target_category` names the category that owns the formula target. - AddFormula { - raw: String, - target_category: String, - }, - - /// Remove a formula by its target name and category. - RemoveFormula { - target: String, - target_category: String, - }, - - /// Create a new view. - CreateView { name: String }, - - /// Delete a view. - DeleteView { name: String }, - - /// Switch the active view. - SwitchView { name: String }, - - /// Set the axis of a category in the active view. - SetAxis { category: String, axis: Axis }, - - /// Set the page-axis selection for a category. - SetPageSelection { category: String, item: String }, - - /// Toggle collapse of a group in the active view. - ToggleGroup { category: String, group: String }, - - /// Hide an item in the active view. - HideItem { category: String, item: String }, - - /// Show (un-hide) an item in the active view. - ShowItem { category: String, item: String }, - - /// Save the model to a file path. - Save { path: String }, - - /// Load a model from a file path (replaces current model). - Load { path: String }, - - /// Export the active view to CSV. - ExportCsv { path: String }, - - /// Import a JSON file via the analyzer (non-interactive, uses auto-detected proposals). - ImportJson { - path: PathBuf, - model_name: Option, - /// Dot-path to the records array (empty = root) - array_path: Option, - }, -} - -/// Inline value for SetCell -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum CellValueArg { - Number { number: f64 }, - Text { text: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommandResult { - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -impl CommandResult { - pub fn ok() -> Self { - Self { - ok: true, - message: None, - } - } - pub fn ok_msg(msg: impl Into) -> Self { - Self { - ok: true, - message: Some(msg.into()), - } - } - pub fn err(msg: impl Into) -> Self { - Self { - ok: false, - message: Some(msg.into()), - } - } -}