From 6647be30fa99bb88e3dde9f00080efc0ef947ad9 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 3 Apr 2026 21:43:16 -0700 Subject: [PATCH] refactor: switch to clap with subcommands for CLI parsing Replace hand-rolled arg parser with clap derive. Restructure as subcommands: import, cmd, script. Import subcommand supports --category, --measure, --time, --skip, --extract, --axis, --formula, --name, --no-wizard, and --output flags for configurable imports. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 115 +++++++++++++ Cargo.toml | 1 + src/main.rs | 452 ++++++++++++++++++++++++++++++++-------------------- 3 files changed, 392 insertions(+), 176 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a325be..829f256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -107,6 +157,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.8.1" @@ -394,6 +490,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "clap", "crossterm", "csv", "dirs", @@ -442,6 +539,12 @@ dependencies = [ "syn", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -567,6 +670,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1040,6 +1149,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 993994e..4912101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ flate2 = "1" unicode-width = "0.2" dirs = "5" csv = "1" +clap = { version = "4.6.0", features = ["derive"] } [dev-dependencies] proptest = "1" diff --git a/src/main.rs b/src/main.rs index 8ad6f4b..180e462 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,43 +12,263 @@ use crate::import::csv_parser::csv_path_p; use std::path::PathBuf; use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; use command::CommandResult; use draw::run_tui; use model::Model; use serde_json::Value; +#[derive(Parser)] +#[command(name = "improvise", about = "Multi-dimensional data modeling TUI")] +struct Cli { + /// Model file to open or create + file: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Import JSON or CSV data, then open TUI (or save with --output) + Import { + /// Files to import (multiple CSVs merge with a "File" category) + files: Vec, + + /// Mark field as category dimension (repeatable) + #[arg(long)] + category: Vec, + + /// Mark field as numeric measure (repeatable) + #[arg(long)] + measure: Vec, + + /// Mark field as time/date category (repeatable) + #[arg(long)] + time: Vec, + + /// Skip/exclude a field from import (repeatable) + #[arg(long)] + skip: Vec, + + /// Extract date component, e.g. "Date:Month" (repeatable) + #[arg(long)] + extract: Vec, + + /// Set category axis, e.g. "Payee:row" (repeatable) + #[arg(long)] + axis: Vec, + + /// Add formula, e.g. "Profit = Revenue - Cost" (repeatable) + #[arg(long)] + formula: Vec, + + /// Model name (default: "Imported Model") + #[arg(long)] + name: Option, + + /// Skip the interactive wizard + #[arg(long)] + no_wizard: bool, + + /// Save to file instead of opening TUI + #[arg(short, long)] + output: Option, + }, + + /// Run a JSON command headless (repeatable) + Cmd { + /// JSON command strings + json: Vec, + + /// Model file to load/save + #[arg(short, long)] + file: Option, + }, + + /// Run commands from a script file headless + Script { + /// Script file (one JSON command per line, # comments) + path: PathBuf, + + /// Model file to load/save + #[arg(short, long)] + file: Option, + }, +} + fn main() -> Result<()> { - let args: Vec = std::env::args().collect(); - let arg_config = parse_args(args); - arg_config.run() -} + let cli = Cli::parse(); -trait Runnable { - fn run(self: Box) -> Result<()>; -} + match cli.command { + None => { + let model = get_initial_model(&cli.file)?; + run_tui(model, cli.file, None) + } -struct CmdLineArgs { - file_path: Option, - import_paths: Vec, -} + Some(Commands::Import { + files, + category, + measure, + time, + skip, + extract, + axis, + formula, + name, + no_wizard, + output, + }) => { + let import_value = if files.is_empty() { + anyhow::bail!("No files specified for import"); + } else { + get_import_data(&files) + .ok_or_else(|| anyhow::anyhow!("Failed to parse import files"))? + }; -impl Runnable for CmdLineArgs { - fn run(self: Box) -> Result<()> { - // Load or create model - let model = get_initial_model(&self.file_path)?; + let config = ImportConfig { + categories: category, + measures: measure, + time_fields: time, + skip_fields: skip, + extractions: parse_colon_pairs(&extract), + axes: parse_colon_pairs(&axis), + formulas: formula, + name, + }; - // Pre-TUI import: parse JSON or CSV and open wizard - let import_value = if self.import_paths.is_empty() { - None - } else { - get_import_data(&self.import_paths) - }; + if no_wizard { + run_headless_import(import_value, &config, output, cli.file) + } else { + run_wizard_import(import_value, &config, cli.file) + } + } - run_tui(model, self.file_path, import_value) + Some(Commands::Cmd { json, file }) => run_headless_commands(&json, &file), + + Some(Commands::Script { path, file }) => run_headless_script(&path, &file), } } +// ── Import config ──────────────────────────────────────────────────────────── + +struct ImportConfig { + categories: Vec, + measures: Vec, + time_fields: Vec, + skip_fields: Vec, + extractions: Vec<(String, String)>, + axes: Vec<(String, String)>, + formulas: Vec, + name: Option, +} + +fn parse_colon_pairs(args: &[String]) -> Vec<(String, String)> { + args.iter() + .filter_map(|s| { + let (a, b) = s.split_once(':')?; + Some((a.to_string(), b.to_string())) + }) + .collect() +} + +fn apply_config_to_pipeline( + pipeline: &mut import::wizard::ImportPipeline, + config: &ImportConfig, +) { + use import::analyzer::{DateComponent, FieldKind}; + + // Override field kinds + for p in &mut pipeline.proposals { + if config.categories.contains(&p.field) { + p.kind = FieldKind::Category; + p.accepted = true; + } else if config.measures.contains(&p.field) { + p.kind = FieldKind::Measure; + p.accepted = true; + } else if config.time_fields.contains(&p.field) { + p.kind = FieldKind::TimeCategory; + p.accepted = true; + } else if config.skip_fields.contains(&p.field) { + p.accepted = false; + } + } + + // Apply date component extractions + for (field, comp_str) in &config.extractions { + let component = match comp_str.to_lowercase().as_str() { + "year" => DateComponent::Year, + "month" => DateComponent::Month, + "quarter" => DateComponent::Quarter, + _ => continue, + }; + for p in &mut pipeline.proposals { + if p.field == *field && !p.date_components.contains(&component) { + p.date_components.push(component); + } + } + } + + // Set formulas + pipeline.formulas = config.formulas.clone(); + + // Set model name + if let Some(ref name) = config.name { + pipeline.model_name = name.clone(); + } +} + +fn apply_axis_overrides(model: &mut Model, axes: &[(String, String)]) { + use view::Axis; + let view = model.active_view_mut(); + for (cat, axis_str) in axes { + let axis = match axis_str.to_lowercase().as_str() { + "row" => Axis::Row, + "column" | "col" => Axis::Column, + "page" => Axis::Page, + "none" => Axis::None, + _ => continue, + }; + view.set_axis(cat, axis); + } +} + +fn run_headless_import( + import_value: Value, + config: &ImportConfig, + output: Option, + model_file: Option, +) -> Result<()> { + let mut pipeline = import::wizard::ImportPipeline::new(import_value); + apply_config_to_pipeline(&mut pipeline, config); + let mut model = pipeline.build_model()?; + model.normalize_view_state(); + apply_axis_overrides(&mut model, &config.axes); + + if let Some(path) = output.or(model_file) { + persistence::save(&model, &path)?; + eprintln!("Saved to {}", path.display()); + } else { + eprintln!("No output path specified; use -o or provide a model file"); + } + Ok(()) +} + +fn run_wizard_import( + import_value: Value, + _config: &ImportConfig, + model_file: Option, +) -> Result<()> { + let model = get_initial_model(&model_file)?; + // Pre-configure will happen inside the TUI via the wizard + // For now, pass import_value and let the wizard handle it + // TODO: pass config to wizard for pre-population + run_tui(model, model_file, Some(import_value)) +} + +// ── Import data loading ────────────────────────────────────────────────────── + fn get_import_data(paths: &[PathBuf]) -> Option { let all_csv = paths.iter().all(|p| csv_path_p(p)); @@ -94,169 +314,49 @@ fn get_import_data(paths: &[PathBuf]) -> Option { } } -enum HeadlessMode { - Commands(Vec), - Script(Option), -} -struct HeadlessArgs { - file_path: Option, - mode: HeadlessMode, -} +// ── Headless command execution ─────────────────────────────────────────────── -impl Runnable for HeadlessArgs { - fn run(self: Box) -> Result<()> { - let mut model = get_initial_model(&self.file_path)?; - let mut exit_code = 0; - match self.mode { - HeadlessMode::Script(script) => { - if let Some(script_path) = script { - let content = std::fs::read_to_string(&script_path)?; - let mut cmds: Vec = Vec::new(); - for line in content.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() - && !trimmed.starts_with("//") - && !trimmed.starts_with('#') - { - cmds.push(trimmed.to_string()); - } - } - for raw_cmd in &cmds { - let parsed: command::Command = match serde_json::from_str(raw_cmd) { - Ok(c) => c, - Err(e) => { - let r = CommandResult::err(format!("JSON parse error: {e}")); - println!("{}", serde_json::to_string(&r)?); - exit_code = 1; - continue; - } - }; - let result = command::dispatch(&mut model, &parsed); - if !result.ok { - exit_code = 1; - } - println!("{}", serde_json::to_string(&result)?); - } - }; - } - HeadlessMode::Commands(cmds) => { - for raw_cmd in &cmds { - let parsed: command::Command = match serde_json::from_str(raw_cmd) { - Ok(c) => c, - Err(e) => { - let r = CommandResult::err(format!("JSON parse error: {e}")); - println!("{}", serde_json::to_string(&r)?); - exit_code = 1; - continue; - } - }; - let result = command::dispatch(&mut model, &parsed); - if !result.ok { - exit_code = 1; - } - println!("{}", serde_json::to_string(&result)?); - } +fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> { + let mut model = get_initial_model(file)?; + let mut exit_code = 0; + + for raw_cmd in cmds { + let parsed: command::Command = match serde_json::from_str(raw_cmd) { + Ok(c) => c, + Err(e) => { + let r = CommandResult::err(format!("JSON parse error: {e}")); + println!("{}", serde_json::to_string(&r)?); + exit_code = 1; + continue; } + }; + let result = command::dispatch(&mut model, &parsed); + if !result.ok { + exit_code = 1; } - - if let Some(path) = self.file_path { - persistence::save(&model, &path)?; - } - - std::process::exit(exit_code); + println!("{}", serde_json::to_string(&result)?); } + + if let Some(path) = file { + persistence::save(&model, path)?; + } + + std::process::exit(exit_code); } -struct HelpArgs; +fn run_headless_script(script_path: &PathBuf, file: &Option) -> Result<()> { + let content = std::fs::read_to_string(script_path)?; + let cmds: Vec = content + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with("//") && !l.starts_with('#')) + .map(String::from) + .collect(); -impl Runnable for HelpArgs { - fn run(self: Box) -> Result<()> { - println!("improvise — multi-dimensional data modeling TUI\n"); - println!("USAGE:"); - println!(" improvise [file.improv] Open or create a model"); - println!(" improvise --import data.json Import JSON or CSV then open TUI"); - println!( - " improvise --import a.csv b.csv Import multiple CSVs (filenames become a category)" - ); - println!(" improvise --cmd '{{...}}' Run JSON command(s) headless (repeatable)"); - println!(" improvise --script cmds.jsonl Run commands from file headless (exclusive with --cmd)"); - println!("\nTUI KEYS (vim-style):"); - println!(" : Command mode (:q :w :import :add-cat :formula …)"); - println!(" hjkl / ↑↓←→ Navigate grid"); - println!(" i / Enter Edit cell (Insert mode)"); - println!(" Esc Return to Normal mode"); - println!(" x Clear cell"); - println!(" yy / p Yank / paste cell value"); - println!(" gg / G First / last row"); - println!(" 0 / $ First / last column"); - println!(" Ctrl+D/U Scroll half-page down / up"); - println!(" / n N Search / next / prev"); - println!(" [ ] Cycle page-axis filter"); - println!(" T Tile-select (pivot) mode"); - println!(" F C V Toggle Formulas / Categories / Views panel"); - println!(" ZZ Save and quit"); - println!(" ? Help"); - Ok(()) - } + run_headless_commands(&cmds, file) } -fn parse_args(args: Vec) -> Box { - let mut file_path: Option = None; - let mut headless_cmds: Vec = Vec::new(); - let mut headless_script: Option = None; - let mut import_paths: Vec = Vec::new(); - - let mut i = 1; - while i < args.len() { - match args[i].as_str() { - "--cmd" | "-c" => { - i += 1; - if let Some(cmd) = args.get(i).cloned() { - headless_cmds.push(cmd); - } - } - "--script" | "-s" => { - i += 1; - headless_script = args.get(i).map(PathBuf::from); - } - "--import" => { - i += 1; - while i < args.len() && !args[i].starts_with('-') { - import_paths.push(PathBuf::from(&args[i])); - i += 1; - } - continue; // skip the i += 1 at the bottom - } - "--help" | "-h" => { - return Box::new(HelpArgs); - } - arg if !arg.starts_with('-') => { - file_path = Some(PathBuf::from(arg)); - } - _ => {} - } - i += 1; - } - - if !headless_cmds.is_empty() && headless_script.is_some() { - eprintln!("Error: --cmd and --script cannot be used together"); - std::process::exit(1); - } else if !headless_cmds.is_empty() || headless_script.is_some() { - Box::new(HeadlessArgs { - file_path, - mode: if headless_script.is_some() { - HeadlessMode::Script(headless_script) - } else { - HeadlessMode::Commands(headless_cmds) - }, - }) - } else { - Box::new(CmdLineArgs { - file_path, - import_paths, - }) - } -} +// ── Helpers ────────────────────────────────────────────────────────────────── fn get_initial_model(file_path: &Option) -> Result { if let Some(ref path) = file_path {