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:
452
src/main.rs
452
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<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 {
|
||||
|
||||
Reference in New Issue
Block a user