Files
improvise/src/main.rs

280 lines
9.2 KiB
Rust

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<String> = std::env::args().collect();
let arg_config = parse_args(args);
arg_config.run()
}
trait Runnable {
fn run(self: Box<Self>) -> Result<()>;
}
struct CmdLineArgs {
file_path: Option<PathBuf>,
import_paths: Vec<PathBuf>,
}
impl Runnable for CmdLineArgs {
fn run(self: Box<Self>) -> 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<Value> {
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::<Value>(&content) {
Err(e) => {
eprintln!("JSON parse error: {e}");
None
}
Ok(json) => Some(json),
}
}
}
}
}
}
enum HeadlessMode {
Commands(Vec<String>),
Script(Option<PathBuf>),
}
struct HeadlessArgs {
file_path: Option<PathBuf>,
mode: HeadlessMode,
}
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)?);
}
}
}
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<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(())
}
}
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,
})
}
}
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
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"))
}
}