chore: format

This commit is contained in:
Edward Langley
2026-04-13 21:30:37 -07:00
parent af74dc3d3f
commit 6370f8b19f
19 changed files with 445 additions and 249 deletions

View File

@ -36,31 +36,61 @@ fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
// ── Word pools for realistic output ─────────────────────────────────────────
const BARE_WORDS: &[&str] = &[
"Region", "Product", "Customer", "Channel", "Date",
"North", "South", "East", "West",
"Revenue", "Cost", "Profit", "Margin",
"Widgets", "Gadgets", "Sprockets",
"Q1", "Q2", "Q3", "Q4",
"Jan", "Feb", "Mar", "Apr",
"Acme", "Globex", "Initech", "Umbrella",
"Region",
"Product",
"Customer",
"Channel",
"Date",
"North",
"South",
"East",
"West",
"Revenue",
"Cost",
"Profit",
"Margin",
"Widgets",
"Gadgets",
"Sprockets",
"Q1",
"Q2",
"Q3",
"Q4",
"Jan",
"Feb",
"Mar",
"Apr",
"Acme",
"Globex",
"Initech",
"Umbrella",
];
const QUOTED_WORDS: &[&str] = &[
"Total Revenue", "Net Income", "Gross Margin",
"2025-01", "2025-02", "2025-03",
"East Coast", "West Coast",
"Acme Corp", "Globex Inc",
"Cost of Goods", "Operating Expense",
"Total Revenue",
"Net Income",
"Gross Margin",
"2025-01",
"2025-02",
"2025-03",
"East Coast",
"West Coast",
"Acme Corp",
"Globex Inc",
"Cost of Goods",
"Operating Expense",
];
const MODEL_NAMES: &[&str] = &[
"Sales Report", "Budget 2025", "Quarterly Review",
"Inventory Model", "Revenue Analysis", "Demo Model",
"Sales Report",
"Budget 2025",
"Quarterly Review",
"Inventory Model",
"Revenue Analysis",
"Demo Model",
];
const VIEW_NAMES: &[&str] = &[
"Default", "Summary", "Detail", "By Region", "Monthly",
];
const VIEW_NAMES: &[&str] = &["Default", "Summary", "Detail", "By Region", "Monthly"];
const FORMULA_EXPRS: &[&str] = &[
"Profit = Revenue - Cost",
@ -70,9 +100,7 @@ const FORMULA_EXPRS: &[&str] = &[
"Net = Revenue - Cost - Tax",
];
const FORMAT_STRINGS: &[&str] = &[
",.0", ",.2f", ",.1f", ".0%",
];
const FORMAT_STRINGS: &[&str] = &[",.0", ",.2f", ",.1f", ".0%"];
// ── PRNG ────────────────────────────────────────────────────────────────────

View File

@ -3,7 +3,7 @@ use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use super::core::{Cmd, CmdContext};
use super::navigation::{viewport_effects, CursorState, EnterAdvance};
use super::navigation::{CursorState, EnterAdvance, viewport_effects};
#[cfg(test)]
mod tests {

View File

@ -3,8 +3,8 @@ use std::fmt::Debug;
use crossterm::event::KeyCode;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::AppMode;
use crate::ui::effect::{Effect, Panel};
use crate::view::{Axis, GridLayout};

View File

@ -2,7 +2,7 @@ use crate::model::cell::CellValue;
use crate::ui::effect::{self, Effect};
use crate::view::Axis;
use super::core::{require_args, Cmd, CmdContext};
use super::core::{Cmd, CmdContext, require_args};
#[cfg(test)]
mod tests {
@ -110,7 +110,6 @@ macro_rules! effect_cmd {
};
}
effect_cmd!(
AddCategoryCmd,
"add-category",
@ -202,7 +201,10 @@ effect_cmd!(
"add-formula",
|args: &[String]| {
if args.is_empty() || args.len() > 2 {
return Err(format!("add-formula requires 1-2 argument(s), got {}", args.len()));
return Err(format!(
"add-formula requires 1-2 argument(s), got {}",
args.len()
));
}
Ok(())
},
@ -397,7 +399,10 @@ effect_cmd!(
"help",
|_args: &[String]| -> Result<(), String> { Ok(()) },
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![effect::help_page_set(0), effect::change_mode(crate::ui::app::AppMode::Help)]
vec![
effect::help_page_set(0),
effect::change_mode(crate::ui::app::AppMode::Help),
]
}
);

View File

@ -1,15 +1,15 @@
pub mod core;
pub mod navigation;
pub mod mode;
pub mod cell;
pub mod search;
pub mod panel;
pub mod grid;
pub mod tile;
pub mod text_buffer;
pub mod commit;
pub mod core;
pub mod effect_cmds;
pub mod grid;
pub mod mode;
pub mod navigation;
pub mod panel;
pub mod registry;
pub mod search;
pub mod text_buffer;
pub mod tile;
// Re-export items used by external code
pub use self::core::{Cmd, CmdContext, CmdRegistry};

View File

@ -3,7 +3,7 @@ use crossterm::event::KeyCode;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use super::core::{read_buffer, Cmd, CmdContext};
use super::core::{Cmd, CmdContext, read_buffer};
#[cfg(test)]
mod tests {

View File

@ -235,9 +235,8 @@ impl Keymap {
/// Look up the binding for a key, falling through to parent keymaps.
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.lookup_local(key, mods).or_else(|| {
self.parent.as_ref().and_then(|p| p.lookup(key, mods))
})
self.lookup_local(key, mods)
.or_else(|| self.parent.as_ref().and_then(|p| p.lookup(key, mods)))
}
/// Dispatch a key: look up binding, resolve through registry, return effects.
@ -729,50 +728,82 @@ impl KeymapSet {
// ── Editing mode ─────────────────────────────────────────────────
let mut ed = Keymap::new();
ed.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["edit".into()]),
("enter-mode", vec!["normal".into()]),
]);
ed.bind_seq(KeyCode::Enter, none, vec![
("commit-cell-edit", vec![]),
("clear-buffer", vec!["edit".into()]),
]);
ed.bind_seq(KeyCode::Tab, none, vec![
("commit-and-advance-right", vec![]),
("clear-buffer", vec!["edit".into()]),
]);
ed.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["edit".into()]),
("enter-mode", vec!["normal".into()]),
],
);
ed.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-cell-edit", vec![]),
("clear-buffer", vec!["edit".into()]),
],
);
ed.bind_seq(
KeyCode::Tab,
none,
vec![
("commit-and-advance-right", vec![]),
("clear-buffer", vec!["edit".into()]),
],
);
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
ed.bind_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed));
// ── Formula edit ─────────────────────────────────────────────────
let mut fe = Keymap::new();
fe.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["formula".into()]),
("enter-mode", vec!["formula-panel".into()]),
]);
fe.bind_seq(KeyCode::Enter, none, vec![
("commit-formula", vec![]),
("clear-buffer", vec!["formula".into()]),
]);
fe.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["formula".into()]),
("enter-mode", vec!["formula-panel".into()]),
],
);
fe.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-formula", vec![]),
("clear-buffer", vec!["formula".into()]),
],
);
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
fe.bind_any_char("append-char", vec!["formula".into()]);
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
// ── Category add ─────────────────────────────────────────────────
let mut ca = Keymap::new();
ca.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["category".into()]),
("enter-mode", vec!["category-panel".into()]),
]);
ca.bind_seq(KeyCode::Enter, none, vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
]);
ca.bind_seq(KeyCode::Tab, none, vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
]);
ca.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["category".into()]),
("enter-mode", vec!["category-panel".into()]),
],
);
ca.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
],
);
ca.bind_seq(
KeyCode::Tab,
none,
vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
],
);
ca.bind_args(
KeyCode::Backspace,
none,
@ -784,46 +815,74 @@ impl KeymapSet {
// ── Item add ─────────────────────────────────────────────────────
let mut ia = Keymap::new();
ia.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["item".into()]),
("enter-mode", vec!["category-panel".into()]),
]);
ia.bind_seq(KeyCode::Enter, none, vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
]);
ia.bind_seq(KeyCode::Tab, none, vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
]);
ia.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["item".into()]),
("enter-mode", vec!["category-panel".into()]),
],
);
ia.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
],
);
ia.bind_seq(
KeyCode::Tab,
none,
vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
],
);
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
ia.bind_any_char("append-char", vec!["item".into()]);
set.insert(ModeKey::ItemAdd, Arc::new(ia));
// ── Export prompt ────────────────────────────────────────────────
let mut ep = Keymap::new();
ep.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["export".into()]),
("enter-mode", vec!["normal".into()]),
]);
ep.bind_seq(KeyCode::Enter, none, vec![
("commit-export", vec![]),
("clear-buffer", vec!["export".into()]),
]);
ep.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["export".into()]),
("enter-mode", vec!["normal".into()]),
],
);
ep.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-export", vec![]),
("clear-buffer", vec!["export".into()]),
],
);
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
ep.bind_any_char("append-char", vec!["export".into()]);
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
// ── Command mode ─────────────────────────────────────────────────
let mut cm = Keymap::new();
cm.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["command".into()]),
("enter-mode", vec!["normal".into()]),
]);
cm.bind_seq(KeyCode::Enter, none, vec![
("execute-command", vec![]),
("clear-buffer", vec!["command".into()]),
]);
cm.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["command".into()]),
("enter-mode", vec!["normal".into()]),
],
);
cm.bind_seq(
KeyCode::Enter,
none,
vec![
("execute-command", vec![]),
("clear-buffer", vec!["command".into()]),
],
);
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
cm.bind_any_char("append-char", vec!["command".into()]);
set.insert(ModeKey::CommandMode, Arc::new(cm));
@ -1086,9 +1145,11 @@ mod tests {
let ks = KeymapSet::default_keymaps();
let editing = ks.mode_maps.get(&ModeKey::Editing).unwrap();
// Should have AnyChar for text input
assert!(editing
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
.is_some());
assert!(
editing
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
.is_some()
);
// Should have Esc to exit
assert!(editing.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
}
@ -1097,9 +1158,11 @@ mod tests {
fn search_mode_has_any_char_and_esc() {
let ks = KeymapSet::default_keymaps();
let search = ks.mode_maps.get(&ModeKey::SearchMode).unwrap();
assert!(search
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
.is_some());
assert!(
search
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
.is_some()
);
assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
}

View File

@ -5,7 +5,7 @@
//! Coordinate pairs use `/`: `Category/Item`
//! Quoted strings supported: `"Profit = Revenue - Cost"`
use super::cmd::{default_registry, Cmd, CmdRegistry};
use super::cmd::{Cmd, CmdRegistry, default_registry};
/// Parse a line into commands using the default registry.
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {

View File

@ -6,14 +6,14 @@ use anyhow::Result;
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use crate::model::Model;

View File

@ -1,4 +1,4 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
@ -364,7 +364,7 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
t => {
return Err(anyhow!(
"Expected category name, got {t:?}"
))
));
}
};
// expect =
@ -537,11 +537,7 @@ mod tests {
#[test]
fn parse_sum_with_top_level_where_works() {
let f = parse_formula(
"EastTotal = SUM(Revenue) WHERE Region = \"East\"",
"Foo",
)
.unwrap();
let f = parse_formula("EastTotal = SUM(Revenue) WHERE Region = \"East\"", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.category, "Region");
@ -552,11 +548,7 @@ mod tests {
/// The tokenizer must not merge "Revenue WHERE" into a single identifier.
#[test]
fn parse_sum_with_inline_where_filter() {
let f = parse_formula(
"EastTotal = SUM(Revenue WHERE Region = \"East\")",
"Foo",
)
.unwrap();
let f = parse_formula("EastTotal = SUM(Revenue WHERE Region = \"East\")", "Foo").unwrap();
if let Expr::Agg(AggFunc::Sum, inner, Some(filter)) = &f.expr {
assert!(matches!(**inner, Expr::Ref(_)));
assert_eq!(filter.category, "Region");
@ -772,11 +764,8 @@ mod tests {
#[test]
fn pipe_quoted_in_inline_where() {
let f = parse_formula(
"X = SUM(Revenue WHERE |Region Name| = |East Coast|)",
"Foo",
)
.unwrap();
let f =
parse_formula("X = SUM(Revenue WHERE |Region Name| = |East Coast|)", "Foo").unwrap();
if let Expr::Agg(AggFunc::Sum, _, Some(filter)) = &f.expr {
assert_eq!(filter.category, "Region Name");
assert_eq!(filter.item, "East Coast");

View File

@ -1,13 +1,13 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use serde_json::Value;
use super::analyzer::{
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
DateComponent, FieldKind, FieldProposal,
DateComponent, FieldKind, FieldProposal, analyze_records, extract_array_at_path,
extract_date_component, find_array_paths,
};
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
@ -850,7 +850,7 @@ mod tests {
let mut w = ImportWizard::new(raw);
assert_eq!(w.step, WizardStep::SelectArrayPath);
w.confirm_path(); // selects first path
// Should advance past SelectArrayPath
// Should advance past SelectArrayPath
assert_ne!(w.step, WizardStep::SelectArrayPath);
assert!(!w.pipeline.records.is_empty());
}

View File

@ -1,6 +1,6 @@
use std::collections::HashMap;
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
@ -274,7 +274,12 @@ impl Model {
pub fn measure_item_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.category("_Measure")
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
.map(|c| {
c.ordered_item_names()
.iter()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
for f in &self.formulas {
if f.target_category == "_Measure" && !names.iter().any(|n| n == &f.target) {
@ -292,7 +297,12 @@ impl Model {
self.measure_item_names()
} else {
self.category(cat_name)
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
.map(|c| {
c.ordered_item_names()
.iter()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default()
}
}
@ -504,8 +514,8 @@ impl Model {
match expr {
Expr::Number(n) => Ok(*n),
Expr::Ref(name) => {
let cat = find_item_category(model, name)
.ok_or_else(|| format!("ref:{name}"))?;
let cat =
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
let ref_key = context.clone().with(cat, name);
// Check formula cache first, then aggregate raw data
if let Some(cached) = model.formula_cache.get(&ref_key) {
@ -529,12 +539,26 @@ impl Model {
BinOp::Add => Ok(lv + rv),
BinOp::Sub => Ok(lv - rv),
BinOp::Mul => Ok(lv * rv),
BinOp::Div => if rv == 0.0 { Err("div/0".into()) } else { Ok(lv / rv) },
BinOp::Div => {
if rv == 0.0 {
Err("div/0".into())
} else {
Ok(lv / rv)
}
}
BinOp::Pow => Ok(lv.powf(rv)),
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => Err("type".into()),
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
Err("type".into())
}
}
}
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(e, context, model, target_category, none_cats)?),
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(
e,
context,
model,
target_category,
none_cats,
)?),
Expr::Agg(func, inner, agg_filter) => {
use crate::formula::AggFunc;
let mut partial = context.without(target_category);
@ -554,9 +578,23 @@ impl Model {
.collect();
match func {
AggFunc::Sum => Ok(values.iter().sum()),
AggFunc::Avg => if values.is_empty() { Err("empty".into()) } else { Ok(values.iter().sum::<f64>() / values.len() as f64) },
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
AggFunc::Avg => {
if values.is_empty() {
Err("empty".into())
} else {
Ok(values.iter().sum::<f64>() / values.len() as f64)
}
}
AggFunc::Min => values
.iter()
.cloned()
.reduce(f64::min)
.ok_or_else(|| "empty".into()),
AggFunc::Max => values
.iter()
.cloned()
.reduce(f64::max)
.ok_or_else(|| "empty".into()),
AggFunc::Count => Ok(values.len() as f64),
}
}
@ -597,7 +635,13 @@ impl Model {
}
}
match eval_expr_cached(&formula.expr, context, self, &formula.target_category, none_cats) {
match eval_expr_cached(
&formula.expr,
context,
self,
&formula.target_category,
none_cats,
) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
}
@ -679,8 +723,8 @@ impl Model {
match expr {
Expr::Number(n) => Ok(*n),
Expr::Ref(name) => {
let cat = find_item_category(model, name)
.ok_or_else(|| format!("ref:{name}"))?;
let cat =
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
let new_key = context.clone().with(cat, name);
match model.evaluate_depth(&new_key, depth) {
Some(CellValue::Number(n)) => Ok(n),
@ -735,8 +779,16 @@ impl Model {
Ok(values.iter().sum::<f64>() / values.len() as f64)
}
}
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
AggFunc::Min => values
.iter()
.cloned()
.reduce(f64::min)
.ok_or_else(|| "empty".into()),
AggFunc::Max => values
.iter()
.cloned()
.reduce(f64::max)
.ok_or_else(|| "empty".into()),
AggFunc::Count => Ok(values.len() as f64),
}
}
@ -779,7 +831,13 @@ impl Model {
}
}
match eval_expr(&formula.expr, context, self, &formula.target_category, depth) {
match eval_expr(
&formula.expr,
context,
self,
&formula.target_category,
depth,
) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
}
@ -1011,11 +1069,19 @@ mod model_tests {
m.category_mut("_Measure").unwrap().add_item("Amount");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("_Measure", "Amount")]),
coord(&[
("Payee", "Acme"),
("Date", "Jan-01"),
("_Measure", "Amount"),
]),
CellValue::Number(100.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("_Measure", "Amount")]),
coord(&[
("Payee", "Acme"),
("Date", "Jan-02"),
("_Measure", "Amount"),
]),
CellValue::Number(50.0),
);
@ -1780,8 +1846,10 @@ mod five_category {
.evaluate(&coord(region, product, channel, time, "Margin"))
.and_then(|v| v.as_f64())
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
assert!(approx(actual, expected),
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
assert!(
approx(actual, expected),
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}"
);
}
}

View File

@ -1,16 +1,16 @@
use anyhow::{Context, Result};
use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use pest::Parser;
use pest_derive::Parser;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::Path;
use crate::formula::parse_formula;
use crate::model::Model;
use crate::model::category::Group;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::{Axis, GridLayout};
#[derive(Parser)]
@ -152,7 +152,6 @@ pub fn autosave_path(path: &Path) -> std::path::PathBuf {
p
}
/// Serialize a model to the markdown `.improv` format.
pub fn format_md(model: &Model) -> String {
// writeln! to a String is infallible; this macro avoids .unwrap() noise.
@ -188,11 +187,15 @@ pub fn format_md(model: &Model) -> String {
if !view.number_format.is_empty() {
w!(out, "format: {}", view.number_format);
}
for (prefix, map) in [("hidden", &view.hidden_items), ("collapsed", &view.collapsed_groups)]
{
for (prefix, map) in [
("hidden", &view.hidden_items),
("collapsed", &view.collapsed_groups),
] {
let mut pairs: Vec<_> = map
.iter()
.flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str())))
.flat_map(|(cat, items)| {
items.iter().map(move |item| (cat.as_str(), item.as_str()))
})
.collect();
pairs.sort();
for (cat, item) in pairs {
@ -225,7 +228,10 @@ pub fn format_md(model: &Model) -> String {
for cat in model.categories.values() {
use crate::model::category::CategoryKind;
// Skip _Index and _Dim — they are fully virtual, never persisted
if matches!(cat.kind, CategoryKind::VirtualIndex | CategoryKind::VirtualDim) {
if matches!(
cat.kind,
CategoryKind::VirtualIndex | CategoryKind::VirtualDim
) {
continue;
}
w!(out, "\n## Category: {}", cat.name);
@ -234,7 +240,9 @@ pub fn format_md(model: &Model) -> String {
for item in cat.items.values() {
// For _Measure, skip items that are formula targets
// (they'll be recreated from the ## Formulas section)
if cat.kind == CategoryKind::VirtualMeasure && formula_targets.contains(item.name.as_str()) {
if cat.kind == CategoryKind::VirtualMeasure
&& formula_targets.contains(item.name.as_str())
{
continue;
}
match &item.group {
@ -272,7 +280,6 @@ pub fn format_md(model: &Model) -> String {
out
}
/// Parse the `.improv` format into a Model using the pest grammar.
///
/// Sections may appear in any order; a two-pass approach registers categories
@ -359,7 +366,10 @@ pub fn parse_md(text: &str) -> Result<Model> {
}
Rule::category_section => {
let mut inner = pair.into_inner();
let cname = next(&mut inner, "category_section")?.as_str().trim().to_string();
let cname = next(&mut inner, "category_section")?
.as_str()
.trim()
.to_string();
let mut pc = PCategory {
name: cname,
items: Vec::new(),
@ -440,7 +450,10 @@ pub fn parse_md(text: &str) -> Result<Model> {
}
Rule::view_section => {
let mut inner = pair.into_inner();
let vname = next(&mut inner, "view_section")?.as_str().trim().to_string();
let vname = next(&mut inner, "view_section")?
.as_str()
.trim()
.to_string();
let mut pv = PView {
name: vname,
axes: Vec::new(),
@ -615,9 +628,9 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
mod tests {
use super::{format_md, parse_md};
use crate::formula::parse_formula;
use crate::model::Model;
use crate::model::category::Group;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::Axis;
fn coord(pairs: &[(&str, &str)]) -> CellKey {
@ -656,7 +669,10 @@ mod tests {
let text = format_md(&m);
assert!(text.contains("## Category: Type"));
// Bare items are now comma-separated on one line
assert!(text.contains("- Food, Gas"), "expected comma-separated items:\n{text}");
assert!(
text.contains("- Food, Gas"),
"expected comma-separated items:\n{text}"
);
assert!(text.contains("## Category: Month"));
assert!(text.contains("Jan"));
}
@ -706,7 +722,10 @@ mod tests {
"expected sorted order Feb < Jan:\n{text}"
);
assert!(text.contains("= 200"), "number not quoted:\n{text}");
assert!(text.contains("= |N/A|"), "text should be pipe-quoted:\n{text}");
assert!(
text.contains("= |N/A|"),
"text should be pipe-quoted:\n{text}"
);
}
#[test]
@ -755,14 +774,16 @@ mod tests {
fn parse_md_round_trips_categories_and_items() {
let m = two_cat_model();
let m2 = parse_md(&format_md(&m)).unwrap();
assert!(m2
.category("Type")
.and_then(|c| c.items.get("Food"))
.is_some());
assert!(m2
.category("Month")
.and_then(|c| c.items.get("Feb"))
.is_some());
assert!(
m2.category("Type")
.and_then(|c| c.items.get("Food"))
.is_some()
);
assert!(
m2.category("Month")
.and_then(|c| c.items.get("Feb"))
.is_some()
);
}
#[test]
@ -1450,8 +1471,8 @@ Type=Food = 42
#[cfg(test)]
mod parser_prop_tests {
use super::{format_md, parse_md};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use proptest::prelude::*;
fn coord(pairs: &[(&str, &str)]) -> CellKey {
@ -1501,31 +1522,29 @@ mod parser_prop_tests {
let items2 = prop::collection::hash_set(safe_ident(), 1..=4);
let values = prop::collection::vec(cell_value(), 1..=8);
(safe_ident(), items1, items2, values).prop_map(
|(name, items1, items2, values)| {
let mut m = Model::new(&name);
m.add_category("CatA").unwrap();
m.add_category("CatB").unwrap();
(safe_ident(), items1, items2, values).prop_map(|(name, items1, items2, values)| {
let mut m = Model::new(&name);
m.add_category("CatA").unwrap();
m.add_category("CatB").unwrap();
let items1: Vec<_> = items1.into_iter().collect();
let items2: Vec<_> = items2.into_iter().collect();
let items1: Vec<_> = items1.into_iter().collect();
let items2: Vec<_> = items2.into_iter().collect();
for item in &items1 {
m.category_mut("CatA").unwrap().add_item(item);
}
for item in &items2 {
m.category_mut("CatB").unwrap().add_item(item);
}
for item in &items1 {
m.category_mut("CatA").unwrap().add_item(item);
}
for item in &items2 {
m.category_mut("CatB").unwrap().add_item(item);
}
for (i, value) in values.into_iter().enumerate() {
let a = &items1[i % items1.len()];
let b = &items2[i % items2.len()];
m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
}
for (i, value) in values.into_iter().enumerate() {
let a = &items1[i % items1.len()];
let b = &items2[i % items2.len()];
m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
}
m
},
)
m
})
}
proptest! {
@ -1720,9 +1739,9 @@ mod parser_prop_tests {
#[cfg(test)]
mod parser_edge_cases {
use super::{format_md, parse_md};
use crate::model::Model;
use crate::model::category::Group;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
@ -1865,14 +1884,14 @@ mod parser_edge_cases {
let text = format_md(&m);
let loaded = parse_md(&text).unwrap();
let cat = loaded.category("Type").unwrap();
let item = cat
.items
.values()
.find(|i| i.name == "Data [raw]");
let item = cat.items.values().find(|i| i.name == "Data [raw]");
assert!(
item.is_some(),
"Item 'Data [raw]' with group not found.\nItems: {:?}\n{text}",
cat.items.values().map(|i| (&i.name, &i.group)).collect::<Vec<_>>()
cat.items
.values()
.map(|i| (&i.name, &i.group))
.collect::<Vec<_>>()
);
assert_eq!(item.unwrap().group.as_deref(), Some("Input"));
}
@ -1882,8 +1901,10 @@ mod parser_edge_cases {
#[test]
fn parse_empty_string() {
let result = parse_md("");
assert!(result.is_err() || result.unwrap().name.is_empty(),
"Empty input should either error or produce empty model");
assert!(
result.is_err() || result.unwrap().name.is_empty(),
"Empty input should either error or produce empty model"
);
}
#[test]

View File

@ -12,8 +12,8 @@ use ratatui::style::Color;
use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet};
use crate::import::wizard::ImportWizard;
use crate::model::cell::CellValue;
use crate::model::Model;
use crate::model::cell::CellValue;
use crate::persistence;
use crate::ui::grid::{
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
@ -338,7 +338,9 @@ impl App {
self.model.categories.values().all(|c| {
matches!(
c.kind,
CategoryKind::VirtualIndex | CategoryKind::VirtualDim | CategoryKind::VirtualMeasure
CategoryKind::VirtualIndex
| CategoryKind::VirtualDim
| CategoryKind::VirtualMeasure
)
})
}
@ -389,16 +391,26 @@ impl App {
/// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str {
match &self.mode {
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd",
AppMode::Normal => {
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
}
AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",
AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name",
AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
AppMode::CategoryPanel => {
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
}
AppMode::CategoryAdd { .. } => {
"Enter:add & continue Tab:same Esc:done — type a category name"
}
AppMode::ItemAdd { .. } => {
"Enter:add & continue Tab:same Esc:done — type an item name"
}
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
AppMode::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help",
AppMode::CommandMode { .. } => {
":q quit :w save :import :add-cat :formula :show-item :help"
}
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
AppMode::Help => "h/l:pages q/Esc:close",
_ => "",

View File

@ -6,7 +6,7 @@ use ratatui::{
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree};
use crate::ui::panel::PanelContent;
use crate::view::Axis;

View File

@ -743,7 +743,7 @@ pub struct ImportJsonHeadless {
impl Effect for ImportJsonHeadless {
fn apply(&self, app: &mut App) {
use crate::import::analyzer::{
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
FieldKind, analyze_records, extract_array_at_path, find_array_paths,
};
use crate::import::wizard::ImportPipeline;
@ -952,8 +952,8 @@ pub fn help_page_set(page: usize) -> Box<dyn Effect> {
#[cfg(test)]
mod tests {
use super::*;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
fn test_app() -> App {
let mut m = Model::new("Test");
@ -1036,12 +1036,13 @@ mod tests {
fn add_formula_adds_target_item_to_category() {
let mut app = test_app();
// "Margin" does not exist as an item in "Type" before adding the formula
assert!(!app
.model
.category("Type")
.unwrap()
.ordered_item_names()
.contains(&"Margin"));
assert!(
!app.model
.category("Type")
.unwrap()
.ordered_item_names()
.contains(&"Margin")
);
AddFormula {
raw: "Margin = Food * 2".to_string(),
target_category: "Type".to_string(),
@ -1257,16 +1258,14 @@ mod tests {
#[test]
fn set_buffer_empty_clears() {
let mut app = test_app();
app.buffers.insert("formula".to_string(), "old text".to_string());
app.buffers
.insert("formula".to_string(), "old text".to_string());
SetBuffer {
name: "formula".to_string(),
value: String::new(),
}
.apply(&mut app);
assert_eq!(
app.buffers.get("formula").map(|s| s.as_str()),
Some(""),
);
assert_eq!(app.buffers.get("formula").map(|s| s.as_str()), Some(""),);
}
#[test]
@ -1597,19 +1596,21 @@ mod tests {
group: "MyGroup".to_string(),
}
.apply(&mut app);
assert!(app
.model
.active_view()
.is_group_collapsed("Type", "MyGroup"));
assert!(
app.model
.active_view()
.is_group_collapsed("Type", "MyGroup")
);
ToggleGroup {
category: "Type".to_string(),
group: "MyGroup".to_string(),
}
.apply(&mut app);
assert!(!app
.model
.active_view()
.is_group_collapsed("Type", "MyGroup"));
assert!(
!app.model
.active_view()
.is_group_collapsed("Type", "MyGroup")
);
}
// ── Cycle axis ──────────────────────────────────────────────────────

View File

@ -674,8 +674,8 @@ mod tests {
use super::GridWidget;
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::AppMode;
use crate::view::GridLayout;
@ -914,8 +914,10 @@ mod tests {
CellValue::Number(600.0),
);
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
m.active_view_mut().set_axis("_Measure", crate::view::Axis::Row);
m.active_view_mut().set_axis("Region", crate::view::Axis::Column);
m.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Row);
m.active_view_mut()
.set_axis("Region", crate::view::Axis::Column);
let text = buf_text(&render(&m, 80, 24));
assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");

View File

@ -1,7 +1,7 @@
use std::rc::Rc;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::view::{Axis, View};
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
@ -149,7 +149,7 @@ impl GridLayout {
.collect()
};
// Sort for deterministic ordering
records.sort_by(|a, b| a.0 .0.cmp(&b.0 .0));
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
// Synthesize row items: one per record, labeled with its index
let row_items: Vec<AxisEntry> = (0..records.len())
@ -192,7 +192,7 @@ impl GridLayout {
// col_item is a category name
let found = record
.0
.0
.0
.iter()
.find(|(c, _)| c == &col_item)
.map(|(_, v)| v.clone());
@ -516,7 +516,10 @@ fn expand_category(
if view.is_hidden(cat_name, item_name) {
continue;
}
let item_group = cat.items.get(item_name.as_str()).and_then(|i| i.group.as_deref());
let item_group = cat
.items
.get(item_name.as_str())
.and_then(|i| i.group.as_deref());
// Emit a group header at each group boundary.
if item_group != last_group {
@ -564,9 +567,9 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
#[cfg(test)]
mod tests {
use super::{synthetic_record_info, AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue};
use super::{AxisEntry, GridLayout, synthetic_record_info};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
fn records_model() -> Model {
@ -867,14 +870,18 @@ mod tests {
fn ungrouped_items_produce_no_headers() {
let m = two_cat_model();
let layout = GridLayout::new(&m, m.active_view());
assert!(!layout
.row_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
assert!(!layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
assert!(
!layout
.row_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
);
assert!(
!layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
);
}
#[test]

View File

@ -3,5 +3,5 @@ pub mod layout;
pub mod types;
pub use axis::Axis;
pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
pub use layout::{AxisEntry, GridLayout, synthetic_record_info};
pub use types::View;