mod command; mod draw; mod formula; mod import; mod model; mod persistence; mod ui; mod view; use crate::import::csv_parser::csv_path_p; use std::path::PathBuf; use anyhow::{Context, Result}; use command::CommandResult; use draw::run_tui; use model::Model; use serde_json::Value; fn main() -> Result<()> { let args: Vec = std::env::args().collect(); let arg_config = parse_args(args); arg_config.run() } trait Runnable { fn run(self: Box) -> Result<()>; } struct CmdLineArgs { file_path: Option, import_paths: Vec, } impl Runnable for CmdLineArgs { fn run(self: Box) -> Result<()> { // Load or create model let model = get_initial_model(&self.file_path)?; // 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) }; run_tui(model, self.file_path, import_value) } } fn get_import_data(paths: &[PathBuf]) -> Option { let all_csv = paths.iter().all(|p| csv_path_p(p)); if paths.len() > 1 { if !all_csv { eprintln!("Multi-file import only supports CSV files"); return None; } match crate::import::csv_parser::merge_csvs(paths) { Ok(records) => Some(Value::Array(records)), Err(e) => { eprintln!("CSV merge error: {e}"); None } } } else { let path = &paths[0]; match std::fs::read_to_string(path) { Err(e) => { eprintln!("Cannot read '{}': {e}", path.display()); None } Ok(content) => { if csv_path_p(path) { match crate::import::csv_parser::parse_csv(path) { Ok(records) => Some(Value::Array(records)), Err(e) => { eprintln!("CSV parse error: {e}"); None } } } else { match serde_json::from_str::(&content) { Err(e) => { eprintln!("JSON parse error: {e}"); None } Ok(json) => Some(json), } } } } } } enum HeadlessMode { Commands(Vec), Script(Option), } struct HeadlessArgs { file_path: Option, mode: HeadlessMode, } 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)?); } } } if let Some(path) = self.file_path { persistence::save(&model, &path)?; } std::process::exit(exit_code); } } struct HelpArgs; 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(()) } } 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, }) } } fn get_initial_model(file_path: &Option) -> Result { if let Some(ref path) = file_path { if path.exists() { let mut m = persistence::load(path) .with_context(|| format!("Failed to load {}", path.display()))?; m.normalize_view_state(); Ok(m) } else { let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("New Model") .to_string(); Ok(Model::new(name)) } } else { Ok(Model::new("New Model")) } }