diff --git a/examples/gen-grammar.rs b/examples/gen-grammar.rs index 321fffc..81f0eab 100644 --- a/examples/gen-grammar.rs +++ b/examples/gen-grammar.rs @@ -36,31 +36,61 @@ fn load_grammar() -> HashMap { // ── 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 ──────────────────────────────────────────────────────────────────── diff --git a/src/command/cmd/commit.rs b/src/command/cmd/commit.rs index 6bcd09a..c522e40 100644 --- a/src/command/cmd/commit.rs +++ b/src/command/cmd/commit.rs @@ -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 { diff --git a/src/command/cmd/core.rs b/src/command/cmd/core.rs index f80201a..049d7dc 100644 --- a/src/command/cmd/core.rs +++ b/src/command/cmd/core.rs @@ -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}; diff --git a/src/command/cmd/effect_cmds.rs b/src/command/cmd/effect_cmds.rs index 5132328..29ed154 100644 --- a/src/command/cmd/effect_cmds.rs +++ b/src/command/cmd/effect_cmds.rs @@ -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, _ctx: &CmdContext| -> Vec> { - 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), + ] } ); diff --git a/src/command/cmd/mod.rs b/src/command/cmd/mod.rs index 8228ea1..aa0daf3 100644 --- a/src/command/cmd/mod.rs +++ b/src/command/cmd/mod.rs @@ -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}; diff --git a/src/command/cmd/text_buffer.rs b/src/command/cmd/text_buffer.rs index b632531..e62f034 100644 --- a/src/command/cmd/text_buffer.rs +++ b/src/command/cmd/text_buffer.rs @@ -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 { diff --git a/src/command/keymap.rs b/src/command/keymap.rs index 4ec8811..d7bdac5 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -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()); } diff --git a/src/command/parse.rs b/src/command/parse.rs index 3955589..6f4d020 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -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>, String> { diff --git a/src/draw.rs b/src/draw.rs index e15a18d..07a591c 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -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; diff --git a/src/formula/parser.rs b/src/formula/parser.rs index 4390780..9c250f6 100644 --- a/src/formula/parser.rs +++ b/src/formula/parser.rs @@ -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 { 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"); diff --git a/src/import/wizard.rs b/src/import/wizard.rs index 73b2dee..bf977f1 100644 --- a/src/import/wizard.rs +++ b/src/import/wizard.rs @@ -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()); } diff --git a/src/model/types.rs b/src/model/types.rs index b8cf1dc..08d381c 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -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 { let mut names: Vec = 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::() / 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::() / 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::() / 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}" + ); } } diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index d12c8ea..54ce753 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -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 { } 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 { } 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::>() + cat.items + .values() + .map(|i| (&i.name, &i.group)) + .collect::>() ); 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] diff --git a/src/ui/app.rs b/src/ui/app.rs index 324c535..831137b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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", _ => "", diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index 1999a4a..e835727 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -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; diff --git a/src/ui/effect.rs b/src/ui/effect.rs index a06c175..d915253 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -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 { #[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 ────────────────────────────────────────────────────── diff --git a/src/ui/grid.rs b/src/ui/grid.rs index f23fec7..198c227 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -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}"); diff --git a/src/view/layout.rs b/src/view/layout.rs index 8be7a1e..4b7aad4 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -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 = (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 #[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] diff --git a/src/view/mod.rs b/src/view/mod.rs index 1ac49c2..eaffffc 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -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;