chore: reformat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-03-31 00:07:22 -07:00
parent 37584670eb
commit 183b2350f7
17 changed files with 1112 additions and 471 deletions

View File

@ -8,5 +8,5 @@
pub mod dispatch;
pub mod types;
pub use types::{Command, CommandResult};
pub use dispatch::dispatch;
pub use types::{Command, CommandResult};

View File

@ -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;

View File

@ -8,7 +8,9 @@ pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
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<Filter> {
// 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<Expr> {
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<Vec<Token>> {
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<Vec<Token>> {
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<Vec<Token>> {
}
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<Expr> {
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<Expr> {
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<Expr> {
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<Expr> {
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<Expr> {
fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
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,

View File

@ -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<FieldProposal> {
}
}
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<String> = 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<String> = 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

View File

@ -1,3 +1,2 @@
pub mod wizard;
pub mod analyzer;
pub mod wizard;

View File

@ -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<Model> {
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]

View File

@ -14,7 +14,11 @@ pub struct Item {
impl Item {
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
Self { id, name: name.into(), group: None }
Self {
id,
name: name.into(),
group: None,
}
}
pub fn with_group(mut self, group: impl Into<String>) -> Self {
@ -32,7 +36,10 @@ pub struct Group {
impl Group {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into(), parent: None }
Self {
name: name.into(),
parent: None,
}
}
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
@ -75,7 +82,11 @@ impl Category {
id
}
pub fn add_item_in_group(&mut self, name: impl Into<String>, group: impl Into<String>) -> ItemId {
pub fn add_item_in_group(
&mut self,
name: impl Into<String>,
group: impl Into<String>,
) -> 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));
}
}

View File

@ -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<String>, item: impl Into<String>) -> 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<Value = Vec<(String, String)>> {

View File

@ -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<CellValue> {
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<f64> = model.data.matching_cells(&partial.0)
let values: Vec<f64> = 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::<f64>() / values.len() as f64) }
if values.is_empty() {
None
} else {
Some(values.iter().sum::<f64>() / 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<Value = f64> {
prop::num::f64::NORMAL.prop_filter("finite", |f| f.is_finite())

View File

@ -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<Model> {
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<Model> {
struct PCategory {
name: String,
items: Vec<(String, Option<String>)>, // (name, group)
group_parents: Vec<(String, String)>, // (group, parent)
items: Vec<(String, Option<String>)>, // (name, group)
group_parents: Vec<(String, String)>, // (group, parent)
}
struct PView {
@ -170,50 +174,78 @@ pub fn parse_md(text: &str) -> Result<Model> {
// ── Pass 1: collect ───────────────────────────────────────────────────────
#[derive(PartialEq)]
enum Section { None, Category, Formulas, Data, View }
enum Section {
None,
Category,
Formulas,
Data,
View,
}
let mut model_name: Option<String> = None;
let mut categories: Vec<PCategory> = Vec::new();
let mut formulas: Vec<(String, String)> = Vec::new(); // (raw, category)
let mut data: Vec<(CellKey, CellValue)> = Vec::new();
let mut views: Vec<PView> = Vec::new();
let mut categories: Vec<PCategory> = Vec::new();
let mut formulas: Vec<(String, String)> = Vec::new(); // (raw, category)
let mut data: Vec<(CellKey, CellValue)> = Vec::new();
let mut views: Vec<PView> = 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<Model> {
}
}
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::<f64>() {
CellValue::Number(n)
@ -247,28 +287,36 @@ pub fn parse_md(text: &str) -> Result<Model> {
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<Model> {
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<Model> {
// 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<Model> {
// 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::<Vec<_>>().join(", ")
key.0
.iter()
.map(|(c, i)| format!("{}={}", c, i))
.collect::<Vec<_>>()
.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<String> = 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<String> = 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<String> = (0..layout.col_count()).map(|ci| layout.col_label(ci)).collect();
let col_labels: Vec<String> = (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<String> = (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);
}

View File

@ -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 <name>)",
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),
);
}
}
}

View File

@ -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));

View File

@ -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()
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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,
);
}
}
}

View File

@ -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 ☰"),
}
}
}