refactor(command): remove old Command enum and dispatch system
Remove the old JSON-based command infrastructure: - Delete Command enum and CommandResult from types.rs - Remove QuitCmd and InitBuffer command implementations - Delete entire dispatch.rs file that handled command execution - Remove Command type exports from mod.rs The old system used a monolithic Command enum with serde serialization. The new trait-based system is more flexible and doesn't require JSON serialization for command execution. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
@ -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<Box<dyn Effect>> {
|
||||
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<Box<dyn Effect>> {
|
||||
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.
|
||||
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
@ -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": "<variant>", ...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<String>,
|
||||
/// Dot-path to the records array (empty = root)
|
||||
array_path: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
impl CommandResult {
|
||||
pub fn ok() -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
pub fn ok_msg(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
message: Some(msg.into()),
|
||||
}
|
||||
}
|
||||
pub fn err(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
message: Some(msg.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user