diff --git a/src/command/mod.rs b/src/command/mod.rs index 9f2917d..0caea49 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -8,5 +8,5 @@ pub mod dispatch; pub mod types; -pub use types::{Command, CommandResult}; pub use dispatch::dispatch; +pub use types::{Command, CommandResult}; diff --git a/src/formula/mod.rs b/src/formula/mod.rs index 56607af..f9f4a5f 100644 --- a/src/formula/mod.rs +++ b/src/formula/mod.rs @@ -1,5 +1,5 @@ -pub mod parser; pub mod ast; +pub mod parser; pub use ast::{AggFunc, BinOp, Expr, Formula}; pub use parser::parse_formula; diff --git a/src/formula/parser.rs b/src/formula/parser.rs index b5f5cb3..dcef661 100644 --- a/src/formula/parser.rs +++ b/src/formula/parser.rs @@ -8,7 +8,9 @@ pub fn parse_formula(raw: &str, target_category: &str) -> Result { let raw = raw.trim(); // Split on first `=` to get target = expression - let eq_pos = raw.find('=').ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?; + let eq_pos = raw + .find('=') + .ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?; let target = raw[..eq_pos].trim().to_string(); let rest = raw[eq_pos + 1..].trim(); @@ -54,7 +56,9 @@ fn split_where(s: &str) -> (&str, Option<&str>) { fn parse_where(s: &str) -> Result { // Format: Category = "Item" or Category = Item - let eq_pos = s.find('=').ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?; + let eq_pos = s + .find('=') + .ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?; let category = s[..eq_pos].trim().to_string(); let item_raw = s[eq_pos + 1..].trim(); let item = item_raw.trim_matches('"').to_string(); @@ -67,7 +71,10 @@ pub fn parse_expr(s: &str) -> Result { let mut pos = 0; let expr = parse_add_sub(&tokens, &mut pos)?; if pos < tokens.len() { - return Err(anyhow!("Unexpected token at position {pos}: {:?}", tokens[pos])); + return Err(anyhow!( + "Unexpected token at position {pos}: {:?}", + tokens[pos] + )); } Ok(expr) } @@ -101,20 +108,62 @@ fn tokenize(s: &str) -> Result> { while i < chars.len() { match chars[i] { ' ' | '\t' | '\n' => i += 1, - '+' => { tokens.push(Token::Plus); i += 1; } - '-' => { tokens.push(Token::Minus); i += 1; } - '*' => { tokens.push(Token::Star); i += 1; } - '/' => { tokens.push(Token::Slash); i += 1; } - '^' => { tokens.push(Token::Caret); i += 1; } - '(' => { tokens.push(Token::LParen); i += 1; } - ')' => { tokens.push(Token::RParen); i += 1; } - ',' => { tokens.push(Token::Comma); i += 1; } - '!' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ne); i += 2; } - '<' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Le); i += 2; } - '>' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ge); i += 2; } - '<' => { tokens.push(Token::Lt); i += 1; } - '>' => { tokens.push(Token::Gt); i += 1; } - '=' => { tokens.push(Token::Eq); i += 1; } + '+' => { + tokens.push(Token::Plus); + i += 1; + } + '-' => { + tokens.push(Token::Minus); + i += 1; + } + '*' => { + tokens.push(Token::Star); + i += 1; + } + '/' => { + tokens.push(Token::Slash); + i += 1; + } + '^' => { + tokens.push(Token::Caret); + i += 1; + } + '(' => { + tokens.push(Token::LParen); + i += 1; + } + ')' => { + tokens.push(Token::RParen); + i += 1; + } + ',' => { + tokens.push(Token::Comma); + i += 1; + } + '!' if chars.get(i + 1) == Some(&'=') => { + tokens.push(Token::Ne); + i += 2; + } + '<' if chars.get(i + 1) == Some(&'=') => { + tokens.push(Token::Le); + i += 2; + } + '>' if chars.get(i + 1) == Some(&'=') => { + tokens.push(Token::Ge); + i += 2; + } + '<' => { + tokens.push(Token::Lt); + i += 1; + } + '>' => { + tokens.push(Token::Gt); + i += 1; + } + '=' => { + tokens.push(Token::Eq); + i += 1; + } '"' => { i += 1; let mut s = String::new(); @@ -122,7 +171,9 @@ fn tokenize(s: &str) -> Result> { s.push(chars[i]); i += 1; } - if i < chars.len() { i += 1; } + if i < chars.len() { + i += 1; + } tokens.push(Token::Str(s)); } c if c.is_ascii_digit() || c == '.' => { @@ -135,13 +186,25 @@ fn tokenize(s: &str) -> Result> { } c if c.is_alphabetic() || c == '_' => { let mut ident = String::new(); - while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ') { + while i < chars.len() + && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ') + { // Don't consume trailing spaces if next non-space is operator if chars[i] == ' ' { // Peek ahead let j = i + 1; let next_nonspace = chars[j..].iter().find(|&&c| c != ' '); - if matches!(next_nonspace, Some('+') | Some('-') | Some('*') | Some('/') | Some('^') | Some(')') | Some(',') | None) { + if matches!( + next_nonspace, + Some('+') + | Some('-') + | Some('*') + | Some('/') + | Some('^') + | Some(')') + | Some(',') + | None + ) { break; } } @@ -161,7 +224,7 @@ fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result { let mut left = parse_mul_div(tokens, pos)?; while *pos < tokens.len() { let op = match &tokens[*pos] { - Token::Plus => BinOp::Add, + Token::Plus => BinOp::Add, Token::Minus => BinOp::Sub, _ => break, }; @@ -176,7 +239,7 @@ fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result { let mut left = parse_pow(tokens, pos)?; while *pos < tokens.len() { let op = match &tokens[*pos] { - Token::Star => BinOp::Mul, + Token::Star => BinOp::Mul, Token::Slash => BinOp::Div, _ => break, }; @@ -239,19 +302,42 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result { if kw.to_ascii_uppercase() == "WHERE" { *pos += 1; let cat = match &tokens[*pos] { - Token::Ident(s) => { let s = s.clone(); *pos += 1; s } - t => return Err(anyhow!("Expected category name, got {t:?}")), + Token::Ident(s) => { + let s = s.clone(); + *pos += 1; + s + } + t => { + return Err(anyhow!( + "Expected category name, got {t:?}" + )) + } }; // expect = - if *pos < tokens.len() && tokens[*pos] == Token::Eq { *pos += 1; } + if *pos < tokens.len() && tokens[*pos] == Token::Eq { + *pos += 1; + } let item = match &tokens[*pos] { - Token::Str(s) | Token::Ident(s) => { let s = s.clone(); *pos += 1; s } + Token::Str(s) | Token::Ident(s) => { + let s = s.clone(); + *pos += 1; + s + } t => return Err(anyhow!("Expected item name, got {t:?}")), }; - Some(Filter { category: cat, item }) - } else { None } - } else { None } - } else { None }; + Some(Filter { + category: cat, + item, + }) + } else { + None + } + } else { + None + } + } else { + None + }; // expect ) if *pos < tokens.len() && tokens[*pos] == Token::RParen { *pos += 1; @@ -266,9 +352,13 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result { if *pos < tokens.len() && tokens[*pos] == Token::LParen { *pos += 1; let cond = parse_comparison(tokens, pos)?; - if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; } + if *pos < tokens.len() && tokens[*pos] == Token::Comma { + *pos += 1; + } let then = parse_add_sub(tokens, pos)?; - if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; } + if *pos < tokens.len() && tokens[*pos] == Token::Comma { + *pos += 1; + } let else_ = parse_add_sub(tokens, pos)?; if *pos < tokens.len() && tokens[*pos] == Token::RParen { *pos += 1; @@ -296,7 +386,9 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result { fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result { let left = parse_add_sub(tokens, pos)?; - if *pos >= tokens.len() { return Ok(left); } + if *pos >= tokens.len() { + return Ok(left); + } let op = match &tokens[*pos] { Token::Eq => BinOp::Eq, Token::Ne => BinOp::Ne, diff --git a/src/import/analyzer.rs b/src/import/analyzer.rs index e6c56f9..ab0cf60 100644 --- a/src/import/analyzer.rs +++ b/src/import/analyzer.rs @@ -1,5 +1,5 @@ -use std::collections::HashSet; use serde_json::Value; +use std::collections::HashSet; #[derive(Debug, Clone, PartialEq)] pub enum FieldKind { @@ -51,73 +51,76 @@ pub fn analyze_records(records: &[Value]) -> Vec { } } - fields.into_iter().map(|field| { - let values: Vec<&Value> = records.iter() - .filter_map(|r| r.get(&field)) - .collect(); + fields + .into_iter() + .map(|field| { + let values: Vec<&Value> = records.iter().filter_map(|r| r.get(&field)).collect(); - let all_numeric = values.iter().all(|v| v.is_number()); - let all_string = values.iter().all(|v| v.is_string()); + let all_numeric = values.iter().all(|v| v.is_number()); + let all_string = values.iter().all(|v| v.is_string()); - if all_numeric { - return FieldProposal { - field, - kind: FieldKind::Measure, - distinct_values: vec![], - accepted: true, - }; - } - - if all_string { - let distinct: HashSet<&str> = values.iter() - .filter_map(|v| v.as_str()) - .collect(); - let distinct_vec: Vec = distinct.into_iter().map(String::from).collect(); - let n = distinct_vec.len(); - let _total = values.len(); - - // Check if looks like date - let looks_like_date = distinct_vec.iter().any(|s| { - s.contains('-') && s.len() >= 8 - || s.starts_with("Q") && s.len() == 2 - || ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] - .iter().any(|m| s.starts_with(m)) - }); - - if looks_like_date { + if all_numeric { return FieldProposal { field, - kind: FieldKind::TimeCategory, - distinct_values: distinct_vec, + kind: FieldKind::Measure, + distinct_values: vec![], accepted: true, }; } - if n <= CATEGORY_THRESHOLD { + if all_string { + let distinct: HashSet<&str> = values.iter().filter_map(|v| v.as_str()).collect(); + let distinct_vec: Vec = distinct.into_iter().map(String::from).collect(); + let n = distinct_vec.len(); + let _total = values.len(); + + // Check if looks like date + let looks_like_date = distinct_vec.iter().any(|s| { + s.contains('-') && s.len() >= 8 + || s.starts_with("Q") && s.len() == 2 + || [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", + "Nov", "Dec", + ] + .iter() + .any(|m| s.starts_with(m)) + }); + + if looks_like_date { + return FieldProposal { + field, + kind: FieldKind::TimeCategory, + distinct_values: distinct_vec, + accepted: true, + }; + } + + if n <= CATEGORY_THRESHOLD { + return FieldProposal { + field, + kind: FieldKind::Category, + distinct_values: distinct_vec, + accepted: true, + }; + } + return FieldProposal { field, - kind: FieldKind::Category, + kind: FieldKind::Label, distinct_values: distinct_vec, - accepted: true, + accepted: false, }; } - return FieldProposal { + // Mixed or other: treat as label + FieldProposal { field, kind: FieldKind::Label, - distinct_values: distinct_vec, + distinct_values: vec![], accepted: false, - }; - } - - // Mixed or other: treat as label - FieldProposal { - field, - kind: FieldKind::Label, - distinct_values: vec![], - accepted: false, - } - }).collect() + } + }) + .collect() } /// Extract nested array from JSON by dot-path diff --git a/src/import/mod.rs b/src/import/mod.rs index deff08c..91e28e4 100644 --- a/src/import/mod.rs +++ b/src/import/mod.rs @@ -1,3 +1,2 @@ -pub mod wizard; pub mod analyzer; - +pub mod wizard; diff --git a/src/import/wizard.rs b/src/import/wizard.rs index 1b02fca..24fce77 100644 --- a/src/import/wizard.rs +++ b/src/import/wizard.rs @@ -1,9 +1,11 @@ -use serde_json::Value; use anyhow::{anyhow, Result}; +use serde_json::Value; -use super::analyzer::{FieldKind, FieldProposal, analyze_records, extract_array_at_path, find_array_paths}; -use crate::model::Model; +use super::analyzer::{ + analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal, +}; use crate::model::cell::{CellKey, CellValue}; +use crate::model::Model; // ── Pipeline (no UI state) ──────────────────────────────────────────────────── @@ -75,10 +77,16 @@ impl ImportPipeline { /// Build a Model from the current proposals. Pure — no side effects. pub fn build_model(&self) -> Result { - let categories: Vec<&FieldProposal> = self.proposals.iter() - .filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory)) + let categories: Vec<&FieldProposal> = self + .proposals + .iter() + .filter(|p| { + p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory) + }) .collect(); - let measures: Vec<&FieldProposal> = self.proposals.iter() + let measures: Vec<&FieldProposal> = self + .proposals + .iter() .filter(|p| p.accepted && p.kind == FieldKind::Measure) .collect(); @@ -112,7 +120,8 @@ impl ImportPipeline { let mut valid = true; for cat_proposal in &categories { - let val = map.get(&cat_proposal.field) + let val = map + .get(&cat_proposal.field) .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string())); @@ -128,7 +137,9 @@ impl ImportPipeline { } } - if !valid { continue; } + if !valid { + continue; + } for measure in &measures { if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) { @@ -180,7 +191,12 @@ impl ImportWizard { WizardStep::ReviewProposals }; - Self { pipeline, step, cursor: 0, message: None } + Self { + pipeline, + step, + cursor: 0, + message: None, + } } // ── Step transitions ────────────────────────────────────────────────────── @@ -219,7 +235,9 @@ impl ImportWizard { WizardStep::ReviewProposals => self.pipeline.proposals.len(), _ => 0, }; - if len == 0 { return; } + if len == 0 { + return; + } if delta > 0 { self.cursor = (self.cursor + 1).min(len - 1); } else if self.cursor > 0 { @@ -240,18 +258,22 @@ impl ImportWizard { if self.cursor < self.pipeline.proposals.len() { let p = &mut self.pipeline.proposals[self.cursor]; p.kind = match p.kind { - FieldKind::Category => FieldKind::Measure, - FieldKind::Measure => FieldKind::TimeCategory, + FieldKind::Category => FieldKind::Measure, + FieldKind::Measure => FieldKind::TimeCategory, FieldKind::TimeCategory => FieldKind::Label, - FieldKind::Label => FieldKind::Category, + FieldKind::Label => FieldKind::Category, }; } } // ── Model name input ────────────────────────────────────────────────────── - pub fn push_name_char(&mut self, c: char) { self.pipeline.model_name.push(c); } - pub fn pop_name_char(&mut self) { self.pipeline.model_name.pop(); } + pub fn push_name_char(&mut self, c: char) { + self.pipeline.model_name.push(c); + } + pub fn pop_name_char(&mut self) { + self.pipeline.model_name.pop(); + } // ── Delegate build to pipeline ──────────────────────────────────────────── @@ -262,9 +284,9 @@ impl ImportWizard { #[cfg(test)] mod tests { - use serde_json::json; use super::ImportPipeline; use crate::import::analyzer::FieldKind; + use serde_json::json; #[test] fn flat_array_auto_selected() { @@ -337,7 +359,9 @@ mod tests { fn build_model_fails_with_no_accepted_categories() { let raw = json!([{"revenue": 100.0, "cost": 50.0}]); let mut p = ImportPipeline::new(raw); - for prop in &mut p.proposals { prop.accepted = false; } + for prop in &mut p.proposals { + prop.accepted = false; + } assert!(p.build_model().is_err()); } @@ -364,14 +388,20 @@ mod tests { use crate::model::cell::CellKey; let k_east = CellKey::new(vec![ ("Measure".to_string(), "revenue".to_string()), - ("region".to_string(), "East".to_string()), + ("region".to_string(), "East".to_string()), ]); let k_west = CellKey::new(vec![ ("Measure".to_string(), "revenue".to_string()), - ("region".to_string(), "West".to_string()), + ("region".to_string(), "West".to_string()), ]); - assert_eq!(model.get_cell(&k_east).and_then(|v| v.as_f64()), Some(100.0)); - assert_eq!(model.get_cell(&k_west).and_then(|v| v.as_f64()), Some(200.0)); + assert_eq!( + model.get_cell(&k_east).and_then(|v| v.as_f64()), + Some(100.0) + ); + assert_eq!( + model.get_cell(&k_west).and_then(|v| v.as_f64()), + Some(200.0) + ); } #[test] diff --git a/src/model/category.rs b/src/model/category.rs index c48af35..a02a232 100644 --- a/src/model/category.rs +++ b/src/model/category.rs @@ -14,7 +14,11 @@ pub struct Item { impl Item { pub fn new(id: ItemId, name: impl Into) -> Self { - Self { id, name: name.into(), group: None } + Self { + id, + name: name.into(), + group: None, + } } pub fn with_group(mut self, group: impl Into) -> Self { @@ -32,7 +36,10 @@ pub struct Group { impl Group { pub fn new(name: impl Into) -> Self { - Self { name: name.into(), parent: None } + Self { + name: name.into(), + parent: None, + } } pub fn with_parent(mut self, parent: impl Into) -> Self { @@ -75,7 +82,11 @@ impl Category { id } - pub fn add_item_in_group(&mut self, name: impl Into, group: impl Into) -> ItemId { + pub fn add_item_in_group( + &mut self, + name: impl Into, + group: impl Into, + ) -> ItemId { let name = name.into(); let group = group.into(); if let Some(item) = self.items.get(&name) { @@ -83,7 +94,8 @@ impl Category { } let id = self.next_item_id; self.next_item_id += 1; - self.items.insert(name.clone(), Item::new(id, name).with_group(group)); + self.items + .insert(name.clone(), Item::new(id, name).with_group(group)); id } @@ -106,18 +118,18 @@ impl Category { self.items.keys().map(|s| s.as_str()).collect() } - // /// Returns unique group names at the top level - // pub fn top_level_groups(&self) -> Vec<&str> { - // let mut seen = Vec::new(); - // for item in self.items.values() { - // if let Some(g) = &item.group { - // if !seen.contains(&g.as_str()) { - // seen.push(g.as_str()); - // } - // } - // } - // seen - // } + /// Returns unique group names in insertion order, derived from item.group fields. + pub fn top_level_groups(&self) -> Vec<&str> { + let mut seen = Vec::new(); + for item in self.items.values() { + if let Some(g) = &item.group { + if !seen.contains(&g.as_str()) { + seen.push(g.as_str()); + } + } + } + seen + } } #[cfg(test)] @@ -150,7 +162,10 @@ mod tests { fn add_item_in_group_sets_group() { let mut c = cat(); c.add_item_in_group("Jan", "Q1"); - assert_eq!(c.items.get("Jan").and_then(|i| i.group.as_deref()), Some("Q1")); + assert_eq!( + c.items.get("Jan").and_then(|i| i.group.as_deref()), + Some("Q1") + ); } #[test] @@ -170,15 +185,29 @@ mod tests { assert_eq!(c.groups.len(), 1); } - // #[test] - // fn top_level_groups_returns_unique_groups_in_insertion_order() { - // let mut c = cat(); - // c.add_item_in_group("Jan", "Q1"); - // c.add_item_in_group("Feb", "Q1"); - // c.add_item_in_group("Apr", "Q2"); - // let groups = c.top_level_groups(); - // assert_eq!(groups, vec!["Q1", "Q2"]); - // } + #[test] + fn top_level_groups_returns_unique_groups_in_insertion_order() { + let mut c = cat(); + c.add_item_in_group("Jan", "Q1"); + c.add_item_in_group("Feb", "Q1"); + c.add_item_in_group("Apr", "Q2"); + assert_eq!(c.top_level_groups(), vec!["Q1", "Q2"]); + } + + #[test] + fn top_level_groups_empty_for_ungrouped_category() { + let mut c = cat(); + c.add_item("East"); + c.add_item("West"); + assert!(c.top_level_groups().is_empty()); + } + + #[test] + fn top_level_groups_only_reflects_item_group_fields_not_groups_vec() { + let mut c = cat(); + c.add_group(Group::new("Orphan")); + assert!(c.top_level_groups().is_empty()); + } #[test] fn item_index_reflects_insertion_order() { @@ -186,8 +215,8 @@ mod tests { c.add_item("East"); c.add_item("West"); c.add_item("North"); - assert_eq!(c.items.get_index_of("East"), Some(0)); - assert_eq!(c.items.get_index_of("West"), Some(1)); + assert_eq!(c.items.get_index_of("East"), Some(0)); + assert_eq!(c.items.get_index_of("West"), Some(1)); assert_eq!(c.items.get_index_of("North"), Some(2)); } } diff --git a/src/model/cell.rs b/src/model/cell.rs index 1b91b81..a3cf63e 100644 --- a/src/model/cell.rs +++ b/src/model/cell.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// A cell key is a sorted vector of (category_name, item_name) pairs. /// Sorted by category name for canonical form. @@ -13,7 +13,10 @@ impl CellKey { } pub fn get(&self, category: &str) -> Option<&str> { - self.0.iter().find(|(c, _)| c == category).map(|(_, v)| v.as_str()) + self.0 + .iter() + .find(|(c, _)| c == category) + .map(|(_, v)| v.as_str()) } pub fn with(mut self, category: impl Into, item: impl Into) -> Self { @@ -29,11 +32,19 @@ impl CellKey { } pub fn without(&self, category: &str) -> Self { - Self(self.0.iter().filter(|(c, _)| c != category).cloned().collect()) + Self( + self.0 + .iter() + .filter(|(c, _)| c != category) + .cloned() + .collect(), + ) } pub fn matches_partial(&self, partial: &[(String, String)]) -> bool { - partial.iter().all(|(cat, item)| self.get(cat) == Some(item.as_str())) + partial + .iter() + .all(|(cat, item)| self.get(cat) == Some(item.as_str())) } } @@ -123,7 +134,8 @@ impl DataStore { /// All cells where partial coords match pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> { - self.cells.iter() + self.cells + .iter() .filter(|(key, _)| key.matches_partial(partial)) .collect() } @@ -134,12 +146,21 @@ mod cell_key { use super::CellKey; fn key(pairs: &[(&str, &str)]) -> CellKey { - CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) } #[test] fn coords_are_sorted_by_category_name() { - let k = key(&[("Region", "East"), ("Measure", "Revenue"), ("Product", "Shirts")]); + let k = key(&[ + ("Region", "East"), + ("Measure", "Revenue"), + ("Product", "Shirts"), + ]); assert_eq!(k.0[0].0, "Measure"); assert_eq!(k.0[1].0, "Product"); assert_eq!(k.0[2].0, "Region"); @@ -227,7 +248,12 @@ mod data_store { use super::{CellKey, CellValue, DataStore}; fn key(pairs: &[(&str, &str)]) -> CellKey { - CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) } #[test] @@ -265,9 +291,18 @@ mod data_store { #[test] fn matching_cells_returns_correct_subset() { let mut store = DataStore::new(); - store.set(key(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0)); - store.set(key(&[("Measure", "Revenue"), ("Region", "West")]), CellValue::Number(200.0)); - store.set(key(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(50.0)); + store.set( + key(&[("Measure", "Revenue"), ("Region", "East")]), + CellValue::Number(100.0), + ); + store.set( + key(&[("Measure", "Revenue"), ("Region", "West")]), + CellValue::Number(200.0), + ); + store.set( + key(&[("Measure", "Cost"), ("Region", "East")]), + CellValue::Number(50.0), + ); let partial = vec![("Measure".to_string(), "Revenue".to_string())]; let cells = store.matching_cells(&partial); assert_eq!(cells.len(), 2); @@ -275,13 +310,12 @@ mod data_store { assert!(values.contains(&100.0)); assert!(values.contains(&200.0)); } - } #[cfg(test)] mod prop_tests { - use proptest::prelude::*; use super::{CellKey, CellValue, DataStore}; + use proptest::prelude::*; /// Strategy: map of unique cat→item strings (HashMap guarantees unique keys). fn pairs_map() -> impl Strategy> { diff --git a/src/model/model.rs b/src/model/model.rs index 35382a8..6a80c52 100644 --- a/src/model/model.rs +++ b/src/model/model.rs @@ -1,6 +1,6 @@ +use anyhow::{anyhow, Result}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use anyhow::{anyhow, Result}; use super::category::{Category, CategoryId}; use super::cell::{CellKey, CellValue, DataStore}; @@ -47,7 +47,8 @@ impl Model { } let id = self.next_category_id; self.next_category_id += 1; - self.categories.insert(name.clone(), Category::new(id, name.clone())); + self.categories + .insert(name.clone(), Category::new(id, name.clone())); // Add to all views for view in self.views.values_mut() { view.on_category_added(&name); @@ -87,7 +88,8 @@ impl Model { } pub fn remove_formula(&mut self, target: &str, target_category: &str) { - self.formulas.retain(|f| !(f.target == target && f.target_category == target_category)); + self.formulas + .retain(|f| !(f.target == target && f.target_category == target_category)); } pub fn formulas(&self) -> &[Formula] { @@ -95,12 +97,14 @@ impl Model { } pub fn active_view(&self) -> &View { - self.views.get(&self.active_view) + self.views + .get(&self.active_view) .expect("active_view always names an existing view") } pub fn active_view_mut(&mut self) -> &mut View { - self.views.get_mut(&self.active_view) + self.views + .get_mut(&self.active_view) .expect("active_view always names an existing view") } @@ -169,11 +173,12 @@ impl Model { } fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option { - use crate::formula::{Expr, AggFunc}; + use crate::formula::{AggFunc, Expr}; // Check WHERE filter first if let Some(filter) = &formula.filter { - let matches = context.get(&filter.category) + let matches = context + .get(&filter.category) .map(|v| v == filter.item.as_str()) .unwrap_or(false); if !matches { @@ -211,12 +216,18 @@ impl Model { BinOp::Add => lv + rv, BinOp::Sub => lv - rv, BinOp::Mul => lv * rv, - BinOp::Div => { if rv == 0.0 { return None; } lv / rv } + BinOp::Div => { + if rv == 0.0 { + return None; + } + lv / rv + } BinOp::Pow => lv.powf(rv), // Comparison operators are handled by eval_bool; reaching // here means a comparison was used where a number is expected. - BinOp::Eq | BinOp::Ne | BinOp::Lt | - BinOp::Gt | BinOp::Le | BinOp::Ge => return None, + BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => { + return None + } }) } Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?), @@ -230,15 +241,20 @@ impl Model { if let Some(f) = agg_filter { partial = partial.with(&f.category, &f.item); } - let values: Vec = model.data.matching_cells(&partial.0) + let values: Vec = model + .data + .matching_cells(&partial.0) .into_iter() .filter_map(|(_, v)| v.as_f64()) .collect(); match func { AggFunc::Sum => Some(values.iter().sum()), AggFunc::Avg => { - if values.is_empty() { None } - else { Some(values.iter().sum::() / values.len() as f64) } + if values.is_empty() { + None + } else { + Some(values.iter().sum::() / values.len() as f64) + } } AggFunc::Min => values.iter().cloned().reduce(f64::min), AggFunc::Max => values.iter().cloned().reduce(f64::max), @@ -275,16 +291,16 @@ impl Model { BinOp::Le => lv <= rv, BinOp::Ge => lv >= rv, // Arithmetic operators are not comparisons - BinOp::Add | BinOp::Sub | BinOp::Mul | - BinOp::Div | BinOp::Pow => return None, + BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => { + return None + } }) } _ => None, } } - eval_expr(&formula.expr, context, self, &formula.target_category) - .map(CellValue::Number) + eval_expr(&formula.expr, context, self, &formula.target_category).map(CellValue::Number) } } @@ -295,7 +311,12 @@ mod model_tests { use crate::view::Axis; fn coord(pairs: &[(&str, &str)]) -> CellKey { - CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) } #[test] @@ -324,7 +345,9 @@ mod model_tests { #[test] fn add_category_max_limit() { let mut m = Model::new("Test"); - for i in 0..12 { m.add_category(format!("Cat{i}")).unwrap(); } + for i in 0..12 { + m.add_category(format!("Cat{i}")).unwrap(); + } assert!(m.add_category("TooMany").is_err()); } @@ -368,10 +391,26 @@ mod model_tests { m.add_category("Region").unwrap(); m.add_category("Product").unwrap(); m.add_category("Measure").unwrap(); - let k1 = coord(&[("Region", "East"), ("Product", "Shirts"), ("Measure", "Revenue")]); - let k2 = coord(&[("Region", "West"), ("Product", "Shirts"), ("Measure", "Revenue")]); - let k3 = coord(&[("Region", "East"), ("Product", "Pants"), ("Measure", "Revenue")]); - let k4 = coord(&[("Region", "East"), ("Product", "Shirts"), ("Measure", "Cost")]); + let k1 = coord(&[ + ("Region", "East"), + ("Product", "Shirts"), + ("Measure", "Revenue"), + ]); + let k2 = coord(&[ + ("Region", "West"), + ("Product", "Shirts"), + ("Measure", "Revenue"), + ]); + let k3 = coord(&[ + ("Region", "East"), + ("Product", "Pants"), + ("Measure", "Revenue"), + ]); + let k4 = coord(&[ + ("Region", "East"), + ("Product", "Shirts"), + ("Measure", "Cost"), + ]); m.set_cell(k1.clone(), CellValue::Number(100.0)); m.set_cell(k2.clone(), CellValue::Number(200.0)); m.set_cell(k3.clone(), CellValue::Number(300.0)); @@ -438,9 +477,9 @@ mod model_tests { m.add_category("Product").unwrap(); m.add_category("Time").unwrap(); let v = m.active_view(); - assert_eq!(v.axis_of("Region"), Axis::Row); + assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); - assert_eq!(v.axis_of("Time"), Axis::Page); + assert_eq!(v.axis_of("Time"), Axis::Page); } #[test] @@ -456,14 +495,21 @@ mod model_tests { #[cfg(test)] mod formula_tests { use super::Model; - use crate::model::cell::{CellKey, CellValue}; use crate::formula::parse_formula; + use crate::model::cell::{CellKey, CellValue}; fn coord(pairs: &[(&str, &str)]) -> CellKey { - CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) } - fn approx_eq(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 } + fn approx_eq(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } fn revenue_cost_model() -> Model { let mut m = Model::new("Test"); @@ -478,10 +524,22 @@ mod formula_tests { cat.add_item("East"); cat.add_item("West"); } - m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0)); - m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0)); - m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "West")]), CellValue::Number(800.0)); - m.set_cell(coord(&[("Measure", "Cost"), ("Region", "West")]), CellValue::Number(500.0)); + m.set_cell( + coord(&[("Measure", "Revenue"), ("Region", "East")]), + CellValue::Number(1000.0), + ); + m.set_cell( + coord(&[("Measure", "Cost"), ("Region", "East")]), + CellValue::Number(600.0), + ); + m.set_cell( + coord(&[("Measure", "Revenue"), ("Region", "West")]), + CellValue::Number(800.0), + ); + m.set_cell( + coord(&[("Measure", "Cost"), ("Region", "West")]), + CellValue::Number(500.0), + ); m } @@ -507,8 +565,13 @@ mod formula_tests { fn formula_multiplication() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Tax = Revenue * 0.1", "Measure").unwrap()); - if let Some(cat) = m.category_mut("Measure") { cat.add_item("Tax"); } - let val = m.evaluate(&coord(&[("Measure", "Tax"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap(); + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("Tax"); + } + let val = m + .evaluate(&coord(&[("Measure", "Tax"), ("Region", "East")])) + .and_then(|v| v.as_f64()) + .unwrap(); assert!(approx_eq(val, 100.0)); } @@ -521,7 +584,10 @@ mod formula_tests { cat.add_item("Profit"); cat.add_item("Margin"); } - let val = m.evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap(); + let val = m + .evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")])) + .and_then(|v| v.as_f64()) + .unwrap(); assert!(approx_eq(val, 0.4)); } @@ -535,18 +601,29 @@ mod formula_tests { cat.add_item("Zero"); cat.add_item("Result"); } - m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0)); - m.set_cell(coord(&[("Measure", "Zero"), ("Region", "East")]), CellValue::Number(0.0)); + m.set_cell( + coord(&[("Measure", "Revenue"), ("Region", "East")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Measure", "Zero"), ("Region", "East")]), + CellValue::Number(0.0), + ); m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap()); // Division by zero must yield Empty, not 0, so the user sees a blank not a misleading zero. - assert_eq!(m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), None); + assert_eq!( + m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), + None + ); } #[test] fn unary_minus() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("NegRevenue = -Revenue", "Measure").unwrap()); - if let Some(cat) = m.category_mut("Measure") { cat.add_item("NegRevenue"); } + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("NegRevenue"); + } let k = coord(&[("Measure", "NegRevenue"), ("Region", "East")]); assert_eq!(m.evaluate(&k), Some(CellValue::Number(-1000.0))); } @@ -561,14 +638,19 @@ mod formula_tests { } m.set_cell(coord(&[("Measure", "Base")]), CellValue::Number(4.0)); m.add_formula(parse_formula("Squared = Base ^ 2", "Measure").unwrap()); - assert_eq!(m.evaluate(&coord(&[("Measure", "Squared")])), Some(CellValue::Number(16.0))); + assert_eq!( + m.evaluate(&coord(&[("Measure", "Squared")])), + Some(CellValue::Number(16.0)) + ); } #[test] fn formula_with_missing_ref_returns_empty() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Ghost = NoSuchField - Cost", "Measure").unwrap()); - if let Some(cat) = m.category_mut("Measure") { cat.add_item("Ghost"); } + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("Ghost"); + } let k = coord(&[("Measure", "Ghost"), ("Region", "East")]); assert_eq!(m.evaluate(&k), None); } @@ -576,18 +658,32 @@ mod formula_tests { #[test] fn formula_where_applied_to_matching_region() { let mut m = revenue_cost_model(); - m.add_formula(parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap()); - if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); } - let val = m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap(); + m.add_formula( + parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap(), + ); + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("EastOnly"); + } + let val = m + .evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "East")])) + .and_then(|v| v.as_f64()) + .unwrap(); assert!(approx_eq(val, 1000.0)); } #[test] fn formula_where_not_applied_to_non_matching_region() { let mut m = revenue_cost_model(); - m.add_formula(parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap()); - if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); } - assert_eq!(m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])), None); + m.add_formula( + parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap(), + ); + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("EastOnly"); + } + assert_eq!( + m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])), + None + ); } #[test] @@ -614,8 +710,13 @@ mod formula_tests { fn sum_aggregation_across_region() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap()); - if let Some(cat) = m.category_mut("Measure") { cat.add_item("Total"); } - let val = m.evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap(); + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("Total"); + } + let val = m + .evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])) + .and_then(|v| v.as_f64()) + .unwrap(); // Revenue(East)=1000 only — Cost must not be included assert_eq!(val, 1000.0); } @@ -630,10 +731,16 @@ mod formula_tests { cat.add_item("Count"); } for region in ["East", "West", "North"] { - m.set_cell(coord(&[("Measure", "Sales"), ("Region", region)]), CellValue::Number(100.0)); + m.set_cell( + coord(&[("Measure", "Sales"), ("Region", region)]), + CellValue::Number(100.0), + ); } m.add_formula(parse_formula("Count = COUNT(Sales)", "Measure").unwrap()); - let val = m.evaluate(&coord(&[("Measure", "Count"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap(); + let val = m + .evaluate(&coord(&[("Measure", "Count"), ("Region", "East")])) + .and_then(|v| v.as_f64()) + .unwrap(); assert!(val >= 1.0); } @@ -647,7 +754,10 @@ mod formula_tests { } m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(10.0)); m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap()); - assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(1.0))); + assert_eq!( + m.evaluate(&coord(&[("Measure", "Result")])), + Some(CellValue::Number(1.0)) + ); } #[test] @@ -660,7 +770,10 @@ mod formula_tests { } m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(3.0)); m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap()); - assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(0.0))); + assert_eq!( + m.evaluate(&coord(&[("Measure", "Result")])), + Some(CellValue::Number(0.0)) + ); } // ── Bug regression tests ───────────────────────────────────────────────── @@ -710,8 +823,14 @@ mod formula_tests { if let Some(cat) = m.category_mut("Region") { cat.add_item("East"); } - m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0)); - m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(50.0)); + m.set_cell( + coord(&[("Measure", "Revenue"), ("Region", "East")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Measure", "Cost"), ("Region", "East")]), + CellValue::Number(50.0), + ); m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap()); // Expected: 100 (SUM constrainted to Revenue only) // Bug: returns 150 — inner Ref("Revenue") is ignored, Cost is also summed @@ -730,8 +849,12 @@ mod formula_tests { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("KPI").unwrap(); - if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); } - if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); } + if let Some(c) = m.category_mut("Measure") { + c.add_item("Profit"); + } + if let Some(c) = m.category_mut("KPI") { + c.add_item("Profit"); + } m.add_formula(parse_formula("Profit = 1", "Measure").unwrap()); m.add_formula(parse_formula("Profit = 2", "KPI").unwrap()); @@ -748,16 +871,26 @@ mod formula_tests { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("KPI").unwrap(); - if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); } - if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); } + if let Some(c) = m.category_mut("Measure") { + c.add_item("Profit"); + } + if let Some(c) = m.category_mut("KPI") { + c.add_item("Profit"); + } m.add_formula(parse_formula("Profit = 1", "Measure").unwrap()); m.add_formula(parse_formula("Profit = 2", "KPI").unwrap()); // Measure formula → 1, KPI formula → 2 // Bug: first formula was replaced; {Measure=Profit} evaluates to Empty. - assert_eq!(m.evaluate(&coord(&[("Measure", "Profit")])), Some(CellValue::Number(1.0))); - assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0))); + assert_eq!( + m.evaluate(&coord(&[("Measure", "Profit")])), + Some(CellValue::Number(1.0)) + ); + assert_eq!( + m.evaluate(&coord(&[("KPI", "Profit")])), + Some(CellValue::Number(2.0)) + ); } /// Bug: remove_formula matches by target name alone, so removing "Profit" @@ -768,8 +901,12 @@ mod formula_tests { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("KPI").unwrap(); - if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); } - if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); } + if let Some(c) = m.category_mut("Measure") { + c.add_item("Profit"); + } + if let Some(c) = m.category_mut("KPI") { + c.add_item("Profit"); + } m.add_formula(parse_formula("Profit = 1", "Measure").unwrap()); m.add_formula(parse_formula("Profit = 2", "KPI").unwrap()); @@ -780,34 +917,37 @@ mod formula_tests { // KPI formula must survive // Bug: remove_formula("Profit") wipes both; formulas.len() == 0 assert_eq!(m.formulas.len(), 1); - assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0))); + assert_eq!( + m.evaluate(&coord(&[("KPI", "Profit")])), + Some(CellValue::Number(2.0)) + ); } } #[cfg(test)] mod five_category { use super::Model; - use crate::model::cell::{CellKey, CellValue}; use crate::formula::parse_formula; + use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; const DATA: &[(&str, &str, &str, &str, f64, f64)] = &[ - ("East", "Shirts", "Online", "Q1", 1_000.0, 600.0), - ("East", "Shirts", "Online", "Q2", 1_200.0, 700.0), - ("East", "Shirts", "Retail", "Q1", 800.0, 500.0), - ("East", "Shirts", "Retail", "Q2", 900.0, 540.0), - ("East", "Pants", "Online", "Q1", 500.0, 300.0), - ("East", "Pants", "Online", "Q2", 600.0, 360.0), - ("East", "Pants", "Retail", "Q1", 400.0, 240.0), - ("East", "Pants", "Retail", "Q2", 450.0, 270.0), - ("West", "Shirts", "Online", "Q1", 700.0, 420.0), - ("West", "Shirts", "Online", "Q2", 750.0, 450.0), - ("West", "Shirts", "Retail", "Q1", 600.0, 360.0), - ("West", "Shirts", "Retail", "Q2", 650.0, 390.0), - ("West", "Pants", "Online", "Q1", 300.0, 180.0), - ("West", "Pants", "Online", "Q2", 350.0, 210.0), - ("West", "Pants", "Retail", "Q1", 250.0, 150.0), - ("West", "Pants", "Retail", "Q2", 280.0, 168.0), + ("East", "Shirts", "Online", "Q1", 1_000.0, 600.0), + ("East", "Shirts", "Online", "Q2", 1_200.0, 700.0), + ("East", "Shirts", "Retail", "Q1", 800.0, 500.0), + ("East", "Shirts", "Retail", "Q2", 900.0, 540.0), + ("East", "Pants", "Online", "Q1", 500.0, 300.0), + ("East", "Pants", "Online", "Q2", 600.0, 360.0), + ("East", "Pants", "Retail", "Q1", 400.0, 240.0), + ("East", "Pants", "Retail", "Q2", 450.0, 270.0), + ("West", "Shirts", "Online", "Q1", 700.0, 420.0), + ("West", "Shirts", "Online", "Q2", 750.0, 450.0), + ("West", "Shirts", "Retail", "Q1", 600.0, 360.0), + ("West", "Shirts", "Retail", "Q2", 650.0, 390.0), + ("West", "Pants", "Online", "Q1", 300.0, 180.0), + ("West", "Pants", "Online", "Q2", 350.0, 210.0), + ("West", "Pants", "Retail", "Q1", 250.0, 150.0), + ("West", "Pants", "Retail", "Q2", 280.0, 168.0), ]; fn coord(region: &str, product: &str, channel: &str, time: &str, measure: &str) -> CellKey { @@ -815,8 +955,8 @@ mod five_category { ("Channel".to_string(), channel.to_string()), ("Measure".to_string(), measure.to_string()), ("Product".to_string(), product.to_string()), - ("Region".to_string(), region.to_string()), - ("Time".to_string(), time.to_string()), + ("Region".to_string(), region.to_string()), + ("Time".to_string(), time.to_string()), ]) } @@ -827,14 +967,16 @@ mod five_category { } for cat in ["Region", "Product", "Channel", "Time"] { let items: &[&str] = match cat { - "Region" => &["East", "West"], + "Region" => &["East", "West"], "Product" => &["Shirts", "Pants"], "Channel" => &["Online", "Retail"], - "Time" => &["Q1", "Q2"], - _ => &[], + "Time" => &["Q1", "Q2"], + _ => &[], }; if let Some(c) = m.category_mut(cat) { - for &item in items { c.add_item(item); } + for &item in items { + c.add_item(item); + } } } if let Some(c) = m.category_mut("Measure") { @@ -843,21 +985,30 @@ mod five_category { } } for &(region, product, channel, time, rev, cost) in DATA { - m.set_cell(coord(region, product, channel, time, "Revenue"), CellValue::Number(rev)); - m.set_cell(coord(region, product, channel, time, "Cost"), CellValue::Number(cost)); + m.set_cell( + coord(region, product, channel, time, "Revenue"), + CellValue::Number(rev), + ); + m.set_cell( + coord(region, product, channel, time, "Cost"), + CellValue::Number(cost), + ); } - m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); + m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); m.add_formula(parse_formula("Margin = Profit / Revenue", "Measure").unwrap()); - m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap()); + m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap()); m } - fn approx(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 } + fn approx(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } #[test] fn all_sixteen_revenue_cells_stored() { let m = build_model(); - let count = DATA.iter() + let count = DATA + .iter() .filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Revenue")).is_none()) .count(); assert_eq!(count, 16); @@ -866,7 +1017,8 @@ mod five_category { #[test] fn all_sixteen_cost_cells_stored() { let m = build_model(); - let count = DATA.iter() + let count = DATA + .iter() .filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Cost")).is_none()) .count(); assert_eq!(count, 16); @@ -875,15 +1027,25 @@ mod five_category { #[test] fn spot_check_raw_revenue() { let m = build_model(); - assert_eq!(m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0))); - assert_eq!(m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")), Some(&CellValue::Number(280.0))); + assert_eq!( + m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), + Some(&CellValue::Number(1_000.0)) + ); + assert_eq!( + m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")), + Some(&CellValue::Number(280.0)) + ); } #[test] fn distinct_cells_do_not_alias() { let m = build_model(); - let a = m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")).clone(); - let b = m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")).clone(); + let a = m + .get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")) + .clone(); + let b = m + .get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")) + .clone(); assert_ne!(a, b); } @@ -892,11 +1054,14 @@ mod five_category { let m = build_model(); for &(region, product, channel, time, rev, cost) in DATA { let expected = rev - cost; - let actual = m.evaluate(&coord(region, product, channel, time, "Profit")) - .and_then(|v| v.as_f64()) - .unwrap_or_else(|| panic!("Profit empty at {region}/{product}/{channel}/{time}")); - assert!(approx(actual, expected), - "Profit at {region}/{product}/{channel}/{time}: expected {expected}, got {actual}"); + let actual = m + .evaluate(&coord(region, product, channel, time, "Profit")) + .and_then(|v| v.as_f64()) + .unwrap_or_else(|| panic!("Profit empty at {region}/{product}/{channel}/{time}")); + assert!( + approx(actual, expected), + "Profit at {region}/{product}/{channel}/{time}: expected {expected}, got {actual}" + ); } } @@ -905,9 +1070,10 @@ mod five_category { let m = build_model(); for &(region, product, channel, time, rev, cost) in DATA { let expected = (rev - cost) / rev; - let actual = m.evaluate(&coord(region, product, channel, time, "Margin")) - .and_then(|v| v.as_f64()) - .unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}")); + let actual = m + .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}"); } @@ -916,17 +1082,29 @@ mod five_category { #[test] fn chained_formula_profit_feeds_margin() { let m = build_model(); - let margin = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")).and_then(|v| v.as_f64()).unwrap(); + let margin = m + .evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")) + .and_then(|v| v.as_f64()) + .unwrap(); assert!(approx(margin, 0.4), "expected 0.4, got {margin}"); } #[test] fn update_revenue_updates_profit_and_margin() { let mut m = build_model(); - m.set_cell(coord("East", "Shirts", "Online", "Q1", "Revenue"), CellValue::Number(1_500.0)); - let profit = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Profit")).and_then(|v| v.as_f64()).unwrap(); + m.set_cell( + coord("East", "Shirts", "Online", "Q1", "Revenue"), + CellValue::Number(1_500.0), + ); + let profit = m + .evaluate(&coord("East", "Shirts", "Online", "Q1", "Profit")) + .and_then(|v| v.as_f64()) + .unwrap(); assert!(approx(profit, 900.0), "expected 900, got {profit}"); - let margin = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")).and_then(|v| v.as_f64()).unwrap(); + let margin = m + .evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")) + .and_then(|v| v.as_f64()) + .unwrap(); assert!(approx(margin, 0.6), "expected 0.6, got {margin}"); } @@ -935,10 +1113,14 @@ mod five_category { let m = build_model(); let key = CellKey::new(vec![ ("Measure".to_string(), "Total".to_string()), - ("Region".to_string(), "East".to_string()), + ("Region".to_string(), "East".to_string()), ]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); - let expected: f64 = DATA.iter().filter(|&&(r, _, _, _, _, _)| r == "East").map(|&(_, _, _, _, rev, _)| rev).sum(); + let expected: f64 = DATA + .iter() + .filter(|&&(r, _, _, _, _, _)| r == "East") + .map(|&(_, _, _, _, rev, _)| rev) + .sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); } @@ -950,7 +1132,11 @@ mod five_category { ("Measure".to_string(), "Total".to_string()), ]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); - let expected: f64 = DATA.iter().filter(|&&(_, _, ch, _, _, _)| ch == "Online").map(|&(_, _, _, _, rev, _)| rev).sum(); + let expected: f64 = DATA + .iter() + .filter(|&&(_, _, ch, _, _, _)| ch == "Online") + .map(|&(_, _, _, _, rev, _)| rev) + .sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); } @@ -960,19 +1146,21 @@ mod five_category { let key = CellKey::new(vec![ ("Measure".to_string(), "Total".to_string()), ("Product".to_string(), "Shirts".to_string()), - ("Time".to_string(), "Q1".to_string()), + ("Time".to_string(), "Q1".to_string()), ]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); - let expected: f64 = DATA.iter().filter(|&&(_, p, _, t, _, _)| p == "Shirts" && t == "Q1").map(|&(_, _, _, _, rev, _)| rev).sum(); + let expected: f64 = DATA + .iter() + .filter(|&&(_, p, _, t, _, _)| p == "Shirts" && t == "Q1") + .map(|&(_, _, _, _, rev, _)| rev) + .sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); } #[test] fn sum_all_revenue_equals_grand_total() { let m = build_model(); - let key = CellKey::new(vec![ - ("Measure".to_string(), "Total".to_string()), - ]); + let key = CellKey::new(vec![("Measure".to_string(), "Total".to_string())]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); let expected: f64 = DATA.iter().map(|&(_, _, _, _, rev, _)| rev).sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); @@ -982,10 +1170,10 @@ mod five_category { fn default_view_first_two_on_axes_rest_on_page() { let m = build_model(); let v = m.active_view(); - assert_eq!(v.axis_of("Region"), Axis::Row); + assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); assert_eq!(v.axis_of("Channel"), Axis::Page); - assert_eq!(v.axis_of("Time"), Axis::Page); + assert_eq!(v.axis_of("Time"), Axis::Page); assert_eq!(v.axis_of("Measure"), Axis::Page); } @@ -994,13 +1182,16 @@ mod five_category { let mut m = build_model(); { let v = m.active_view_mut(); - v.set_axis("Region", Axis::Page); + v.set_axis("Region", Axis::Page); v.set_axis("Product", Axis::Page); v.set_axis("Channel", Axis::Row); - v.set_axis("Time", Axis::Column); + v.set_axis("Time", Axis::Column); v.set_axis("Measure", Axis::Page); } - assert_eq!(m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0))); + assert_eq!( + m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), + Some(&CellValue::Number(1_000.0)) + ); } #[test] @@ -1009,15 +1200,18 @@ mod five_category { m.create_view("Pivot"); { let v = m.views.get_mut("Pivot").unwrap(); - v.set_axis("Time", Axis::Row); + v.set_axis("Time", Axis::Row); v.set_axis("Channel", Axis::Column); - v.set_axis("Region", Axis::Page); + v.set_axis("Region", Axis::Page); v.set_axis("Product", Axis::Page); v.set_axis("Measure", Axis::Page); } assert_eq!(m.views.get("Default").unwrap().axis_of("Region"), Axis::Row); - assert_eq!(m.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row); - assert_eq!(m.views.get("Pivot").unwrap().axis_of("Channel"), Axis::Column); + assert_eq!(m.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row); + assert_eq!( + m.views.get("Pivot").unwrap().axis_of("Channel"), + Axis::Column + ); } #[test] @@ -1027,8 +1221,14 @@ mod five_category { if let Some(v) = m.views.get_mut("West only") { v.set_page_selection("Region", "West"); } - assert_eq!(m.views.get("Default").unwrap().page_selection("Region"), None); - assert_eq!(m.views.get("West only").unwrap().page_selection("Region"), Some("West")); + assert_eq!( + m.views.get("Default").unwrap().page_selection("Region"), + None + ); + assert_eq!( + m.views.get("West only").unwrap().page_selection("Region"), + Some("West") + ); } #[test] @@ -1036,7 +1236,9 @@ mod five_category { let m = build_model(); assert_eq!(m.categories.len(), 5); let mut m2 = build_model(); - for i in 0..7 { m2.add_category(format!("Extra{i}")).unwrap(); } + for i in 0..7 { + m2.add_category(format!("Extra{i}")).unwrap(); + } assert_eq!(m2.categories.len(), 12); assert!(m2.add_category("OneMore").is_err()); } @@ -1044,10 +1246,10 @@ mod five_category { #[cfg(test)] mod prop_tests { - use proptest::prelude::*; use super::Model; - use crate::model::cell::{CellKey, CellValue}; use crate::formula::parse_formula; + use crate::model::cell::{CellKey, CellValue}; + use proptest::prelude::*; fn finite_f64() -> impl Strategy { prop::num::f64::NORMAL.prop_filter("finite", |f| f.is_finite()) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 0638fb0..96d9f42 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -1,16 +1,15 @@ -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 std::io::{BufReader, BufWriter, Read, Write}; +use std::path::Path; -use crate::model::Model; -use crate::model::cell::{CellKey, CellValue}; -use crate::model::category::Group; -use crate::view::{Axis, GridLayout}; use crate::formula::parse_formula; - +use crate::model::category::Group; +use crate::model::cell::{CellKey, CellValue}; +use crate::model::Model; +use crate::view::{Axis, GridLayout}; pub fn save(model: &Model, path: &Path) -> Result<()> { let text = format_md(model); @@ -22,15 +21,14 @@ pub fn save(model: &Model, path: &Path) -> Result<()> { encoder.write_all(text.as_bytes())?; encoder.finish()?; } else { - std::fs::write(path, &text) - .with_context(|| format!("Cannot write {}", path.display()))?; + std::fs::write(path, &text).with_context(|| format!("Cannot write {}", path.display()))?; } Ok(()) } pub fn load(path: &Path) -> Result { - let file = std::fs::File::open(path) - .with_context(|| format!("Cannot open {}", path.display()))?; + let file = + std::fs::File::open(path).with_context(|| format!("Cannot open {}", path.display()))?; let text = if path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false) { let mut decoder = GzDecoder::new(BufReader::new(file)); @@ -70,7 +68,7 @@ pub fn format_md(model: &Model) -> String { for item in cat.items.values() { match &item.group { Some(g) => writeln!(out, "- {} [{}]", item.name, g).unwrap(), - None => writeln!(out, "- {}", item.name).unwrap(), + None => writeln!(out, "- {}", item.name).unwrap(), } } // Group hierarchy: lines starting with `>` for groups that have a parent @@ -97,7 +95,7 @@ pub fn format_md(model: &Model) -> String { for (key, value) in cells { let val_str = match value { CellValue::Number(_) => value.to_string(), - CellValue::Text(s) => format!("\"{}\"", s), + CellValue::Text(s) => format!("\"{}\"", s), }; writeln!(out, "{} = {}", coord_str(key), val_str).unwrap(); } @@ -105,25 +103,29 @@ pub fn format_md(model: &Model) -> String { // Views for (view_name, view) in &model.views { - let active = if view_name == &model.active_view { " (active)" } else { "" }; + let active = if view_name == &model.active_view { + " (active)" + } else { + "" + }; writeln!(out, "\n## View: {}{}", view.name, active).unwrap(); for (cat, axis) in &view.category_axes { match axis { - Axis::Row => writeln!(out, "{}: row", cat).unwrap(), + Axis::Row => writeln!(out, "{}: row", cat).unwrap(), Axis::Column => writeln!(out, "{}: column", cat).unwrap(), - Axis::Page => { - match view.page_selections.get(cat) { - Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(), - None => writeln!(out, "{}: page", cat).unwrap(), - } - } + Axis::Page => match view.page_selections.get(cat) { + Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(), + None => writeln!(out, "{}: page", cat).unwrap(), + }, } } if !view.number_format.is_empty() { writeln!(out, "format: {}", view.number_format).unwrap(); } // Hidden items (sorted for deterministic diffs) - let mut hidden: Vec<(&str, &str)> = view.hidden_items.iter() + let mut hidden: Vec<(&str, &str)> = view + .hidden_items + .iter() .flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str()))) .collect(); hidden.sort(); @@ -131,7 +133,9 @@ pub fn format_md(model: &Model) -> String { writeln!(out, "hidden: {}/{}", cat, item).unwrap(); } // Collapsed groups (sorted for deterministic diffs) - let mut collapsed: Vec<(&str, &str)> = view.collapsed_groups.iter() + let mut collapsed: Vec<(&str, &str)> = view + .collapsed_groups + .iter() .flat_map(|(cat, gs)| gs.iter().map(move |g| (cat.as_str(), g.as_str()))) .collect(); collapsed.sort(); @@ -153,8 +157,8 @@ pub fn parse_md(text: &str) -> Result { struct PCategory { name: String, - items: Vec<(String, Option)>, // (name, group) - group_parents: Vec<(String, String)>, // (group, parent) + items: Vec<(String, Option)>, // (name, group) + group_parents: Vec<(String, String)>, // (group, parent) } struct PView { @@ -170,50 +174,78 @@ pub fn parse_md(text: &str) -> Result { // ── Pass 1: collect ─────────────────────────────────────────────────────── #[derive(PartialEq)] - enum Section { None, Category, Formulas, Data, View } + enum Section { + None, + Category, + Formulas, + Data, + View, + } let mut model_name: Option = None; - let mut categories: Vec = Vec::new(); - let mut formulas: Vec<(String, String)> = Vec::new(); // (raw, category) - let mut data: Vec<(CellKey, CellValue)> = Vec::new(); - let mut views: Vec = Vec::new(); + let mut categories: Vec = Vec::new(); + let mut formulas: Vec<(String, String)> = Vec::new(); // (raw, category) + let mut data: Vec<(CellKey, CellValue)> = Vec::new(); + let mut views: Vec = Vec::new(); let mut section = Section::None; for line in text.lines() { let trimmed = line.trim(); - if trimmed.is_empty() { continue; } + if trimmed.is_empty() { + continue; + } if trimmed.starts_with("# ") && !trimmed.starts_with("## ") { model_name = Some(trimmed[2..].trim().to_string()); continue; } if let Some(rest) = trimmed.strip_prefix("## Category: ") { - categories.push(PCategory { name: rest.trim().to_string(), - items: Vec::new(), group_parents: Vec::new() }); + categories.push(PCategory { + name: rest.trim().to_string(), + items: Vec::new(), + group_parents: Vec::new(), + }); section = Section::Category; continue; } - if trimmed == "## Formulas" { section = Section::Formulas; continue; } - if trimmed == "## Data" { section = Section::Data; continue; } + if trimmed == "## Formulas" { + section = Section::Formulas; + continue; + } + if trimmed == "## Data" { + section = Section::Data; + continue; + } if let Some(rest) = trimmed.strip_prefix("## View: ") { let (name, is_active) = match rest.trim().strip_suffix(" (active)") { Some(n) => (n.trim().to_string(), true), - None => (rest.trim().to_string(), false), + None => (rest.trim().to_string(), false), }; - views.push(PView { name, is_active, axes: Vec::new(), - page_selections: Vec::new(), format: String::new(), - hidden: Vec::new(), collapsed: Vec::new() }); + views.push(PView { + name, + is_active, + axes: Vec::new(), + page_selections: Vec::new(), + format: String::new(), + hidden: Vec::new(), + collapsed: Vec::new(), + }); section = Section::View; continue; } - if trimmed.starts_with("## ") { continue; } + if trimmed.starts_with("## ") { + continue; + } match section { Section::Category => { - let Some(cat) = categories.last_mut() else { continue }; + let Some(cat) = categories.last_mut() else { + continue; + }; if let Some(rest) = trimmed.strip_prefix("- ") { let (name, group) = parse_bracketed(rest); - cat.items.push((name.to_string(), group.map(str::to_string))); + cat.items + .push((name.to_string(), group.map(str::to_string))); } else if let Some(rest) = trimmed.strip_prefix("> ") { let (group, parent) = parse_bracketed(rest); if let Some(p) = parent { @@ -230,14 +262,22 @@ pub fn parse_md(text: &str) -> Result { } } Section::Data => { - let Some(sep) = trimmed.find(" = ") else { continue }; - let coords: Vec<(String, String)> = trimmed[..sep].split(", ") - .filter_map(|p| { let (c, i) = p.split_once('=')?; - Some((c.trim().to_string(), i.trim().to_string())) }) + let Some(sep) = trimmed.find(" = ") else { + continue; + }; + let coords: Vec<(String, String)> = trimmed[..sep] + .split(", ") + .filter_map(|p| { + let (c, i) = p.split_once('=')?; + Some((c.trim().to_string(), i.trim().to_string())) + }) .collect(); - if coords.is_empty() { continue; } + if coords.is_empty() { + continue; + } let vs = trimmed[sep + 3..].trim(); - let value = if let Some(s) = vs.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { + let value = if let Some(s) = vs.strip_prefix('"').and_then(|s| s.strip_suffix('"')) + { CellValue::Text(s.to_string()) } else if let Ok(n) = vs.parse::() { CellValue::Number(n) @@ -247,28 +287,36 @@ pub fn parse_md(text: &str) -> Result { data.push((CellKey::new(coords), value)); } Section::View => { - let Some(view) = views.last_mut() else { continue }; + let Some(view) = views.last_mut() else { + continue; + }; if let Some(fmt) = trimmed.strip_prefix("format: ") { view.format = fmt.trim().to_string(); } else if let Some(rest) = trimmed.strip_prefix("hidden: ") { if let Some((c, i)) = rest.trim().split_once('/') { - view.hidden.push((c.trim().to_string(), i.trim().to_string())); + view.hidden + .push((c.trim().to_string(), i.trim().to_string())); } } else if let Some(rest) = trimmed.strip_prefix("collapsed: ") { if let Some((c, g)) = rest.trim().split_once('/') { - view.collapsed.push((c.trim().to_string(), g.trim().to_string())); + view.collapsed + .push((c.trim().to_string(), g.trim().to_string())); } } else if let Some(colon) = trimmed.find(": ") { - let cat = trimmed[..colon].trim(); + let cat = trimmed[..colon].trim(); let rest = trimmed[colon + 2..].trim(); if let Some(sel_rest) = rest.strip_prefix("page") { view.axes.push((cat.to_string(), Axis::Page)); if let Some(sel) = sel_rest.strip_prefix(", ") { - view.page_selections.push((cat.to_string(), sel.trim().to_string())); + view.page_selections + .push((cat.to_string(), sel.trim().to_string())); } } else { - let axis = match rest { "row" => Axis::Row, "column" => Axis::Column, - _ => continue }; + let axis = match rest { + "row" => Axis::Row, + "column" => Axis::Column, + _ => continue, + }; view.axes.push((cat.to_string(), axis)); } } @@ -294,13 +342,15 @@ pub fn parse_md(text: &str) -> Result { cat.add_group(Group::new(g)); } } - None => { cat.add_item(item_name); } + None => { + cat.add_item(item_name); + } } } for (group_name, parent) in &pc.group_parents { match cat.groups.iter_mut().find(|g| &g.name == group_name) { Some(g) => g.parent = Some(parent.clone()), - None => cat.add_group(Group::new(group_name).with_parent(parent)), + None => cat.add_group(Group::new(group_name).with_parent(parent)), } } } @@ -308,14 +358,28 @@ pub fn parse_md(text: &str) -> Result { // Views — all categories are now registered, so set_axis works correctly let mut active_view = String::new(); for pv in &views { - if pv.is_active { active_view = pv.name.clone(); } - if !m.views.contains_key(&pv.name) { m.create_view(&pv.name); } + if pv.is_active { + active_view = pv.name.clone(); + } + if !m.views.contains_key(&pv.name) { + m.create_view(&pv.name); + } let view = m.views.get_mut(&pv.name).unwrap(); - for (cat, axis) in &pv.axes { view.set_axis(cat, *axis); } - for (cat, sel) in &pv.page_selections { view.set_page_selection(cat, sel); } - if !pv.format.is_empty() { view.number_format = pv.format.clone(); } - for (cat, item) in &pv.hidden { view.hide_item(cat, item); } - for (cat, grp) in &pv.collapsed { view.toggle_group_collapse(cat, grp); } + for (cat, axis) in &pv.axes { + view.set_axis(cat, *axis); + } + for (cat, sel) in &pv.page_selections { + view.set_page_selection(cat, sel); + } + if !pv.format.is_empty() { + view.number_format = pv.format.clone(); + } + for (cat, item) in &pv.hidden { + view.hide_item(cat, item); + } + for (cat, grp) in &pv.collapsed { + view.toggle_group_collapse(cat, grp); + } } if !active_view.is_empty() && m.views.contains_key(&active_view) { m.active_view = active_view; @@ -323,8 +387,7 @@ pub fn parse_md(text: &str) -> Result { // Formulas and data can go in any order relative to each other for (raw, cat_name) in &formulas { - m.add_formula(parse_formula(raw, cat_name) - .with_context(|| format!("Formula: {raw}"))?); + m.add_formula(parse_formula(raw, cat_name).with_context(|| format!("Formula: {raw}"))?); } for (key, value) in data { m.set_cell(key, value); @@ -346,11 +409,17 @@ fn parse_bracketed(s: &str) -> (&str, Option<&str>) { } fn coord_str(key: &CellKey) -> String { - key.0.iter().map(|(c, i)| format!("{}={}", c, i)).collect::>().join(", ") + key.0 + .iter() + .map(|(c, i)| format!("{}={}", c, i)) + .collect::>() + .join(", ") } pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { - let view = model.views.get(view_name) + let view = model + .views + .get(view_name) .ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?; let layout = GridLayout::new(model, view); @@ -359,16 +428,23 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { // Header row let row_header = layout.row_cats.join("/"); - let page_label: Vec = layout.page_coords.iter() - .map(|(c, v)| format!("{c}={v}")).collect(); - let header_prefix = if page_label.is_empty() { row_header } else { + let page_label: Vec = layout + .page_coords + .iter() + .map(|(c, v)| format!("{c}={v}")) + .collect(); + let header_prefix = if page_label.is_empty() { + row_header + } else { format!("{} ({})", row_header, page_label.join(", ")) }; if !header_prefix.is_empty() { out.push_str(&header_prefix); out.push(','); } - let col_labels: Vec = (0..layout.col_count()).map(|ci| layout.col_label(ci)).collect(); + let col_labels: Vec = (0..layout.col_count()) + .map(|ci| layout.col_label(ci)) + .collect(); out.push_str(&col_labels.join(",")); out.push('\n'); @@ -380,10 +456,13 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { out.push(','); } let row_values: Vec = (0..layout.col_count()) - .map(|ci| layout.cell_key(ri, ci) - .and_then(|key| model.evaluate(&key)) - .map(|v| v.to_string()) - .unwrap_or_default()) + .map(|ci| { + layout + .cell_key(ri, ci) + .and_then(|key| model.evaluate(&key)) + .map(|v| v.to_string()) + .unwrap_or_default() + }) .collect(); out.push_str(&row_values.join(",")); out.push('\n'); @@ -396,22 +475,31 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::{format_md, parse_md}; - use crate::model::Model; - use crate::model::cell::{CellKey, CellValue}; - use crate::model::category::Group; - use crate::view::Axis; use crate::formula::parse_formula; + 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 { - CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) } fn two_cat_model() -> Model { let mut m = Model::new("Budget"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); - for item in ["Food", "Gas"] { m.category_mut("Type").unwrap().add_item(item); } - for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); } + for item in ["Food", "Gas"] { + m.category_mut("Type").unwrap().add_item(item); + } + for item in ["Jan", "Feb"] { + m.category_mut("Month").unwrap().add_item(item); + } m } @@ -438,7 +526,9 @@ mod tests { fn format_md_item_with_group_uses_brackets() { let mut m = Model::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); let text = format_md(&m); assert!(text.contains("- Jan [Q1]"), "got:\n{text}"); } @@ -447,8 +537,12 @@ mod tests { fn format_md_group_hierarchy_uses_angle_prefix() { let mut m = Model::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1"); - m.category_mut("Month").unwrap().add_group(Group::new("Q1").with_parent("2025")); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_group(Group::new("Q1").with_parent("2025")); let text = format_md(&m); assert!(text.contains("> Q1 [2025]"), "got:\n{text}"); } @@ -456,14 +550,22 @@ mod tests { #[test] fn format_md_data_is_sorted_and_quoted() { let mut m = two_cat_model(); - m.set_cell(coord(&[("Month", "Feb"), ("Type", "Food")]), CellValue::Number(200.0)); - m.set_cell(coord(&[("Month", "Jan"), ("Type", "Gas")]), CellValue::Text("N/A".into())); + m.set_cell( + coord(&[("Month", "Feb"), ("Type", "Food")]), + CellValue::Number(200.0), + ); + m.set_cell( + coord(&[("Month", "Jan"), ("Type", "Gas")]), + CellValue::Text("N/A".into()), + ); let text = format_md(&m); - let data_pos = text.find("## Data").unwrap(); - let feb_pos = text.find("Month=Feb").unwrap(); - let jan_pos = text.find("Month=Jan").unwrap(); - assert!(data_pos < feb_pos && feb_pos < jan_pos, - "expected sorted order Feb < Jan:\n{text}"); + let data_pos = text.find("## Data").unwrap(); + let feb_pos = text.find("Month=Feb").unwrap(); + let jan_pos = text.find("Month=Jan").unwrap(); + assert!( + data_pos < feb_pos && feb_pos < jan_pos, + "expected sorted order Feb < Jan:\n{text}" + ); assert!(text.contains("= 200"), "number not quoted:\n{text}"); assert!(text.contains("= \"N/A\""), "text should be quoted:\n{text}"); } @@ -485,7 +587,9 @@ mod tests { m.add_category("Region").unwrap(); m.category_mut("Type").unwrap().add_item("Food"); m.category_mut("Month").unwrap().add_item("Jan"); - for r in ["East", "West"] { m.category_mut("Region").unwrap().add_item(r); } + for r in ["East", "West"] { + m.category_mut("Region").unwrap().add_item(r); + } m.active_view_mut().set_page_selection("Region", "West"); let text = format_md(&m); assert!(text.contains("Region: page, West"), "got:\n{text}"); @@ -512,18 +616,28 @@ 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] fn parse_md_round_trips_item_group() { let mut m = Model::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); let m2 = parse_md(&format_md(&m)).unwrap(); assert_eq!( - m2.category("Month").and_then(|c| c.items.get("Jan")).and_then(|i| i.group.as_deref()), + m2.category("Month") + .and_then(|c| c.items.get("Jan")) + .and_then(|i| i.group.as_deref()), Some("Q1") ); } @@ -532,8 +646,12 @@ mod tests { fn parse_md_round_trips_group_hierarchy() { let mut m = Model::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1"); - m.category_mut("Month").unwrap().add_group(Group::new("Q1").with_parent("2025")); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_group(Group::new("Q1").with_parent("2025")); let m2 = parse_md(&format_md(&m)).unwrap(); let groups = &m2.category("Month").unwrap().groups; let q1 = groups.iter().find(|g| g.name == "Q1").unwrap(); @@ -543,13 +661,23 @@ mod tests { #[test] fn parse_md_round_trips_data_cells() { let mut m = two_cat_model(); - m.set_cell(coord(&[("Month", "Jan"), ("Type", "Food")]), CellValue::Number(100.0)); - m.set_cell(coord(&[("Month", "Feb"), ("Type", "Gas")]), CellValue::Text("N/A".into())); + m.set_cell( + coord(&[("Month", "Jan"), ("Type", "Food")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Month", "Feb"), ("Type", "Gas")]), + CellValue::Text("N/A".into()), + ); let m2 = parse_md(&format_md(&m)).unwrap(); - assert_eq!(m2.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])), - Some(&CellValue::Number(100.0))); - assert_eq!(m2.get_cell(&coord(&[("Month", "Feb"), ("Type", "Gas")])), - Some(&CellValue::Text("N/A".into()))); + assert_eq!( + m2.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])), + Some(&CellValue::Number(100.0)) + ); + assert_eq!( + m2.get_cell(&coord(&[("Month", "Feb"), ("Type", "Gas")])), + Some(&CellValue::Text("N/A".into())) + ); } #[test] @@ -557,7 +685,7 @@ mod tests { let m = two_cat_model(); let m2 = parse_md(&format_md(&m)).unwrap(); let v = m2.active_view(); - assert_eq!(v.axis_of("Type"), Axis::Row); + assert_eq!(v.axis_of("Type"), Axis::Row); assert_eq!(v.axis_of("Month"), Axis::Column); } @@ -569,7 +697,9 @@ mod tests { m.add_category("Region").unwrap(); m.category_mut("Type").unwrap().add_item("Food"); m.category_mut("Month").unwrap().add_item("Jan"); - for r in ["East", "West"] { m.category_mut("Region").unwrap().add_item(r); } + for r in ["East", "West"] { + m.category_mut("Region").unwrap().add_item(r); + } m.active_view_mut().set_page_selection("Region", "West"); let m2 = parse_md(&format_md(&m)).unwrap(); assert_eq!(m2.active_view().page_selection("Region"), Some("West")); @@ -621,7 +751,7 @@ mod tests { ## Category: Month\n\ - Jan\n"; let m = parse_md(text).unwrap(); - assert_eq!(m.active_view().axis_of("Type"), Axis::Row); + assert_eq!(m.active_view().axis_of("Type"), Axis::Row); assert_eq!(m.active_view().axis_of("Month"), Axis::Column); } @@ -641,10 +771,10 @@ mod tests { - Jan\n"; let m = parse_md(text).unwrap(); let transposed = m.views.get("Transposed").unwrap(); - assert_eq!(transposed.axis_of("Type"), Axis::Column); + assert_eq!(transposed.axis_of("Type"), Axis::Column); assert_eq!(transposed.axis_of("Month"), Axis::Row); let default = m.views.get("Default").unwrap(); - assert_eq!(default.axis_of("Type"), Axis::Row); + assert_eq!(default.axis_of("Type"), Axis::Row); assert_eq!(default.axis_of("Month"), Axis::Column); } diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index b76c3b6..ba8afe0 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -6,14 +6,14 @@ use ratatui::{ }; use crate::model::Model; -use crate::view::Axis; use crate::ui::app::AppMode; +use crate::view::Axis; fn axis_display(axis: Axis) -> (&'static str, Color) { match axis { - Axis::Row => ("Row ↕", Color::Green), + Axis::Row => ("Row ↕", Color::Green), Axis::Column => ("Col ↔", Color::Blue), - Axis::Page => ("Page ☰", Color::Magenta), + Axis::Page => ("Page ☰", Color::Magenta), } } @@ -25,20 +25,30 @@ pub struct CategoryPanel<'a> { impl<'a> CategoryPanel<'a> { pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { - Self { model, mode, cursor } + Self { + model, + mode, + cursor, + } } } impl<'a> Widget for CategoryPanel<'a> { fn render(self, area: Rect, buf: &mut Buffer) { let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. }); - let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. }); - let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add; + let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. }); + let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add; let (border_color, title) = if is_cat_add { - (Color::Yellow, " Categories — New category (Enter:add Esc:done) ") + ( + Color::Yellow, + " Categories — New category (Enter:add Esc:done) ", + ) } else if is_item_add { - (Color::Green, " Categories — Adding items (Enter:add Esc:done) ") + ( + Color::Green, + " Categories — Adding items (Enter:add Esc:done) ", + ) } else if is_active { (Color::Cyan, " Categories n:new a:add-items Space:axis ") } else { @@ -56,9 +66,12 @@ impl<'a> Widget for CategoryPanel<'a> { let cat_names: Vec<&str> = self.model.category_names(); if cat_names.is_empty() { - buf.set_string(inner.x, inner.y, + buf.set_string( + inner.x, + inner.y, "(no categories — use :add-cat )", - Style::default().fg(Color::DarkGray)); + Style::default().fg(Color::DarkGray), + ); return; } @@ -67,24 +80,35 @@ impl<'a> Widget for CategoryPanel<'a> { let list_height = inner.height.saturating_sub(prompt_rows); for (i, cat_name) in cat_names.iter().enumerate() { - if i as u16 >= list_height { break; } + if i as u16 >= list_height { + break; + } let y = inner.y + i as u16; let (axis_str, axis_color) = axis_display(view.axis_of(cat_name)); - let item_count = self.model.category(cat_name).map(|c| c.items.len()).unwrap_or(0); + let item_count = self + .model + .category(cat_name) + .map(|c| c.items.len()) + .unwrap_or(0); // Highlight the selected category both in CategoryPanel and ItemAdd modes let is_selected_cat = if is_item_add { if let AppMode::ItemAdd { category, .. } = self.mode { *cat_name == category.as_str() - } else { false } + } else { + false + } } else { i == self.cursor && is_active }; let base_style = if is_selected_cat { - Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default() }; @@ -99,19 +123,23 @@ impl<'a> Widget for CategoryPanel<'a> { buf.set_string(inner.x, y, &name_part, base_style); if name_part.len() + axis_part.len() < inner.width as usize { - buf.set_string(inner.x + name_part.len() as u16, y, &axis_part, - if is_selected_cat { base_style } else { Style::default().fg(axis_color) }); + buf.set_string( + inner.x + name_part.len() as u16, + y, + &axis_part, + if is_selected_cat { + base_style + } else { + Style::default().fg(axis_color) + }, + ); } } // Inline prompt at the bottom for CategoryAdd or ItemAdd let (prompt_color, prompt_text) = match self.mode { - AppMode::CategoryAdd { buffer } => { - (Color::Yellow, format!(" + category: {buffer}▌")) - } - AppMode::ItemAdd { buffer, .. } => { - (Color::Green, format!(" + item: {buffer}▌")) - } + AppMode::CategoryAdd { buffer } => (Color::Yellow, format!(" + category: {buffer}▌")), + AppMode::ItemAdd { buffer, .. } => (Color::Green, format!(" + item: {buffer}▌")), _ => return, }; @@ -122,8 +150,14 @@ impl<'a> Widget for CategoryPanel<'a> { buf.set_string(inner.x, sep_y, &sep, Style::default().fg(prompt_color)); } if prompt_y < inner.y + inner.height { - buf.set_string(inner.x, prompt_y, &prompt_text, - Style::default().fg(prompt_color).add_modifier(Modifier::BOLD)); + buf.set_string( + inner.x, + prompt_y, + &prompt_text, + Style::default() + .fg(prompt_color) + .add_modifier(Modifier::BOLD), + ); } } } diff --git a/src/ui/formula_panel.rs b/src/ui/formula_panel.rs index 495f5e5..2ec78b7 100644 --- a/src/ui/formula_panel.rs +++ b/src/ui/formula_panel.rs @@ -16,13 +16,20 @@ pub struct FormulaPanel<'a> { impl<'a> FormulaPanel<'a> { pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { - Self { model, mode, cursor } + Self { + model, + mode, + cursor, + } } } impl<'a> Widget for FormulaPanel<'a> { fn render(self, area: Rect, buf: &mut Buffer) { - let is_active = matches!(self.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }); + let is_active = matches!( + self.mode, + AppMode::FormulaPanel | AppMode::FormulaEdit { .. } + ); let border_style = if is_active { Style::default().fg(Color::Yellow) } else { @@ -39,17 +46,25 @@ impl<'a> Widget for FormulaPanel<'a> { let formulas = self.model.formulas(); if formulas.is_empty() { - buf.set_string(inner.x, inner.y, + buf.set_string( + inner.x, + inner.y, "(no formulas — press 'n' to add)", - Style::default().fg(Color::DarkGray)); + Style::default().fg(Color::DarkGray), + ); return; } for (i, formula) in formulas.iter().enumerate() { - if inner.y + i as u16 >= inner.y + inner.height { break; } + if inner.y + i as u16 >= inner.y + inner.height { + break; + } let is_selected = i == self.cursor && is_active; let style = if is_selected { - Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Green) }; @@ -65,9 +80,12 @@ impl<'a> Widget for FormulaPanel<'a> { // Formula edit mode if let AppMode::FormulaEdit { buffer } = self.mode { let y = inner.y + inner.height.saturating_sub(2); - buf.set_string(inner.x, y, + buf.set_string( + inner.x, + y, "┄ Enter formula (Name = expr): ", - Style::default().fg(Color::Yellow)); + Style::default().fg(Color::Yellow), + ); let y = y + 1; let prompt = format!("> {buffer}█"); buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green)); diff --git a/src/ui/import_wizard_ui.rs b/src/ui/import_wizard_ui.rs index 8b60903..9e19eda 100644 --- a/src/ui/import_wizard_ui.rs +++ b/src/ui/import_wizard_ui.rs @@ -5,8 +5,8 @@ use ratatui::{ widgets::{Block, Borders, Clear, Widget}, }; -use crate::import::wizard::{ImportWizard, WizardStep}; use crate::import::analyzer::FieldKind; +use crate::import::wizard::{ImportWizard, WizardStep}; pub struct ImportWizardWidget<'a> { pub wizard: &'a ImportWizard, @@ -52,20 +52,31 @@ impl<'a> Widget for ImportWizardWidget<'a> { let summary = self.wizard.pipeline.preview_summary(); buf.set_string(x, y, truncate(&summary, w), Style::default()); y += 2; - buf.set_string(x, y, + buf.set_string( + x, + y, "Press Enter to continue\u{2026}", - Style::default().fg(Color::Yellow)); + Style::default().fg(Color::Yellow), + ); } WizardStep::SelectArrayPath => { - buf.set_string(x, y, + buf.set_string( + x, + y, "Select the path containing records:", - Style::default().fg(Color::Yellow)); + Style::default().fg(Color::Yellow), + ); y += 1; for (i, path) in self.wizard.pipeline.array_paths.iter().enumerate() { - if y >= inner.y + inner.height { break; } + if y >= inner.y + inner.height { + break; + } let is_sel = i == self.wizard.cursor; let style = if is_sel { - Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD) } else { Style::default() }; @@ -74,19 +85,36 @@ impl<'a> Widget for ImportWizardWidget<'a> { y += 1; } y += 1; - buf.set_string(x, y, "\u{2191}\u{2193} select Enter confirm", Style::default().fg(Color::DarkGray)); + buf.set_string( + x, + y, + "\u{2191}\u{2193} select Enter confirm", + Style::default().fg(Color::DarkGray), + ); } WizardStep::ReviewProposals => { - buf.set_string(x, y, + buf.set_string( + x, + y, "Review field proposals (Space toggle, c cycle kind):", - Style::default().fg(Color::Yellow)); + Style::default().fg(Color::Yellow), + ); y += 1; let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept"); - buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED)); + buf.set_string( + x, + y, + truncate(&header, w), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::UNDERLINED), + ); y += 1; for (i, proposal) in self.wizard.pipeline.proposals.iter().enumerate() { - if y >= inner.y + inner.height - 2 { break; } + if y >= inner.y + inner.height - 2 { + break; + } let is_sel = i == self.wizard.cursor; let kind_color = match proposal.kind { @@ -96,14 +124,23 @@ impl<'a> Widget for ImportWizardWidget<'a> { FieldKind::Label => Color::DarkGray, }; - let accept_str = if proposal.accepted { "[\u{2713}]" } else { "[ ]" }; - let row = format!(" {:<20} {:<22} {}", + let accept_str = if proposal.accepted { + "[\u{2713}]" + } else { + "[ ]" + }; + let row = format!( + " {:<20} {:<22} {}", truncate(&proposal.field, 20), truncate(proposal.kind_label(), 22), - accept_str); + accept_str + ); let style = if is_sel { - Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else if proposal.accepted { Style::default().fg(kind_color) } else { @@ -114,23 +151,34 @@ impl<'a> Widget for ImportWizardWidget<'a> { y += 1; } let hint_y = inner.y + inner.height - 1; - buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel", - Style::default().fg(Color::DarkGray)); + buf.set_string( + x, + hint_y, + "Enter: next Space: toggle c: cycle kind Esc: cancel", + Style::default().fg(Color::DarkGray), + ); } WizardStep::NameModel => { buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow)); y += 1; let name_str = format!("> {}\u{2588}", self.wizard.pipeline.model_name); - buf.set_string(x, y, truncate(&name_str, w), - Style::default().fg(Color::Green)); + buf.set_string( + x, + y, + truncate(&name_str, w), + Style::default().fg(Color::Green), + ); y += 2; - buf.set_string(x, y, "Enter to import, Esc to cancel", - Style::default().fg(Color::DarkGray)); + buf.set_string( + x, + y, + "Enter to import, Esc to cancel", + Style::default().fg(Color::DarkGray), + ); if let Some(msg) = &self.wizard.message { let msg_y = inner.y + inner.height - 1; - buf.set_string(x, msg_y, truncate(msg, w), - Style::default().fg(Color::Red)); + buf.set_string(x, msg_y, truncate(msg, w), Style::default().fg(Color::Red)); } } WizardStep::Done => { @@ -141,7 +189,11 @@ impl<'a> Widget for ImportWizardWidget<'a> { } fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { s.to_string() } - else if max > 1 { format!("{}\u{2026}", &s[..max-1]) } - else { s[..max].to_string() } + if s.len() <= max { + s.to_string() + } else if max > 1 { + format!("{}\u{2026}", &s[..max - 1]) + } else { + s[..max].to_string() + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 758b256..7438a47 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,9 +1,8 @@ pub mod app; -pub mod grid; -pub mod formula_panel; pub mod category_panel; -pub mod view_panel; -pub mod tile_bar; -pub mod import_wizard_ui; +pub mod formula_panel; +pub mod grid; pub mod help; - +pub mod import_wizard_ui; +pub mod tile_bar; +pub mod view_panel; diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index a38c366..9c4bd96 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -6,14 +6,14 @@ use ratatui::{ }; use crate::model::Model; -use crate::view::Axis; use crate::ui::app::AppMode; +use crate::view::Axis; fn axis_display(axis: Axis) -> (&'static str, Color) { match axis { - Axis::Row => ("↕", Color::Green), + Axis::Row => ("↕", Color::Green), Axis::Column => ("↔", Color::Blue), - Axis::Page => ("☰", Color::Magenta), + Axis::Page => ("☰", Color::Magenta), } } @@ -49,12 +49,17 @@ impl<'a> Widget for TileBar<'a> { let is_selected = selected_cat_idx == Some(i); let style = if is_selected { - Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(axis_color) }; - if x + label.len() as u16 > area.x + area.width { break; } + if x + label.len() as u16 > area.x + area.width { + break; + } buf.set_string(x, area.y, &label, style); x += label.len() as u16; } diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs index 8ee5516..e087ddb 100644 --- a/src/ui/view_panel.rs +++ b/src/ui/view_panel.rs @@ -16,7 +16,11 @@ pub struct ViewPanel<'a> { impl<'a> ViewPanel<'a> { pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { - Self { model, mode, cursor } + Self { + model, + mode, + cursor, + } } } @@ -40,23 +44,33 @@ impl<'a> Widget for ViewPanel<'a> { let active = &self.model.active_view; for (i, view_name) in view_names.iter().enumerate() { - if inner.y + i as u16 >= inner.y + inner.height { break; } + if inner.y + i as u16 >= inner.y + inner.height { + break; + } let is_selected = i == self.cursor && is_active; let is_active_view = *view_name == active.as_str(); let style = if is_selected { - Style::default().fg(Color::Black).bg(Color::Blue).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Blue) + .add_modifier(Modifier::BOLD) } else if is_active_view { - Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD) } else { Style::default() }; let prefix = if is_active_view { "▶ " } else { " " }; - buf.set_string(inner.x, inner.y + i as u16, + buf.set_string( + inner.x, + inner.y + i as u16, format!("{prefix}{view_name}"), - style); + style, + ); } } } diff --git a/src/view/axis.rs b/src/view/axis.rs index 42e6ef7..03c6e17 100644 --- a/src/view/axis.rs +++ b/src/view/axis.rs @@ -11,9 +11,9 @@ pub enum Axis { impl std::fmt::Display for Axis { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Axis::Row => write!(f, "Row ↕"), + Axis::Row => write!(f, "Row ↕"), Axis::Column => write!(f, "Col ↔"), - Axis::Page => write!(f, "Page ☰"), + Axis::Page => write!(f, "Page ☰"), } } }