Initial implementation of Improvise TUI

Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-20 21:11:14 -07:00
parent 0ba39672d3
commit eae00522e2
35 changed files with 5413 additions and 0 deletions

137
src/persistence/mod.rs Normal file
View File

@ -0,0 +1,137 @@
use std::io::{Read, Write, BufReader, BufWriter};
use std::path::Path;
use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use crate::model::Model;
const MAGIC: &str = ".improv";
const COMPRESSED_EXT: &str = ".improv.gz";
pub fn save(model: &Model, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(model)
.context("Failed to serialize model")?;
if path.extension().and_then(|e| e.to_str()) == Some("gz")
|| path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false)
{
let file = std::fs::File::create(path)
.with_context(|| format!("Cannot create {}", path.display()))?;
let mut encoder = GzEncoder::new(BufWriter::new(file), Compression::default());
encoder.write_all(json.as_bytes())?;
encoder.finish()?;
} else {
std::fs::write(path, &json)
.with_context(|| format!("Cannot write {}", path.display()))?;
}
Ok(())
}
pub fn load(path: &Path) -> Result<Model> {
let file = std::fs::File::open(path)
.with_context(|| format!("Cannot open {}", path.display()))?;
let json = if path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false) {
let mut decoder = GzDecoder::new(BufReader::new(file));
let mut s = String::new();
decoder.read_to_string(&mut s)?;
s
} else {
let mut s = String::new();
BufReader::new(file).read_to_string(&mut s)?;
s
};
serde_json::from_str(&json)
.context("Failed to deserialize model")
}
pub fn autosave_path(path: &Path) -> std::path::PathBuf {
let mut p = path.to_path_buf();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("model");
p.set_file_name(format!(".{name}.autosave"));
p
}
pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
use crate::view::Axis;
let view = model.views.get(view_name)
.ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?;
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
let page_cats: Vec<String> = view.categories_on(Axis::Page).into_iter().map(String::from).collect();
// Build page-axis coords from current selections (or first item)
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat_name.clone(), sel)
}).collect();
let row_items: Vec<String> = if row_cats.is_empty() {
vec![]
} else {
model.category(&row_cats[0])
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default()
};
let col_items: Vec<String> = if col_cats.is_empty() {
vec![]
} else {
model.category(&col_cats[0])
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default()
};
let mut out = String::new();
// Header row
let row_label = if row_cats.is_empty() { String::new() } else { row_cats.join("/") };
let page_label: Vec<String> = page_coords.iter().map(|(c, v)| format!("{c}={v}")).collect();
let header_prefix = if page_label.is_empty() { row_label } else {
format!("{} ({})", row_label, page_label.join(", "))
};
if !header_prefix.is_empty() {
out.push_str(&header_prefix);
out.push(',');
}
out.push_str(&col_items.join(","));
out.push('\n');
// Data rows
let effective_row_items: Vec<String> = if row_items.is_empty() { vec!["".to_string()] } else { row_items };
let effective_col_items: Vec<String> = if col_items.is_empty() { vec!["".to_string()] } else { col_items };
for ri in &effective_row_items {
if !ri.is_empty() {
out.push_str(ri);
out.push(',');
}
let row_values: Vec<String> = effective_col_items.iter().map(|ci| {
let mut coords = page_coords.clone();
if !row_cats.is_empty() && !ri.is_empty() {
coords.push((row_cats[0].clone(), ri.clone()));
}
if !col_cats.is_empty() && !ci.is_empty() {
coords.push((col_cats[0].clone(), ci.clone()));
}
let key = crate::model::CellKey::new(coords);
model.evaluate(&key).to_string()
}).collect();
out.push_str(&row_values.join(","));
out.push('\n');
}
std::fs::write(path, out)?;
Ok(())
}