280 lines
9.2 KiB
Rust
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"))
|
|
}
|
|
}
|