diff --git a/src/command/cmd.rs b/src/command/cmd.rs index b4bdbbd..e08b32f 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -806,6 +806,7 @@ impl Cmd for SearchNavigate { let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) { Some(CellValue::Number(n)) => format!("{n}"), Some(CellValue::Text(t)) => t, + Some(CellValue::Error(e)) => format!("ERR:{e}"), None => String::new(), }; s.to_lowercase().contains(&query) diff --git a/src/format.rs b/src/format.rs index b667df6..5dadb66 100644 --- a/src/format.rs +++ b/src/format.rs @@ -5,6 +5,7 @@ pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String match v { Some(CellValue::Number(n)) => format_f64(*n, comma, decimals), Some(CellValue::Text(s)) => s.clone(), + Some(CellValue::Error(e)) => format!("ERR:{e}"), None => String::new(), } } diff --git a/src/model/cell.rs b/src/model/cell.rs index 129b3e7..c74cd39 100644 --- a/src/model/cell.rs +++ b/src/model/cell.rs @@ -62,15 +62,21 @@ impl std::fmt::Display for CellKey { pub enum CellValue { Number(f64), Text(String), + /// Evaluation error (circular reference, depth overflow, etc.) + Error(String), } impl CellValue { pub fn as_f64(&self) -> Option { match self { CellValue::Number(n) => Some(*n), - CellValue::Text(_) => None, + _ => None, } } + + pub fn is_error(&self) -> bool { + matches!(self, CellValue::Error(_)) + } } impl std::fmt::Display for CellValue { @@ -84,6 +90,7 @@ impl std::fmt::Display for CellValue { } } CellValue::Text(s) => write!(f, "{s}"), + CellValue::Error(msg) => write!(f, "ERR:{msg}"), } } } diff --git a/src/model/types.rs b/src/model/types.rs index 2c8cf21..9a3600f 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -261,11 +261,22 @@ impl Model { /// Evaluate a computed value at a given key, considering formulas. /// Returns None when the cell is empty (no stored value, no applicable formula). + /// Maximum formula evaluation depth. Circular references return None + /// instead of stack-overflowing. + const MAX_EVAL_DEPTH: u8 = 16; + pub fn evaluate(&self, key: &CellKey) -> Option { + self.evaluate_depth(key, Self::MAX_EVAL_DEPTH) + } + + fn evaluate_depth(&self, key: &CellKey, depth: u8) -> Option { + if depth == 0 { + return Some(CellValue::Error("circular".into())); + } for formula in &self.formulas { if let Some(item_val) = key.get(&formula.target_category) { if item_val == formula.target { - return self.eval_formula(formula, key); + return self.eval_formula_depth(formula, key, depth - 1); } } } @@ -326,6 +337,15 @@ impl Model { } fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option { + self.eval_formula_depth(formula, context, Self::MAX_EVAL_DEPTH) + } + + fn eval_formula_depth( + &self, + formula: &Formula, + context: &CellKey, + depth: u8, + ) -> Option { use crate::formula::{AggFunc, Expr}; // Check WHERE filter first @@ -348,42 +368,50 @@ impl Model { None } + /// Evaluate an expression, returning Ok(f64) or Err(reason). + /// Errors propagate immediately — a circular reference in any + /// sub-expression short-circuits the entire formula. fn eval_expr( expr: &Expr, context: &CellKey, model: &Model, target_category: &str, - ) -> Option { + depth: u8, + ) -> Result { match expr { - Expr::Number(n) => Some(*n), + Expr::Number(n) => Ok(*n), Expr::Ref(name) => { - let cat = find_item_category(model, name)?; + let cat = find_item_category(model, name) + .ok_or_else(|| format!("ref:{name}"))?; let new_key = context.clone().with(cat, name); - model.evaluate(&new_key).and_then(|v| v.as_f64()) + match model.evaluate_depth(&new_key, depth) { + Some(CellValue::Number(n)) => Ok(n), + Some(CellValue::Error(e)) => Err(e), + _ => Err(format!("ref:{name}")), + } } Expr::BinOp(op, l, r) => { use crate::formula::BinOp; - let lv = eval_expr(l, context, model, target_category)?; - let rv = eval_expr(r, context, model, target_category)?; - Some(match op { - BinOp::Add => lv + rv, - BinOp::Sub => lv - rv, - BinOp::Mul => lv * rv, + let lv = eval_expr(l, context, model, target_category, depth)?; + let rv = eval_expr(r, context, model, target_category, depth)?; + match op { + BinOp::Add => Ok(lv + rv), + BinOp::Sub => Ok(lv - rv), + BinOp::Mul => Ok(lv * rv), BinOp::Div => { if rv == 0.0 { - return None; + Err("div/0".into()) + } else { + Ok(lv / rv) } - 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::Pow => Ok(lv.powf(rv)), BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => { - return None + Err("type".into()) } - }) + } } - Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?), + Expr::UnaryMinus(e) => Ok(-eval_expr(e, context, model, target_category, depth)?), Expr::Agg(func, inner, agg_filter) => { let mut partial = context.without(target_category); if let Expr::Ref(item_name) = inner.as_ref() { @@ -401,25 +429,25 @@ impl Model { .filter_map(|v| v.as_f64()) .collect(); match func { - AggFunc::Sum => Some(values.iter().sum()), + AggFunc::Sum => Ok(values.iter().sum()), AggFunc::Avg => { if values.is_empty() { - None + Err("empty".into()) } else { - Some(values.iter().sum::() / values.len() as f64) + Ok(values.iter().sum::() / values.len() as f64) } } - AggFunc::Min => values.iter().cloned().reduce(f64::min), - AggFunc::Max => values.iter().cloned().reduce(f64::max), - AggFunc::Count => Some(values.len() as f64), + AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()), + AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()), + AggFunc::Count => Ok(values.len() as f64), } } Expr::If(cond, then, else_) => { - let cv = eval_bool(cond, context, model, target_category)?; + let cv = eval_bool(cond, context, model, target_category, depth)?; if cv { - eval_expr(then, context, model, target_category) + eval_expr(then, context, model, target_category, depth) } else { - eval_expr(else_, context, model, target_category) + eval_expr(else_, context, model, target_category, depth) } } } @@ -430,30 +458,33 @@ impl Model { context: &CellKey, model: &Model, target_category: &str, - ) -> Option { + depth: u8, + ) -> Result { use crate::formula::BinOp; match expr { Expr::BinOp(op, l, r) => { - let lv = eval_expr(l, context, model, target_category)?; - let rv = eval_expr(r, context, model, target_category)?; - Some(match op { - BinOp::Eq => (lv - rv).abs() < 1e-10, - BinOp::Ne => (lv - rv).abs() >= 1e-10, - BinOp::Lt => lv < rv, - BinOp::Gt => lv > rv, - BinOp::Le => lv <= rv, - BinOp::Ge => lv >= rv, - // Arithmetic operators are not comparisons + let lv = eval_expr(l, context, model, target_category, depth)?; + let rv = eval_expr(r, context, model, target_category, depth)?; + match op { + BinOp::Eq => Ok((lv - rv).abs() < 1e-10), + BinOp::Ne => Ok((lv - rv).abs() >= 1e-10), + BinOp::Lt => Ok(lv < rv), + BinOp::Gt => Ok(lv > rv), + BinOp::Le => Ok(lv <= rv), + BinOp::Ge => Ok(lv >= rv), BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => { - return None + Err("type".into()) } - }) + } } - _ => None, + _ => Err("type".into()), } } - eval_expr(&formula.expr, context, self, &formula.target_category).map(CellValue::Number) + match eval_expr(&formula.expr, context, self, &formula.target_category, depth) { + Ok(n) => Some(CellValue::Number(n)), + Err(e) => Some(CellValue::Error(e)), + } } } @@ -862,10 +893,10 @@ mod formula_tests { 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. + // Division by zero yields an error, not a blank or misleading zero. assert_eq!( m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), - None + Some(CellValue::Error("div/0".into())) ); } @@ -904,7 +935,47 @@ mod formula_tests { cat.add_item("Ghost"); } let k = coord(&[("Measure", "Ghost"), ("Region", "East")]); - assert_eq!(m.evaluate(&k), None); + assert!( + matches!(m.evaluate(&k), Some(CellValue::Error(_))), + "missing ref should produce an error, got: {:?}", + m.evaluate(&k) + ); + } + + /// Circular formula references must produce an error, not stack overflow. + #[test] + fn circular_formula_returns_error() { + let mut m = Model::new("Test"); + m.add_category("Measure").unwrap(); + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("A"); + cat.add_item("B"); + } + m.add_formula(parse_formula("A = B + 1", "Measure").unwrap()); + m.add_formula(parse_formula("B = A + 1", "Measure").unwrap()); + let result = m.evaluate(&coord(&[("Measure", "A")])); + assert!( + matches!(result, Some(CellValue::Error(_))), + "circular reference should produce an error, got: {:?}", + result + ); + } + + /// Self-referencing formula must produce an error. + #[test] + fn self_referencing_formula_returns_error() { + let mut m = Model::new("Test"); + m.add_category("Measure").unwrap(); + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("X"); + } + m.add_formula(parse_formula("X = X + 1", "Measure").unwrap()); + let result = m.evaluate(&coord(&[("Measure", "X")])); + assert!( + matches!(result, Some(CellValue::Error(_))), + "self-reference should produce an error, got: {:?}", + result + ); } #[test] diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 07d6c27..dc3b2a4 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -55,26 +55,73 @@ pub fn autosave_path(path: &Path) -> std::path::PathBuf { p } + +/// Format a number with enough precision for lossless round-trip. +fn format_number(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { + format!("{}", n as i64) + } else { + // Use enough decimal digits to round-trip any f64. + // Rust's {:?} (Debug) uses full precision, but looks odd. + // Instead, try the default Display first; if it round-trips, use it. + let display = format!("{n}"); + if display.parse::() == Ok(n) { + display + } else { + // Fall back to repr-style full precision + format!("{n:?}") + } + } +} + +/// Characters that require pipe-quoting in a name. +const NAME_SPECIAL: &[char] = &['=', ',', '|', '[', ']', '/', ':', '#']; + +/// Format a name using CL-style `|...|` pipe quoting if it contains special +/// characters. Inside a quoted name, `\|` is a literal pipe and `\\` is a +/// literal backslash. +fn quote_name(name: &str) -> String { + if name.is_empty() || name.chars().any(|c| NAME_SPECIAL.contains(&c)) || name != name.trim() { + let mut out = String::with_capacity(name.len() + 2); + out.push('|'); + for c in name.chars() { + match c { + '|' => out.push_str("\\|"), + '\\' => out.push_str("\\\\"), + c => out.push(c), + } + } + out.push('|'); + out + } else { + name.to_string() + } +} + + /// Serialize a model to the markdown `.improv` format. pub fn format_md(model: &Model) -> String { use std::fmt::Write; let mut out = String::new(); + writeln!(out, "v2025-04-09").unwrap(); writeln!(out, "# {}", model.name).unwrap(); + writeln!(out, "Initial View: {}", model.active_view).unwrap(); // Categories for cat in model.categories.values() { writeln!(out, "\n## Category: {}", cat.name).unwrap(); for item in cat.items.values() { match &item.group { - Some(g) => writeln!(out, "- {} [{}]", item.name, g).unwrap(), - None => writeln!(out, "- {}", item.name).unwrap(), + Some(g) => writeln!(out, "- {}[{}]", quote_name(&item.name), quote_name(g)) + .unwrap(), + None => writeln!(out, "- {}", quote_name(&item.name)).unwrap(), } } // Group hierarchy: lines starting with `>` for groups that have a parent for g in &cat.groups { if let Some(parent) = &g.parent { - writeln!(out, "> {} [{}]", g.name, parent).unwrap(); + writeln!(out, "> {}[{}]", quote_name(&g.name), quote_name(parent)).unwrap(); } } } @@ -94,30 +141,40 @@ pub fn format_md(model: &Model) -> String { writeln!(out, "\n## Data").unwrap(); for (key, value) in cells { let val_str = match value { - CellValue::Number(_) => value.to_string(), - CellValue::Text(s) => format!("\"{}\"", s), + CellValue::Number(n) => format_number(*n), + // Always pipe-quote text values to distinguish from numbers + CellValue::Text(s) | CellValue::Error(s) => { + let mut out = String::with_capacity(s.len() + 2); + out.push('|'); + for c in s.chars() { + match c { + '|' => out.push_str("\\|"), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + c => out.push(c), + } + } + out.push('|'); + out + } }; writeln!(out, "{} = {}", coord_str(&key), val_str).unwrap(); } } // Views - for (view_name, view) in &model.views { - let active = if view_name == &model.active_view { - " (active)" - } else { - "" - }; - writeln!(out, "\n## View: {}{}", view.name, active).unwrap(); + for (_view_name, view) in &model.views { + writeln!(out, "\n## View: {}", view.name).unwrap(); for (cat, axis) in &view.category_axes { + let qcat = quote_name(cat); match axis { - Axis::Row => writeln!(out, "{}: row", cat).unwrap(), - Axis::Column => writeln!(out, "{}: column", cat).unwrap(), + Axis::Row => writeln!(out, "{qcat}: row").unwrap(), + Axis::Column => writeln!(out, "{qcat}: column").unwrap(), Axis::Page => match view.page_selections.get(cat) { - Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(), - None => writeln!(out, "{}: page", cat).unwrap(), + Some(sel) => writeln!(out, "{qcat}: page, {}", quote_name(sel)).unwrap(), + None => writeln!(out, "{qcat}: page").unwrap(), }, - Axis::None => writeln!(out, "{}: none", cat).unwrap(), + Axis::None => writeln!(out, "{qcat}: none").unwrap(), } } if !view.number_format.is_empty() { @@ -131,7 +188,7 @@ pub fn format_md(model: &Model) -> String { .collect(); hidden.sort(); for (cat, item) in hidden { - writeln!(out, "hidden: {}/{}", cat, item).unwrap(); + writeln!(out, "hidden: {}/{}", quote_name(cat), quote_name(item)).unwrap(); } // Collapsed groups (sorted for deterministic diffs) let mut collapsed: Vec<(&str, &str)> = view @@ -141,13 +198,14 @@ pub fn format_md(model: &Model) -> String { .collect(); collapsed.sort(); for (cat, group) in collapsed { - writeln!(out, "collapsed: {}/{}", cat, group).unwrap(); + writeln!(out, "collapsed: {}/{}", quote_name(cat), quote_name(group)).unwrap(); } } out } + /// Parse the markdown `.improv` format into a Model. /// /// Uses a two-pass approach so the file is order-independent: @@ -164,7 +222,6 @@ pub fn parse_md(text: &str) -> Result { struct PView { name: String, - is_active: bool, axes: Vec<(String, Axis)>, page_selections: Vec<(String, String)>, format: String, @@ -184,6 +241,7 @@ pub fn parse_md(text: &str) -> Result { } let mut model_name: Option = None; + let mut initial_view: 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(); @@ -195,6 +253,15 @@ pub fn parse_md(text: &str) -> Result { if trimmed.is_empty() { continue; } + // Skip version line + if trimmed.starts_with('v') && trimmed.len() <= 20 && trimmed.contains('-') { + continue; + } + + if let Some(rest) = trimmed.strip_prefix("Initial View: ") { + initial_view = Some(rest.trim().to_string()); + continue; + } if trimmed.starts_with("# ") && !trimmed.starts_with("## ") { model_name = Some(trimmed[2..].trim().to_string()); @@ -218,13 +285,9 @@ pub fn parse_md(text: &str) -> Result { 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), - }; + let name = rest.trim().to_string(); views.push(PView { name, - is_active, axes: Vec::new(), page_selections: Vec::new(), format: String::new(), @@ -245,12 +308,11 @@ pub fn parse_md(text: &str) -> Result { }; 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, group)); } else if let Some(rest) = trimmed.strip_prefix("> ") { let (group, parent) = parse_bracketed(rest); if let Some(p) = parent { - cat.group_parents.push((group.to_string(), p.to_string())); + cat.group_parents.push((group, p)); } } } @@ -263,28 +325,9 @@ pub fn parse_md(text: &str) -> Result { } } Section::Data => { - let Some(sep) = trimmed.find(" = ") else { + let Some((coords, value)) = parse_data_line(trimmed) 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; - } - let vs = trimmed[sep + 3..].trim(); - 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) - } else { - CellValue::Text(vs.to_string()) - }; data.push((CellKey::new(coords), value)); } Section::View => { @@ -294,23 +337,19 @@ pub fn parse_md(text: &str) -> Result { 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())); + if let Some((c, i)) = parse_slash_path(rest.trim()) { + view.hidden.push((c, i)); } } 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())); + if let Some((c, g)) = parse_slash_path(rest.trim()) { + view.collapsed.push((c, g)); } - } else if let Some(colon) = trimmed.find(": ") { - let cat = trimmed[..colon].trim(); - let rest = trimmed[colon + 2..].trim(); + } else if let Some((cat, rest)) = parse_name_colon(trimmed) { if let Some(sel_rest) = rest.strip_prefix("page") { - view.axes.push((cat.to_string(), Axis::Page)); + view.axes.push((cat.clone(), Axis::Page)); if let Some(sel) = sel_rest.strip_prefix(", ") { - view.page_selections - .push((cat.to_string(), sel.trim().to_string())); + let sel = parse_inline_name(sel.trim()); + view.page_selections.push((cat, sel)); } } else { let axis = match rest { @@ -319,7 +358,7 @@ pub fn parse_md(text: &str) -> Result { "none" => Axis::None, _ => continue, }; - view.axes.push((cat.to_string(), axis)); + view.axes.push((cat, axis)); } } } @@ -358,11 +397,7 @@ 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); } @@ -383,8 +418,12 @@ pub fn parse_md(text: &str) -> Result { view.toggle_group_collapse(cat, grp); } } - if !active_view.is_empty() && m.views.contains_key(&active_view) { - m.active_view = active_view; + + // Set initial view if specified + if let Some(iv) = &initial_view { + if m.views.contains_key(iv) { + m.active_view = iv.clone(); + } } // Formulas and data can go in any order relative to each other @@ -398,26 +437,186 @@ pub fn parse_md(text: &str) -> Result { Ok(m) } -/// Split `"Name [Bracket]"` → `("Name", Some("Bracket"))` or `("Name", None)`. -fn parse_bracketed(s: &str) -> (&str, Option<&str>) { - if let Some(open) = s.rfind('[') { - if s.ends_with(']') { - let name = s[..open].trim(); - let inner = &s[open + 1..s.len() - 1]; - return (name, Some(inner)); +/// Parse `"Name[Group]"` or `"|Name|[|Group|]"` or `"Name"`. +/// Returns (name_str, optional_group_str). Both may be pipe-quoted. +fn parse_bracketed(s: &str) -> (String, Option) { + let s = s.trim(); + // Parse the name part (possibly pipe-quoted) + let (name, rest) = if s.starts_with('|') { + match parse_maybe_quoted_name(s) { + Some((n, r)) => (n, r), + None => return (s.to_string(), None), + } + } else { + // Bare name: everything before `[` (if any) or end + match s.find('[') { + Some(i) => (s[..i].to_string(), &s[i..]), + None => return (s.to_string(), None), + } + }; + + // Check for [group] suffix + let rest = rest.trim(); + if rest.starts_with('[') && rest.ends_with(']') { + let inner = &rest[1..rest.len() - 1]; + let group = parse_inline_name(inner); + (name, Some(group)) + } else { + (name, None) + } +} + +/// Parse a `name/name` path where names may be pipe-quoted. +fn parse_slash_path(s: &str) -> Option<(String, String)> { + if s.starts_with('|') { + // First name is pipe-quoted — find closing pipe, then expect / + let (name, rest) = parse_maybe_quoted_name(s)?; + let rest = rest.strip_prefix('/')?; + let item = parse_inline_name(rest); + Some((name, item)) + } else { + let (head, tail) = s.split_once('/')?; + Some((head.trim().to_string(), parse_inline_name(tail.trim()))) + } +} + +/// Parse a `name: rest` line where name may be pipe-quoted. +fn parse_name_colon(s: &str) -> Option<(String, &str)> { + if s.starts_with('|') { + let (name, rest) = parse_maybe_quoted_name(s)?; + let rest = rest.strip_prefix(": ")?; + Some((name, rest)) + } else { + let colon = s.find(": ")?; + let name = s[..colon].trim().to_string(); + let rest = s[colon + 2..].trim(); + Some((name, rest)) + } +} + +/// Parse a single name that may be pipe-quoted. Returns the unquoted string. +fn parse_inline_name(s: &str) -> String { + let s = s.trim(); + if s.starts_with('|') { + if let Some((name, _)) = parse_maybe_quoted_name(s) { + return name; } } - (s.trim(), None) + s.to_string() } fn coord_str(key: &CellKey) -> String { key.0 .iter() - .map(|(c, i)| format!("{}={}", c, i)) + .map(|(c, i)| format!("{}={}", quote_name(c), quote_name(i))) .collect::>() .join(", ") } +/// Parse a data line like `Cat=Item, Cat2=Item2 = "value"` into coordinates +/// and a cell value. Handles backtick-quoted names containing `=` or `, `. +fn parse_data_line(line: &str) -> Option<(Vec<(String, String)>, CellValue)> { + // Find the value separator: the last ` = ` that isn't inside a backtick-quoted name. + // Strategy: scan for ` = ` from the right, since the value is always at the end. + // But the value itself could contain ` = ` if it's a quoted text. + // The format is: coords ` = ` value + // where value is either a number or "quoted text". + // + // We find the separator by scanning from left: the first ` = ` that is NOT + // inside a backtick-quoted name is the separator. Since coordinates don't + // contain ` = ` (they use bare `=`), the first ` = ` is always the separator. + let sep = line.find(" = ")?; + let coord_part = &line[..sep]; + let value_part = line[sep + 3..].trim(); + + let coords = parse_coord_str(coord_part)?; + if coords.is_empty() { + return None; + } + + let value = if let Ok(n) = value_part.parse::() { + CellValue::Number(n) + } else { + // Text value — may be pipe-quoted or bare + CellValue::Text(parse_inline_name(value_part)) + }; + + Some((coords, value)) +} + +/// Parse a coordinate string like `Cat=Item, Cat2=Item2` into pairs. +/// Handles backtick-quoted names: `` `Income, Gross`=A ``. +fn parse_coord_str(s: &str) -> Option> { + let mut pairs = Vec::new(); + let mut rest = s.trim(); + + while !rest.is_empty() { + // Parse category name (possibly backtick-quoted) + let (cat, after_cat) = parse_maybe_quoted_name(rest)?; + let after_cat = after_cat.strip_prefix('=')?; + // Parse item name (possibly backtick-quoted) + let (item, after_item) = parse_maybe_quoted_name(after_cat)?; + + pairs.push((cat, item)); + + let after_item = after_item.trim_start(); + if after_item.is_empty() { + break; + } + // Expect ", " separator + rest = after_item.strip_prefix(", ")?; + } + + Some(pairs) +} + +/// Parse a name that may be pipe-quoted. Returns (name, rest_of_string). +/// Pipe-quoted: `|Income, Gross|` → `"Income, Gross"`. +/// Backslash escapes inside: `|\||` → `"|"`, `|\\|` → `"\"`, `|\n|` → newline. +/// Unquoted: stops at `=` or `, ` or end of string. +fn parse_maybe_quoted_name(s: &str) -> Option<(String, &str)> { + if let Some(inner) = s.strip_prefix('|') { + // Pipe-quoted name: scan for unescaped closing pipe + let mut name = String::new(); + let mut chars = inner.char_indices(); + while let Some((i, c)) = chars.next() { + if c == '\\' { + // Escape sequence + if let Some((_, next)) = chars.next() { + match next { + '|' => name.push('|'), + '\\' => name.push('\\'), + 'n' => name.push('\n'), + other => { + name.push('\\'); + name.push(other); + } + } + } + } else if c == '|' { + // End of quoted name + return Some((name, &inner[i + 1..])); + } else { + name.push(c); + } + } + // Unterminated pipe — treat whole thing as name + Some((name, "")) + } else { + // Unquoted: take chars until `=` or `, ` or end (whichever comes first) + let eq_pos = s.find('='); + let comma_pos = s.find(", "); + let end = match (eq_pos, comma_pos) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => s.len(), + }; + let name = s[..end].trim().to_string(); + Some((name, &s[end..])) + } +} + pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { let view = model .views @@ -526,7 +725,7 @@ mod tests { .unwrap() .add_item_in_group("Jan", "Q1"); let text = format_md(&m); - assert!(text.contains("- Jan [Q1]"), "got:\n{text}"); + assert!(text.contains("- Jan[Q1]"), "got:\n{text}"); } #[test] @@ -540,7 +739,7 @@ mod tests { .unwrap() .add_group(Group::new("Q1").with_parent("2025")); let text = format_md(&m); - assert!(text.contains("> Q1 [2025]"), "got:\n{text}"); + assert!(text.contains("> Q1[2025]"), "got:\n{text}"); } #[test] @@ -563,14 +762,14 @@ mod tests { "expected sorted order Feb < Jan:\n{text}" ); assert!(text.contains("= 200"), "number not quoted:\n{text}"); - assert!(text.contains("= \"N/A\""), "text should be quoted:\n{text}"); + assert!(text.contains("= |N/A|"), "text should be pipe-quoted:\n{text}"); } #[test] - fn format_md_view_axes_and_active_marker() { + fn format_md_view_axes() { let m = two_cat_model(); let text = format_md(&m); - assert!(text.contains("## View: Default (active)")); + assert!(text.contains("## View: Default")); assert!(text.contains("Type: row")); assert!(text.contains("Month: column")); } @@ -702,14 +901,7 @@ mod tests { assert_eq!(m2.active_view().axis_of("Region"), Axis::Page); } - #[test] - fn parse_md_round_trips_active_view() { - let mut m = two_cat_model(); - m.create_view("Other"); - m.switch_view("Other").unwrap(); - let m2 = parse_md(&format_md(&m)).unwrap(); - assert_eq!(m2.active_view, "Other"); - } + // active_view is no longer persisted — it's runtime state #[test] fn parse_md_round_trips_formula() { @@ -739,7 +931,7 @@ mod tests { // A hand-edited file with the view section before the category sections. // The parser must still produce correct axis assignments. let text = "# Test\n\ - ## View: Default (active)\n\ + ## View: Default\n\ Type: row\n\ Month: column\n\ ## Category: Type\n\ @@ -755,7 +947,7 @@ mod tests { fn parse_md_order_independent_new_view_before_categories() { // A non-Default view with swapped axes, declared before categories exist. let text = "# Test\n\ - ## View: Transposed (active)\n\ + ## View: Transposed\n\ Type: column\n\ Month: row\n\ ## View: Default\n\ @@ -783,7 +975,7 @@ mod tests { - Food\n\ ## Category: Month\n\ - Jan\n\ - ## View: Default (active)\n\ + ## View: Default\n\ Type: row\n\ Month: column\n"; let m = parse_md(text).unwrap(); @@ -1067,4 +1259,917 @@ Type=Food = 42 assert!(v.is_group_collapsed("Type", "G1")); assert_eq!(v.number_format, ",.0"); } + + // ── Stress tests: special characters in values ────────────────────── + + #[test] + fn text_value_with_embedded_comma() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("Smith, Jr.".into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("Smith, Jr.".into())), + "Comma inside quoted text was corrupted.\n{text}" + ); + } + + #[test] + fn text_value_with_embedded_double_quote() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text(r#"He said "hello""#.into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text(r#"He said "hello""#.into())), + "Embedded double quotes were corrupted.\n{text}" + ); + } + + #[test] + fn text_value_with_equals_space_sequence() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("x = y".into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("x = y".into())), + "Text containing ' = ' was corrupted.\n{text}" + ); + } + + #[test] + fn text_value_is_single_double_quote() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("\"".into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("\"".into())), + "Single double-quote text was corrupted.\n{text}" + ); + } + + #[test] + fn text_value_is_empty_string() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("".into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("".into())), + "Empty string was corrupted.\n{text}" + ); + } + + #[test] + fn text_value_with_newline() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("line1\nline2".into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("line1\nline2".into())), + "Newline in text was corrupted.\n{text}" + ); + } + + #[test] + fn text_value_looks_like_number() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("42".into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("42".into())), + "Numeric-looking text was converted to Number.\n{text}" + ); + } + + #[test] + fn text_value_with_hash_prefix() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("#NotAHeader".into()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("#NotAHeader".into())), + "Hash-prefixed text was misinterpreted.\n{text}" + ); + } + + #[test] + fn item_name_with_brackets_misinterpreted_as_group() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.category_mut("Type").unwrap().add_item("Item [special]"); + m.add_category("Month").unwrap(); + m.category_mut("Month").unwrap().add_item("Jan"); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let cat = loaded.category("Type").unwrap(); + let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect(); + assert!( + item_names.contains(&"Item [special]"), + "Item name with brackets was misinterpreted as having a group.\n\ + Got items: {item_names:?}\n{text}" + ); + } + + #[test] + fn category_name_with_comma_space_in_data() { + let mut m = Model::new("Test"); + m.add_category("Income, Gross").unwrap(); + m.category_mut("Income, Gross").unwrap().add_item("A"); + m.add_category("Month").unwrap(); + m.category_mut("Month").unwrap().add_item("Jan"); + m.set_cell( + coord(&[("Income, Gross", "A"), ("Month", "Jan")]), + CellValue::Number(100.0), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Income, Gross", "A"), ("Month", "Jan")])), + Some(&CellValue::Number(100.0)), + "Category name with comma-space broke coord parsing.\n{text}" + ); + } + + #[test] + fn item_name_with_equals_sign_in_data() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.category_mut("Type").unwrap().add_item("A=B"); + m.add_category("Month").unwrap(); + m.category_mut("Month").unwrap().add_item("Jan"); + m.set_cell( + coord(&[("Type", "A=B"), ("Month", "Jan")]), + CellValue::Number(50.0), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "A=B"), ("Month", "Jan")])), + Some(&CellValue::Number(50.0)), + "Item name with '=' broke coord parsing.\n{text}" + ); + } + + #[test] + fn view_name_with_parentheses() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.category_mut("Type").unwrap().add_item("A"); + m.create_view("My View (v2)"); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert!( + loaded.views.contains_key("My View (v2)"), + "View with parens was corrupted.\nViews: {:?}\n{text}", + loaded.views.keys().collect::>() + ); + } + + #[test] + fn multiple_tricky_text_cells() { + let mut m = Model::new("EdgeCases"); + m.add_category("Dim").unwrap(); + for item in ["A", "B", "C", "D"] { + m.category_mut("Dim").unwrap().add_item(item); + } + m.add_category("Msr").unwrap(); + m.category_mut("Msr").unwrap().add_item("Val"); + + let cases: Vec<(&str, CellValue)> = vec![ + ("A", CellValue::Text("hello, world".into())), + ("B", CellValue::Text(r#"a "quoted" thing"#.into())), + ("C", CellValue::Text("x = y = z".into())), + ("D", CellValue::Text("".into())), + ]; + + for (item, value) in &cases { + m.set_cell(coord(&[("Dim", item), ("Msr", "Val")]), value.clone()); + } + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + + for (item, expected) in &cases { + let got = loaded.get_cell(&coord(&[("Dim", item), ("Msr", "Val")])); + assert_eq!( + got, + Some(expected), + "Cell Dim={item} round-trip failed.\nExpected: {expected:?}\nGot: {got:?}\n{text}" + ); + } + } +} + +// ── Property-based parser tests ────────────────────────────────────────────── + +#[cfg(test)] +mod parser_prop_tests { + use super::{format_md, parse_md}; + use crate::model::cell::{CellKey, CellValue}; + use crate::model::Model; + use proptest::prelude::*; + + fn coord(pairs: &[(&str, &str)]) -> CellKey { + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) + } + + /// Safe identifier: won't collide with format grammar. + fn safe_ident() -> impl Strategy { + "[A-Za-z][A-Za-z0-9_]{0,9}" + } + + /// Text values designed to exercise parser edge cases. + fn tricky_text() -> impl Strategy { + prop_oneof![ + "[a-zA-Z0-9 ]{0,20}", + "[a-zA-Z]+(, [a-zA-Z]+)*", + Just(r#"say "hi""#.to_string()), + Just("\"".to_string()), + Just("\"\"".to_string()), + Just("a = b".to_string()), + Just(" = ".to_string()), + Just("# not a header".to_string()), + Just("[bracketed]".to_string()), + // printable ASCII range + "[ -~]{0,30}", + Just("".to_string()), + ] + } + + fn cell_value() -> impl Strategy { + prop_oneof![ + prop::num::f64::NORMAL + .prop_filter("finite", |f| f.is_finite()) + .prop_map(CellValue::Number), + (-1000i64..1000).prop_map(|n| CellValue::Number(n as f64)), + tricky_text().prop_map(CellValue::Text), + ] + } + + fn arbitrary_model() -> impl Strategy { + let items1 = prop::collection::hash_set(safe_ident(), 1..=4); + let items2 = prop::collection::hash_set(safe_ident(), 1..=4); + let values = prop::collection::vec(cell_value(), 1..=8); + + (safe_ident(), items1, items2, values).prop_map( + |(name, items1, items2, values)| { + let mut m = Model::new(&name); + m.add_category("CatA").unwrap(); + m.add_category("CatB").unwrap(); + + let items1: Vec<_> = items1.into_iter().collect(); + let items2: Vec<_> = items2.into_iter().collect(); + + for item in &items1 { + m.category_mut("CatA").unwrap().add_item(item); + } + for item in &items2 { + m.category_mut("CatB").unwrap().add_item(item); + } + + for (i, value) in values.into_iter().enumerate() { + let a = &items1[i % items1.len()]; + let b = &items2[i % items2.len()]; + m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value); + } + + m + }, + ) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + + #[test] + fn roundtrip_preserves_model_name(name in safe_ident()) { + let m = Model::new(&name); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + prop_assert_eq!(loaded.name, name); + } + + #[test] + fn roundtrip_preserves_categories_and_items( + items1 in prop::collection::hash_set(safe_ident(), 1..=5), + items2 in prop::collection::hash_set(safe_ident(), 1..=5), + ) { + let mut m = Model::new("Test"); + m.add_category("Alpha").unwrap(); + m.add_category("Beta").unwrap(); + for item in &items1 { + m.category_mut("Alpha").unwrap().add_item(item); + } + for item in &items2 { + m.category_mut("Beta").unwrap().add_item(item); + } + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + + let loaded_alpha: std::collections::HashSet = loaded + .category("Alpha").unwrap() + .items.values().map(|i| i.name.clone()).collect(); + let loaded_beta: std::collections::HashSet = loaded + .category("Beta").unwrap() + .items.values().map(|i| i.name.clone()).collect(); + + prop_assert_eq!(loaded_alpha, items1); + prop_assert_eq!(loaded_beta, items2); + } + + /// Double round-trip: format → parse → format should be idempotent. + #[test] + fn double_roundtrip_is_idempotent(model in arbitrary_model()) { + let text1 = format_md(&model); + let loaded = parse_md(&text1).unwrap(); + let text2 = format_md(&loaded); + prop_assert_eq!(&text1, &text2, + "format→parse→format was not idempotent"); + } + + /// Tricky text values survive round-trip as cell values. + #[test] + fn tricky_text_value_roundtrips(text_val in tricky_text()) { + let mut m = Model::new("Test"); + m.add_category("Dim").unwrap(); + m.category_mut("Dim").unwrap().add_item("A"); + m.add_category("Msr").unwrap(); + m.category_mut("Msr").unwrap().add_item("V"); + m.set_cell( + coord(&[("Dim", "A"), ("Msr", "V")]), + CellValue::Text(text_val.clone()), + ); + + let formatted = format_md(&m); + let loaded = parse_md(&formatted).unwrap(); + let got = loaded.get_cell(&coord(&[("Dim", "A"), ("Msr", "V")])); + prop_assert_eq!( + got, + Some(&CellValue::Text(text_val.clone())), + "Text value {:?} did not round-trip.\n{}", + text_val, formatted + ); + } + + /// Cell count is preserved across round-trip. + #[test] + fn roundtrip_preserves_cell_count(model in arbitrary_model()) { + let original_count = model.data.iter_cells().count(); + let text = format_md(&model); + let loaded = parse_md(&text).unwrap(); + let loaded_count = loaded.data.iter_cells().count(); + prop_assert_eq!(original_count, loaded_count, + "Cell count changed after round-trip"); + } + + /// Item names with special characters (brackets, backslashes) round-trip. + #[test] + fn tricky_item_names_roundtrip( + name in prop_oneof![ + "[a-zA-Z]{1,8}", + Just("[bracketed]".to_string()), + Just("a\\b".to_string()), + Just("x [y]".to_string()), + Just("\\[escaped\\]".to_string()), + Just("name[0]".to_string()), + "[ -~]{1,15}", + ] + ) { + // Item names must not be empty/whitespace or start with markdown syntax + prop_assume!(!name.is_empty()); + prop_assume!(name.trim() == name); // no leading/trailing whitespace + prop_assume!(!name.starts_with('#')); + prop_assume!(!name.starts_with('>')); + prop_assume!(!name.starts_with('-')); + + let mut m = Model::new("Test"); + m.add_category("Cat").unwrap(); + m.category_mut("Cat").unwrap().add_item(&name); + m.add_category("Dim").unwrap(); + m.category_mut("Dim").unwrap().add_item("X"); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let cat = loaded.category("Cat").unwrap(); + let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect(); + prop_assert!(item_names.contains(&name.as_str()), + "Item name {:?} did not round-trip.\nGot: {:?}\n{}", + name, item_names, text); + } + + /// Category names with special characters used in data coordinates. + #[test] + fn tricky_category_names_in_data_roundtrip( + cat_name in prop_oneof![ + "[a-zA-Z]{1,8}", + Just("Type, Sub".to_string()), + Just("A=B".to_string()), + Just("Cat`Name".to_string()), + Just("Income, Gross".to_string()), + ] + ) { + prop_assume!(!cat_name.is_empty()); + prop_assume!(!cat_name.starts_with('#')); + + let mut m = Model::new("Test"); + m.add_category(&cat_name).unwrap(); + m.category_mut(&cat_name).unwrap().add_item("X"); + m.add_category("Other").unwrap(); + m.category_mut("Other").unwrap().add_item("Y"); + m.set_cell( + coord(&[(&cat_name, "X"), ("Other", "Y")]), + CellValue::Number(42.0), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let got = loaded.get_cell(&coord(&[(&cat_name, "X"), ("Other", "Y")])); + prop_assert_eq!(got, Some(&CellValue::Number(42.0)), + "Category name {:?} broke data round-trip.\n{}", + cat_name, text); + } + + /// Item names with special characters used in data coordinates. + #[test] + fn tricky_item_names_in_data_roundtrip( + item_name in prop_oneof![ + "[a-zA-Z]{1,8}", + Just("A=B".to_string()), + Just("X, Y".to_string()), + Just("Item`s".to_string()), + ] + ) { + prop_assume!(!item_name.is_empty()); + prop_assume!(!item_name.starts_with('#')); + prop_assume!(!item_name.starts_with('-')); + prop_assume!(!item_name.starts_with('>')); + + let mut m = Model::new("Test"); + m.add_category("Cat").unwrap(); + m.category_mut("Cat").unwrap().add_item(&item_name); + m.add_category("Dim").unwrap(); + m.category_mut("Dim").unwrap().add_item("V"); + m.set_cell( + coord(&[("Cat", &item_name), ("Dim", "V")]), + CellValue::Number(99.0), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let got = loaded.get_cell(&coord(&[("Cat", &item_name), ("Dim", "V")])); + prop_assert_eq!(got, Some(&CellValue::Number(99.0)), + "Item name {:?} broke data round-trip.\n{}", + item_name, text); + } + } +} + +// ── Additional parser edge-case tests ──────────────────────────────────────── + +#[cfg(test)] +mod parser_edge_cases { + use super::{format_md, parse_md}; + use crate::model::category::Group; + use crate::model::cell::{CellKey, CellValue}; + use crate::model::Model; + + fn coord(pairs: &[(&str, &str)]) -> CellKey { + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) + } + + // ── Backtick quoting in coordinates ───────────────────────────────── + + #[test] + fn backtick_in_category_name() { + let mut m = Model::new("Test"); + m.add_category("Cat`s").unwrap(); + m.category_mut("Cat`s").unwrap().add_item("A"); + m.add_category("Dim").unwrap(); + m.category_mut("Dim").unwrap().add_item("X"); + m.set_cell( + coord(&[("Cat`s", "A"), ("Dim", "X")]), + CellValue::Number(1.0), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Cat`s", "A"), ("Dim", "X")])), + Some(&CellValue::Number(1.0)), + "Backtick in category name broke round-trip.\n{text}" + ); + } + + #[test] + fn item_name_with_both_equals_and_comma() { + let mut m = Model::new("Test"); + m.add_category("Cat").unwrap(); + m.category_mut("Cat").unwrap().add_item("a=1, b=2"); + m.add_category("Dim").unwrap(); + m.category_mut("Dim").unwrap().add_item("X"); + m.set_cell( + coord(&[("Cat", "a=1, b=2"), ("Dim", "X")]), + CellValue::Number(7.0), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Cat", "a=1, b=2"), ("Dim", "X")])), + Some(&CellValue::Number(7.0)), + "Item with '=' and ', ' broke round-trip.\n{text}" + ); + } + + // ── View section edge cases ───────────────────────────────────────── + + #[test] + fn hidden_item_with_slash_in_name() { + // hidden: Cat/Item — but what if item name contains '/'? + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.category_mut("Type").unwrap().add_item("A/B"); + m.active_view_mut().hide_item("Type", "A/B"); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + // This will likely fail — the parser splits on '/' + assert!( + loaded.active_view().is_hidden("Type", "A/B"), + "Hidden item with '/' in name was corrupted.\n{text}" + ); + } + + #[test] + fn collapsed_group_with_slash_in_name() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.active_view_mut().toggle_group_collapse("Type", "Q1/Q2"); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert!( + loaded.active_view().is_group_collapsed("Type", "Q1/Q2"), + "Collapsed group with '/' in name was corrupted.\n{text}" + ); + } + + #[test] + fn view_name_ending_with_active_string() { + // View name "Not (active)" could be confused with the active marker + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.category_mut("Type").unwrap().add_item("A"); + m.create_view("Not (active)"); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert!( + loaded.views.contains_key("Not (active)"), + "View name ending with '(active)' was misinterpreted.\nViews: {:?}\n{text}", + loaded.views.keys().collect::>() + ); + } + + // ── Group hierarchy edge cases ────────────────────────────────────── + + #[test] + fn group_name_with_brackets() { + let mut m = Model::new("Test"); + m.add_category("Month").unwrap(); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1 [2025]"); + m.category_mut("Month") + .unwrap() + .add_group(Group::new("Q1 [2025]").with_parent("Year")); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let cat = loaded.category("Month").unwrap(); + let jan = cat.items.values().find(|i| i.name == "Jan"); + assert!(jan.is_some(), "Jan item missing after round-trip.\n{text}"); + assert_eq!( + jan.unwrap().group.as_deref(), + Some("Q1 [2025]"), + "Group name with brackets was corrupted.\n{text}" + ); + } + + #[test] + fn item_in_group_where_item_has_brackets() { + // Item "Data [raw]" in group "Input" — the item name has brackets + // AND the item has a group. + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.category_mut("Type") + .unwrap() + .add_item_in_group("Data [raw]", "Input"); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let cat = loaded.category("Type").unwrap(); + let item = cat + .items + .values() + .find(|i| i.name == "Data [raw]"); + assert!( + item.is_some(), + "Item 'Data [raw]' with group not found.\nItems: {:?}\n{text}", + cat.items.values().map(|i| (&i.name, &i.group)).collect::>() + ); + assert_eq!(item.unwrap().group.as_deref(), Some("Input")); + } + + // ── Malformed input resilience ────────────────────────────────────── + + #[test] + fn parse_empty_string() { + let result = parse_md(""); + assert!(result.is_err() || result.unwrap().name.is_empty(), + "Empty input should either error or produce empty model"); + } + + #[test] + fn parse_just_model_name() { + let m = parse_md("# MyModel\n").unwrap(); + assert_eq!(m.name, "MyModel"); + } + + #[test] + fn parse_data_without_value() { + // Malformed data line: no " = " separator + let text = "# Test\n## Data\nType=Food\n"; + let m = parse_md(text).unwrap(); + // Should silently skip the malformed line + assert_eq!(m.data.iter_cells().count(), 0); + } + + #[test] + fn parse_data_with_empty_coords() { + // Data line with only value, no coordinates + let text = "# Test\n## Data\n = 42\n"; + let m = parse_md(text).unwrap(); + assert_eq!(m.data.iter_cells().count(), 0); + } + + #[test] + fn parse_duplicate_categories() { + // Two categories with the same name + let text = "# Test\n## Category: Type\n- A\n## Category: Type\n- B\n"; + let m = parse_md(text).unwrap(); + let cat = m.category("Type").unwrap(); + // Second declaration should win or merge + let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect(); + // At minimum shouldn't panic + assert!(!item_names.is_empty()); + } + + #[test] + fn parse_category_with_no_items() { + let text = "# Test\n## Category: Empty\n## Category: Full\n- A\n"; + let m = parse_md(text).unwrap(); + assert!(m.category("Empty").is_some()); + assert_eq!(m.category("Empty").unwrap().items.len(), 0); + assert_eq!(m.category("Full").unwrap().items.len(), 1); + } + + // ── Number formatting edge cases ──────────────────────────────────── + + #[test] + fn number_negative_zero_roundtrips() { + let mut m = Model::new("Test"); + m.add_category("A").unwrap(); + m.category_mut("A").unwrap().add_item("X"); + m.add_category("B").unwrap(); + m.category_mut("B").unwrap().add_item("Y"); + m.set_cell(coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(-0.0)); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + match got { + Some(CellValue::Number(n)) => assert!(n.abs() == 0.0), + other => panic!("Expected Number(0.0), got {other:?}"), + } + } + + #[test] + fn number_very_large_roundtrips() { + let mut m = Model::new("Test"); + m.add_category("A").unwrap(); + m.category_mut("A").unwrap().add_item("X"); + m.add_category("B").unwrap(); + m.category_mut("B").unwrap().add_item("Y"); + m.set_cell( + coord(&[("A", "X"), ("B", "Y")]), + CellValue::Number(1.7976931348623157e308), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + assert_eq!( + got, + Some(&CellValue::Number(1.7976931348623157e308)), + "f64::MAX did not round-trip.\n{text}" + ); + } + + #[test] + fn number_very_small_roundtrips() { + let mut m = Model::new("Test"); + m.add_category("A").unwrap(); + m.category_mut("A").unwrap().add_item("X"); + m.add_category("B").unwrap(); + m.category_mut("B").unwrap().add_item("Y"); + m.set_cell( + coord(&[("A", "X"), ("B", "Y")]), + CellValue::Number(5e-324), // f64::MIN_POSITIVE subnormal + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + assert_eq!( + got, + Some(&CellValue::Number(5e-324)), + "Subnormal float did not round-trip.\n{text}" + ); + } + + #[test] + fn number_pi_roundtrips() { + let mut m = Model::new("Test"); + m.add_category("A").unwrap(); + m.category_mut("A").unwrap().add_item("X"); + m.add_category("B").unwrap(); + m.category_mut("B").unwrap().add_item("Y"); + m.set_cell( + coord(&[("A", "X"), ("B", "Y")]), + CellValue::Number(std::f64::consts::PI), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + assert_eq!( + got, + Some(&CellValue::Number(std::f64::consts::PI)), + "PI did not round-trip.\n{text}" + ); + } + + // ── Whitespace edge cases ─────────────────────────────────────────── + + #[test] + fn model_name_with_leading_trailing_spaces() { + let text = "# Spaced Model \n"; + let m = parse_md(text).unwrap(); + assert_eq!(m.name, "Spaced Model"); + } + + #[test] + fn category_name_with_trailing_spaces() { + let text = "# Test\n## Category: Trailing \n- Item\n"; + let m = parse_md(text).unwrap(); + assert!(m.category("Trailing").is_some()); + } + + #[test] + fn data_line_with_extra_whitespace() { + let text = "# Test\n## Category: T\n- A\n## Category: M\n- J\n## Data\n T=A , M=J = 42 \n"; + let m = parse_md(text).unwrap(); + // Should handle extra whitespace gracefully + let count = m.data.iter_cells().count(); + assert!(count <= 1, "At most one cell should parse: got {count}"); + } + + // ── Three-category model ──────────────────────────────────────────── + + #[test] + fn three_categories_round_trip() { + let mut m = Model::new("3D"); + for cat in ["Region", "Product", "Year"] { + m.add_category(cat).unwrap(); + } + m.category_mut("Region").unwrap().add_item("East"); + m.category_mut("Region").unwrap().add_item("West"); + m.category_mut("Product").unwrap().add_item("Widget"); + m.category_mut("Year").unwrap().add_item("2025"); + m.set_cell( + coord(&[("Region", "East"), ("Product", "Widget"), ("Year", "2025")]), + CellValue::Number(1000.0), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[ + ("Region", "East"), + ("Product", "Widget"), + ("Year", "2025") + ])), + Some(&CellValue::Number(1000.0)), + "3-category cell did not round-trip.\n{text}" + ); + } + + // ── Text value with backslash ─────────────────────────────────────── + + #[test] + fn text_value_with_backslash() { + let mut m = Model::new("Test"); + m.add_category("A").unwrap(); + m.category_mut("A").unwrap().add_item("X"); + m.add_category("B").unwrap(); + m.category_mut("B").unwrap().add_item("Y"); + m.set_cell( + coord(&[("A", "X"), ("B", "Y")]), + CellValue::Text("C:\\Users\\file.txt".into()), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])), + Some(&CellValue::Text("C:\\Users\\file.txt".into())), + "Backslash in text was corrupted.\n{text}" + ); + } + + #[test] + fn text_value_with_backslash_n_literal() { + // The literal string "\n" (two chars) should not become a newline + let mut m = Model::new("Test"); + m.add_category("A").unwrap(); + m.category_mut("A").unwrap().add_item("X"); + m.add_category("B").unwrap(); + m.category_mut("B").unwrap().add_item("Y"); + m.set_cell( + coord(&[("A", "X"), ("B", "Y")]), + CellValue::Text("literal \\n not newline".into()), + ); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])), + Some(&CellValue::Text("literal \\n not newline".into())), + "Literal backslash-n was corrupted.\n{text}" + ); + } }