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) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-03 21:43:16 -07:00
parent ac0c538c98
commit 6647be30fa
3 changed files with 392 additions and 176 deletions

View File

@ -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<PathBuf>,
#[command(subcommand)]
command: Option<Commands>,
}
#[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<PathBuf>,
/// Mark field as category dimension (repeatable)
#[arg(long)]
category: Vec<String>,
/// Mark field as numeric measure (repeatable)
#[arg(long)]
measure: Vec<String>,
/// Mark field as time/date category (repeatable)
#[arg(long)]
time: Vec<String>,
/// Skip/exclude a field from import (repeatable)
#[arg(long)]
skip: Vec<String>,
/// Extract date component, e.g. "Date:Month" (repeatable)
#[arg(long)]
extract: Vec<String>,
/// Set category axis, e.g. "Payee:row" (repeatable)
#[arg(long)]
axis: Vec<String>,
/// Add formula, e.g. "Profit = Revenue - Cost" (repeatable)
#[arg(long)]
formula: Vec<String>,
/// Model name (default: "Imported Model")
#[arg(long)]
name: Option<String>,
/// Skip the interactive wizard
#[arg(long)]
no_wizard: bool,
/// Save to file instead of opening TUI
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Run a JSON command headless (repeatable)
Cmd {
/// JSON command strings
json: Vec<String>,
/// Model file to load/save
#[arg(short, long)]
file: Option<PathBuf>,
},
/// 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<PathBuf>,
},
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let arg_config = parse_args(args);
arg_config.run()
}
let cli = Cli::parse();
trait Runnable {
fn run(self: Box<Self>) -> Result<()>;
}
match cli.command {
None => {
let model = get_initial_model(&cli.file)?;
run_tui(model, cli.file, None)
}
struct CmdLineArgs {
file_path: Option<PathBuf>,
import_paths: Vec<PathBuf>,
}
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<Self>) -> 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<String>,
measures: Vec<String>,
time_fields: Vec<String>,
skip_fields: Vec<String>,
extractions: Vec<(String, String)>,
axes: Vec<(String, String)>,
formulas: Vec<String>,
name: Option<String>,
}
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<PathBuf>,
model_file: Option<PathBuf>,
) -> 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 <path> or provide a model file");
}
Ok(())
}
fn run_wizard_import(
import_value: Value,
_config: &ImportConfig,
model_file: Option<PathBuf>,
) -> 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<Value> {
let all_csv = paths.iter().all(|p| csv_path_p(p));
@ -94,169 +314,49 @@ fn get_import_data(paths: &[PathBuf]) -> Option<Value> {
}
}
enum HeadlessMode {
Commands(Vec<String>),
Script(Option<PathBuf>),
}
struct HeadlessArgs {
file_path: Option<PathBuf>,
mode: HeadlessMode,
}
// ── Headless command execution ───────────────────────────────────────────────
impl Runnable for HeadlessArgs {
fn run(self: Box<Self>) -> 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<String> = 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<PathBuf>) -> 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<PathBuf>) -> Result<()> {
let content = std::fs::read_to_string(script_path)?;
let cmds: Vec<String> = 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<Self>) -> 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<String>) -> Box<dyn Runnable> {
let mut file_path: Option<PathBuf> = None;
let mut headless_cmds: Vec<String> = Vec::new();
let mut headless_script: Option<PathBuf> = None;
let mut import_paths: Vec<PathBuf> = 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<PathBuf>) -> Result<Model> {
if let Some(ref path) = file_path {