From 4b3fd234580f5463da7aeef11f35306ca6886191 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:59:32 +0000 Subject: [PATCH] refactor(engine): move cmd/whatif.rs items to cmd/whatif/ --- crates/engine/src/cmd/whatif.rs | 725 +----------------- .../engine/src/cmd/whatif/advanced_filter.rs | 231 ++++++ crates/engine/src/cmd/whatif/consolidate.rs | 193 +++++ crates/engine/src/cmd/whatif/scenarios.rs | 294 +++++++ 4 files changed, 730 insertions(+), 713 deletions(-) create mode 100644 crates/engine/src/cmd/whatif/advanced_filter.rs create mode 100644 crates/engine/src/cmd/whatif/consolidate.rs create mode 100644 crates/engine/src/cmd/whatif/scenarios.rs diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 1b9ed0f54..7b6f20c8d 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -16,6 +16,18 @@ use serde_json::{Value as Json, json}; use super::*; +mod advanced_filter; +mod consolidate; +mod scenarios; + +use advanced_filter::advanced_filter; +#[cfg(test)] +pub(crate) use advanced_filter::criterion_matches; +use consolidate::consolidate; +#[cfg(test)] +pub(crate) use scenarios::{SCENARIO_NAME, load_scenarios}; +use scenarios::{scenario_add, scenario_delete, scenario_list, scenario_show, scenario_summary}; + pub fn specs() -> Vec { vec![ cmd!( @@ -338,297 +350,6 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result { Ok(json!({"value": r.x, "result": value_json(&result), "iterations": r.iterations, "converged": r.converged})) } -// ---------------------------------------------------------------- scenarios - -/// Hidden workbook-scope name holding the scenarios as a quoted JSON string. -pub(crate) const SCENARIO_NAME: &str = "_sc_scenarios"; -const MAX_SCENARIO_CELLS: usize = 32; -const MAX_SCENARIOS: usize = 1000; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub(crate) struct Scenario { - pub name: String, - pub sheet: String, - pub changing: String, - pub values: Vec, - #[serde(default)] - pub comment: String, -} - -pub(crate) fn load_scenarios(wb: &Workbook) -> Vec { - let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)) else { return vec![] }; - serde_json::from_str(&string_literals(&n.formula)).unwrap_or_default() -} - -/// Concatenates the string literals of a `"a"&"b""c"` formula. -fn string_literals(f: &str) -> String { - let mut out = String::new(); - let mut it = f.chars().peekable(); - let mut in_str = false; - while let Some(c) = it.next() { - if !in_str { - if c == '"' { - in_str = true; - } - continue; - } - if c == '"' { - if it.peek() == Some(&'"') { - it.next(); - out.push('"'); - } else { - in_str = false; - } - } else { - out.push(c); - } - } - out -} - -fn store_scenarios(wb: &mut Workbook, list: &[Scenario]) { - wb.names.retain(|n| !(n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME))); - if list.is_empty() { - return; - } - let text = serde_json::to_string(list).unwrap_or_else(|_| "[]".into()); - wb.names.push(DefinedName { - name: SCENARIO_NAME.into(), - scope: None, - // Excel caps string literals at 255 characters: store `"…"&"…"` chunks. - formula: text - .chars() - .collect::>() - .chunks(250) - .map(|c| format!("\"{}\"", c.iter().collect::().replace('"', "\"\""))) - .collect::>() - .join("&"), - comment: "GridCraft Scenario Manager data".into(), - hidden: true, - }); -} - -fn scenario_json(sc: &Scenario) -> Json { - json!({"name": sc.name, "sheet": sc.sheet, "changing": sc.changing, "values": sc.values, "comment": sc.comment}) -} - -fn scenario_list(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let sheet = str_param(p, "sheet").map(str::to_string).unwrap_or_else(|| d.wb.active().map(|s| s.name.clone()).unwrap_or_default()); - let list: Vec = - load_scenarios(&d.wb).iter().filter(|sc| sheet == "*" || sc.sheet.eq_ignore_ascii_case(&sheet)).map(scenario_json).collect(); - Ok(json!({"scenarios": list})) -} - -fn scenario_add(s: &mut Session, p: &Json) -> Result { - const CMD: &str = "data.scenarioAdd"; - let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string(); - if name.chars().count() > 255 { - return Err(bad(CMD, "the name is too long (255 characters max)")); - } - let changing = str_param(p, "changing").ok_or_else(|| bad(CMD, "missing `changing`"))?.to_string(); - let d = s.doc()?; - let (si, cells) = cell_list(&d.wb, &changing, CMD, MAX_SCENARIO_CELLS)?; - if cells.is_empty() { - return Err(bad(CMD, "no changing cells")); - } - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let values: Vec = match p.get("values").and_then(Json::as_array) { - Some(a) => { - if a.len() != cells.len() { - return Err(bad(CMD, format!("{} values for {} changing cells", a.len(), cells.len()))); - } - a.clone() - } - None => cells.iter().map(|c| value_json(&sh.value(*c))).collect(), - }; - let changing = cells.iter().map(|c| c.a1()).collect::>().join(","); - let sc = Scenario { name: name.clone(), sheet: sh.name.clone(), changing, values, comment: str_param(p, "comment").unwrap_or("").to_string() }; - let replace = bool_param(p, "replace").unwrap_or(false); - let mut list = load_scenarios(&d.wb); - match list.iter().position(|x| x.name.eq_ignore_ascii_case(&name) && x.sheet.eq_ignore_ascii_case(&sc.sheet)) { - Some(i) if replace => { - if let Some(slot) = list.get_mut(i) { - *slot = sc.clone(); - } - } - Some(_) => return Err(EngineError::Other(format!("A scenario named '{name}' already exists on this sheet."))), - None => { - if list.len() >= MAX_SCENARIOS { - return Err(bad(CMD, "too many scenarios")); - } - list.push(sc.clone()); - } - } - edit(s, |cx| { - store_scenarios(&mut cx.wb, &list); - Ok(()) - })?; - Ok(scenario_json(&sc)) -} - -fn find_scenario(wb: &Workbook, p: &Json, cmd: &str) -> Result<(Vec, usize)> { - let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing `name`"))?; - let list = load_scenarios(wb); - let active = wb.active().map(|s| s.name.clone()).unwrap_or_default(); - let i = list - .iter() - .position(|x| x.name.eq_ignore_ascii_case(name) && x.sheet.eq_ignore_ascii_case(&active)) - .or_else(|| list.iter().position(|x| x.name.eq_ignore_ascii_case(name))) - .ok_or_else(|| bad(cmd, format!("no scenario named `{name}`")))?; - Ok((list, i)) -} - -fn scenario_show(s: &mut Session, p: &Json) -> Result { - const CMD: &str = "data.scenarioShow"; - let d = s.doc()?; - let (list, i) = find_scenario(&d.wb, p, CMD)?; - let sc = list.get(i).cloned().ok_or_else(|| bad(CMD, "no such scenario"))?; - let si = d.wb.sheet_index(&sc.sheet).ok_or_else(|| bad(CMD, format!("the scenario's sheet `{}` no longer exists", sc.sheet)))?; - let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?; - edit(s, |cx| { - for (c, v) in cells.iter().zip(sc.values.iter()) { - // Stored numbers are values, not typed text: entering them through the parser would - // apply percent entry (9 -> 9%) to a Percentage-formatted cell. - let cell = match v.as_f64() { - Some(n) => { - let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default(); - Some(Cell { value: Value::Number(n), formula: None, style }) - } - None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?, - }; - let sh = cx.sheet_mut(si)?; - match cell { - Some(cell) => sh.set_cell(*c, cell), - None => { - sh.remove_cell(*c); - } - } - cx.touch(si, *c); - } - Ok(()) - })?; - Ok(scenario_json(&sc)) -} - -fn scenario_delete(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let (mut list, i) = find_scenario(&d.wb, p, "data.scenarioDelete")?; - let removed = list.remove(i); - edit(s, |cx| { - store_scenarios(&mut cx.wb, &list); - Ok(()) - })?; - Ok(json!({"deleted": removed.name})) -} - -fn scenario_summary(s: &mut Session, p: &Json) -> Result { - const CMD: &str = "data.scenarioSummary"; - let d = s.doc()?; - let si = d.wb.active_sheet(); - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let scenarios: Vec = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect(); - if scenarios.is_empty() { - return Err(EngineError::Other("There are no scenarios on this sheet.".into())); - } - let results: Vec = match str_param(p, "resultCells").map(str::trim).filter(|t| !t.is_empty()) { - Some(t) => { - let (rsi, cells) = cell_list(&d.wb, t, CMD, 255)?; - if rsi != si { - return Err(bad(CMD, "result cells must be on the scenarios' sheet")); - } - cells - } - None => vec![], - }; - // Changing cells of every scenario, first-seen order; per scenario its values. - let mut changing: Vec = Vec::new(); - let mut per: Vec> = Vec::new(); - for sc in &scenarios { - let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?; - let mut m = HashMap::new(); - for (c, v) in cells.iter().zip(sc.values.iter()) { - if !changing.contains(c) { - changing.push(*c); - } - m.insert(*c, json_value(v, &d.wb)); - } - per.push(m); - } - let current: Vec = changing.iter().map(|c| sh.value(*c)).collect(); - let current_results: Vec = results.iter().map(|c| sh.value(*c)).collect(); - let mut trial = Trial::new(d); - let reads: Vec<(usize, CellRef)> = results.iter().map(|c| (si, *c)).collect(); - let scenario_results: Vec> = per - .iter() - .map(|m| { - let sets: Vec<(usize, CellRef, Value)> = m.iter().map(|(c, v)| (si, *c, v.clone())).collect(); - trial.run(&sets, &reads) - }) - .collect(); - let fmt_of = |c: CellRef| d.wb.styles.get(sh.style_id(c)).num_fmt.clone(); - let changing_fmts: Vec<_> = changing.iter().map(|c| fmt_of(*c)).collect(); - let result_fmts: Vec<_> = results.iter().map(|c| fmt_of(*c)).collect(); - let name = (1..) - .map(|n| if n == 1 { "Scenario Summary".to_string() } else { format!("Scenario Summary {n}") }) - .find(|n| d.wb.sheet_index(n).is_none()) - .unwrap_or_else(|| "Scenario Summary".into()); - let names: Vec = scenarios.iter().map(|x| x.name.clone()).collect(); - let new_index = edit(s, |cx| { - let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); - let mut sheet = Sheet::new(name.clone()); - let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { - sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); - }; - put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold); - put(&mut sheet, 2, 2, Value::text("Current Values:"), bold); - for (k, n) in names.iter().enumerate() { - put(&mut sheet, 2, 3 + k as u32, Value::text(n.as_str()), bold); - } - put(&mut sheet, 3, 1, Value::text("Changing Cells:"), bold); - let mut row = 4u32; - for (i, c) in changing.iter().enumerate() { - let fmt = changing_fmts.get(i).cloned().unwrap_or_default(); - let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt); - put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT); - let cur = current.get(i).cloned().unwrap_or_default(); - put(&mut sheet, row, 2, cur.clone(), st); - for (k, m) in per.iter().enumerate() { - put(&mut sheet, row, 3 + k as u32, m.get(c).cloned().unwrap_or_else(|| cur.clone()), st); - } - row += 1; - } - if !results.is_empty() { - put(&mut sheet, row, 1, Value::text("Result Cells:"), bold); - row += 1; - for (i, c) in results.iter().enumerate() { - let fmt = result_fmts.get(i).cloned().unwrap_or_default(); - let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt); - put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT); - put(&mut sheet, row, 2, current_results.get(i).cloned().unwrap_or_default(), st); - for (k, r) in scenario_results.iter().enumerate() { - put(&mut sheet, row, 3 + k as u32, r.get(i).cloned().unwrap_or_default(), st); - } - row += 1; - } - } - put( - &mut sheet, - row + 1, - 1, - Value::text("Notes: Current Values column represents values of changing cells at time Scenario Summary Report was created."), - StyleId::DEFAULT, - ); - sheet.cols.insert(1, gridcraft_model::LineInfo { size: Some(110.0), ..Default::default() }); - sheet.cols.insert(2, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); - cx.wb.sheets.push(std::sync::Arc::new(sheet)); - cx.structural = true; - Ok(cx.wb.sheets.len() - 1) - })?; - s.doc_mut()?.wb_switch_sheet(new_index); - Ok(json!({"sheet": name, "scenarios": names.len(), "changingCells": changing.len(), "resultCells": results.len()})) -} - // ---------------------------------------------------------------- data tables const MAX_TABLE_CELLS: u64 = 100_000; @@ -709,427 +430,5 @@ fn data_table(s: &mut Session, p: &Json) -> Result { Ok(json!({"range": body.a1(), "cells": n, "mode": mode, "static": true})) } -// ---------------------------------------------------------------- advanced filter - -#[derive(Clone, Debug)] -enum Cond { - /// Column offset in the list, comparison operator and operand text. - Compare(u32, Value), - /// A computed criterion: formula relative to the first data row, evaluated at `at`. - Computed(gridcraft_formula::Expr, CellRef), -} - -/// Splits `">=10"` into (`">="`, `"10"`); text without an operator means "begins with". -fn split_op(t: &str) -> (&str, &str) { - for op in ["<>", ">=", "<=", "=", ">", "<"] { - if let Some(rest) = t.strip_prefix(op) { - return (op, rest); - } - } - ("begins", t) -} - -/// Does a list value match one criteria cell's value (Excel's advanced-filter semantics)? -pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool { - let crit_text = match crit { - Value::Empty => return true, - Value::Number(n) => return v.as_f64().is_some_and(|x| (x - n).abs() <= 1e-12 * n.abs().max(1.0)), - Value::Bool(b) => return matches!(v, Value::Bool(x) if x == b), - Value::Error(e) => return v.as_error() == Some(*e), - Value::Text(t) => t.to_string(), - Value::Array(_) => return false, - }; - let (op, operand) = split_op(&crit_text); - if operand.is_empty() { - return match op { - "=" => v.is_empty() || text.is_empty(), - "<>" => !(v.is_empty() || text.is_empty()), - _ => true, - }; - } - let num = gridcraft_core::parse::parse_number_text(operand); - let lower_text = text.to_lowercase(); - let lower_op = operand.to_lowercase(); - use std::cmp::Ordering; - let cmp: Option = match (v, num) { - (Value::Number(a), Some(b)) => a.partial_cmp(&b), - (Value::Number(_), None) => None, - (Value::Empty, _) => None, - (_, Some(_)) if op != "begins" => None, - _ => Some(gridcraft_core::compare_text(text, operand)), - }; - let wild = |pat: &str| super::edit::wildcard(&lower_text, pat); - match op { - "=" => { - if num.is_none() && operand.contains(['*', '?']) { - wild(&lower_op) - } else if num.is_none() { - lower_text == lower_op - } else { - cmp == Some(Ordering::Equal) - } - } - "<>" => { - if num.is_none() && operand.contains(['*', '?']) { - !wild(&lower_op) - } else if num.is_none() { - lower_text != lower_op - } else { - cmp != Some(Ordering::Equal) - } - } - ">" => cmp == Some(Ordering::Greater), - "<" => cmp == Some(Ordering::Less), - ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), - "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), - _ => { - // Plain text criterion: begins with (wildcards allowed); a plain number equals. - match (v, num) { - (Value::Number(a), Some(b)) => (a - b).abs() <= 1e-12 * b.abs().max(1.0), - _ => !v.is_empty() && wild(&format!("{lower_op}*")), - } - } - } -} - -fn advanced_filter(s: &mut Session, p: &Json) -> Result { - const CMD: &str = "data.advancedFilter"; - let d = s.doc()?; - let (si, list) = match str_param(p, "range") { - Some(t) => parse_range(&d.wb, t, CMD)?, - None => { - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - (d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active)) - } - }; - let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?; - if list.height() < 1 || list.count() > 50_000_000 { - return Err(bad(CMD, "the list range is too large")); - } - if let Some((_, crit)) = criteria - && (crit.width() > 1024 || crit.height() > 10_000) - { - return Err(bad(CMD, "the criteria range is too large")); - } - let wb = &d.wb; - let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?; - let headers: Vec = - (list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect(); - let first_data = list.start.row + 1; - // Criteria rows → conditions. - let mut rows: Vec> = Vec::new(); - if let Some((csi, crit)) = criteria { - let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?; - for cr in crit.start.row + 1..=crit.end.row { - let mut conds = Vec::new(); - for cc in crit.start.col..=crit.end.col { - let at = CellRef::new(cr, cc); - let cell = csh.cell(at); - let v = csh.value(at); - let formula = cell.and_then(|c| c.formula.as_ref()); - if v.is_empty() && formula.is_none() { - continue; - } - let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase(); - match headers.iter().position(|h| !head.is_empty() && *h == head) { - Some(off) => conds.push(Cond::Compare(off as u32, v)), - None => match formula.and_then(|f| f.expr()) { - Some(e) => conds.push(Cond::Computed(e, at)), - None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))), - }, - } - } - rows.push(conds); - } - } - let matches_row = |row: u32| -> bool { - if rows.is_empty() { - return true; - } - rows.iter().any(|conds| { - conds.iter().all(|c| match c { - Cond::Compare(off, crit) => { - let at = CellRef::new(row, list.start.col + off); - let v = sh.value(at); - let text = crate::display::cell_text(wb, sh, at); - criterion_matches(&v, &text, crit) - } - Cond::Computed(e, at) => { - let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0); - let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted); - matches!(v.scalar().to_bool(), Ok(true)) - } - }) - }) - }; - let unique = bool_param(p, "unique").unwrap_or(false); - let mut seen: HashSet> = HashSet::new(); - let mut keep: Vec<(u32, bool)> = Vec::new(); - for row in first_data..=list.end.row { - let mut ok = matches_row(row); - if ok && unique { - let key: Vec = - (list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(row, c)).to_lowercase()).collect(); - ok = seen.insert(key); - } - keep.push((row, ok)); - } - let matched = keep.iter().filter(|(_, k)| *k).count(); - if let Some(t) = str_param(p, "copyTo") { - let (dsi, dest) = parse_range(wb, t, CMD)?; - let dest = dest.start; - let mut rows_out: Vec = vec![list.start.row]; - rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r)); - let end = - dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?; - let out_range = RangeRef::new(dest, end); - if dsi == si && out_range.intersects(&list) { - return Err(EngineError::Other("The extract range can't overlap the list range.".into())); - } - let cells: Vec<(CellRef, Cell)> = rows_out - .iter() - .enumerate() - .flat_map(|(i, row)| { - (list.start.col..=list.end.col).map(move |c| { - let src = CellRef::new(*row, c); - // The full output footprint was checked above. - let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col); - let style = sh.style_id(src); - (to, Cell { value: sh.value(src), formula: None, style }) - }) - }) - .collect(); - edit(s, |cx| { - let shm = cx.sheet_mut(dsi)?; - let old: Vec = shm.cells.iter_range(out_range).map(|(c, _)| c).collect(); - for c in &old { - shm.remove_cell(*c); - } - for (c, cell) in cells { - shm.set_cell(c, cell); - } - for c in old { - cx.touch(dsi, c); - } - for c in out_range.iter() { - cx.touch(dsi, c); - } - Ok(()) - })?; - return Ok(json!({"matched": matched, "range": out_range.a1()})); - } - let hidden = keep.iter().filter(|(_, k)| !*k).count(); - edit(s, |cx| { - let shm = cx.sheet_mut(si)?; - for (row, k) in &keep { - if *k { - if let Some(e) = shm.rows.get_mut(row) { - e.hidden = false; - if *e == gridcraft_model::LineInfo::default() { - shm.rows.remove(row); - } - } - } else { - shm.rows.entry(*row).or_default().hidden = true; - } - } - cx.structural = true; - Ok(()) - })?; - Ok(json!({"matched": matched, "hiddenRows": hidden})) -} - -// ---------------------------------------------------------------- consolidate - -#[derive(Clone, Copy, Debug, Default)] -struct Agg { - count: u64, - nums: u64, - sum: f64, - product: f64, - min: f64, - max: f64, -} - -impl Agg { - fn add(&mut self, v: &Value) { - if v.is_empty() { - return; - } - self.count += 1; - if let Value::Number(n) = v { - if self.nums == 0 { - self.min = *n; - self.max = *n; - self.product = 1.0; - } - self.nums += 1; - self.sum += n; - self.product *= n; - self.min = self.min.min(*n); - self.max = self.max.max(*n); - } - } - fn result(&self, func: &str) -> Value { - match func { - "count" => Value::number(self.count as f64), - "countNums" | "countnums" => Value::number(self.nums as f64), - "average" => { - if self.nums == 0 { - Value::Error(gridcraft_core::CellError::Div0) - } else { - Value::number(self.sum / self.nums as f64) - } - } - "max" => Value::number(if self.nums == 0 { 0.0 } else { self.max }), - "min" => Value::number(if self.nums == 0 { 0.0 } else { self.min }), - "product" => Value::number(if self.nums == 0 { 0.0 } else { self.product }), - _ => Value::number(self.sum), - } - } -} - -/// A row/column key: a position or a label (matched case-insensitively). -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -enum Key { - Pos(u32), - Label(String), -} - -const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000; - -fn consolidate(s: &mut Session, p: &Json) -> Result { - const CMD: &str = "data.consolidate"; - let func = str_param(p, "function").unwrap_or("sum").to_string(); - if !["sum", "count", "countNums", "countnums", "average", "max", "min", "product"].contains(&func.as_str()) { - return Err(bad(CMD, format!("unknown function `{func}`"))); - } - let top = bool_param(p, "topRow").unwrap_or(false); - let left = bool_param(p, "leftColumn").unwrap_or(false); - let d = s.doc()?; - let srcs: Vec = p - .get("sources") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()) - .or_else(|| str_param(p, "sources").map(|t| t.split(';').map(str::to_string).collect())) - .unwrap_or_default(); - if srcs.is_empty() { - return Err(bad(CMD, "give at least one source range in `sources`")); - } - if srcs.len() > 255 { - return Err(bad(CMD, "too many sources (255 max)")); - } - let (dsi, dest) = match str_param(p, "destination") { - Some(t) => { - let (si, r) = parse_range(&d.wb, t, CMD)?; - (si, r.start) - } - None => (d.wb.active_sheet(), d.selection.active), - }; - let mut row_keys: Vec = Vec::new(); - let mut col_keys: Vec = Vec::new(); - let mut row_label: HashMap = HashMap::new(); - let mut col_label: HashMap = HashMap::new(); - let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new(); - let mut total: u64 = 0; - let index_of = |keys: &mut Vec, k: Key| -> usize { - match keys.iter().position(|x| *x == k) { - Some(i) => i, - None => { - keys.push(k); - keys.len() - 1 - } - } - }; - for t in &srcs { - let (si, r) = parse_range(&d.wb, t, CMD)?; - total = total.saturating_add(r.count()); - if total > MAX_CONSOLIDATE_CELLS { - return Err(bad(CMD, "the sources are too large")); - } - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let r0 = r.start.row + top as u32; - let c0 = r.start.col + left as u32; - if r0 > r.end.row || c0 > r.end.col { - continue; - } - let label = |c: CellRef| crate::display::cell_text(&d.wb, sh, c).trim().to_string(); - // Column keys of this source. - let mut cols: Vec<(u32, usize)> = Vec::new(); - for col in c0..=r.end.col { - let k = if top { - let l = label(CellRef::new(r.start.row, col)); - if l.is_empty() { - continue; - } - let k = Key::Label(l.to_lowercase()); - col_label.entry(k.clone()).or_insert(l); - k - } else { - Key::Pos(col - c0) - }; - cols.push((col, index_of(&mut col_keys, k))); - } - for row in r0..=r.end.row { - let k = if left { - let l = label(CellRef::new(row, r.start.col)); - if l.is_empty() { - continue; - } - let k = Key::Label(l.to_lowercase()); - row_label.entry(k.clone()).or_insert(l); - k - } else { - Key::Pos(row - r0) - }; - let ri = index_of(&mut row_keys, k); - for (col, ci) in &cols { - aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col))); - } - } - } - let (out_r0, out_c0) = (dest.row + top as u32, dest.col + left as u32); - if out_r0 as u64 + row_keys.len() as u64 > gridcraft_core::MAX_ROWS as u64 - || out_c0 as u64 + col_keys.len() as u64 > gridcraft_core::MAX_COLS as u64 - { - return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into())); - } - let mut cells: Vec<(CellRef, Value)> = Vec::new(); - if top { - for (ci, k) in col_keys.iter().enumerate() { - cells.push((CellRef::new(dest.row, out_c0 + ci as u32), Value::text(col_label.get(k).cloned().unwrap_or_default()))); - } - } - if left { - for (ri, k) in row_keys.iter().enumerate() { - cells.push((CellRef::new(out_r0 + ri as u32, dest.col), Value::text(row_label.get(k).cloned().unwrap_or_default()))); - } - } - for ri in 0..row_keys.len() { - for ci in 0..col_keys.len() { - let v = aggs.get(&(ri, ci)).map(|a| if a.count == 0 { Value::Empty } else { a.result(&func) }).unwrap_or_default(); - cells.push((CellRef::new(out_r0 + ri as u32, out_c0 + ci as u32), v)); - } - } - let end = CellRef::new( - (out_r0 + row_keys.len() as u32).saturating_sub(1).max(dest.row), - (out_c0 + col_keys.len() as u32).saturating_sub(1).max(dest.col), - ); - edit(s, |cx| { - for (c, v) in cells { - let sh = cx.sheet_mut(dsi)?; - let style = sh.style_id(c); - if v.is_empty() && style == StyleId::DEFAULT { - sh.remove_cell(c); - } else { - sh.set_cell(c, Cell { value: v, formula: None, style }); - } - cx.touch(dsi, c); - } - Ok(()) - })?; - Ok(json!({"range": RangeRef::new(dest, end).a1(), "rows": row_keys.len(), "columns": col_keys.len()})) -} - #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/whatif/advanced_filter.rs b/crates/engine/src/cmd/whatif/advanced_filter.rs new file mode 100644 index 000000000..53e55820c --- /dev/null +++ b/crates/engine/src/cmd/whatif/advanced_filter.rs @@ -0,0 +1,231 @@ +use super::*; + +// ---------------------------------------------------------------- advanced filter + +#[derive(Clone, Debug)] +enum Cond { + /// Column offset in the list, comparison operator and operand text. + Compare(u32, Value), + /// A computed criterion: formula relative to the first data row, evaluated at `at`. + Computed(gridcraft_formula::Expr, CellRef), +} + +/// Splits `">=10"` into (`">="`, `"10"`); text without an operator means "begins with". +fn split_op(t: &str) -> (&str, &str) { + for op in ["<>", ">=", "<=", "=", ">", "<"] { + if let Some(rest) = t.strip_prefix(op) { + return (op, rest); + } + } + ("begins", t) +} + +/// Does a list value match one criteria cell's value (Excel's advanced-filter semantics)? +pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool { + let crit_text = match crit { + Value::Empty => return true, + Value::Number(n) => return v.as_f64().is_some_and(|x| (x - n).abs() <= 1e-12 * n.abs().max(1.0)), + Value::Bool(b) => return matches!(v, Value::Bool(x) if x == b), + Value::Error(e) => return v.as_error() == Some(*e), + Value::Text(t) => t.to_string(), + Value::Array(_) => return false, + }; + let (op, operand) = split_op(&crit_text); + if operand.is_empty() { + return match op { + "=" => v.is_empty() || text.is_empty(), + "<>" => !(v.is_empty() || text.is_empty()), + _ => true, + }; + } + let num = gridcraft_core::parse::parse_number_text(operand); + let lower_text = text.to_lowercase(); + let lower_op = operand.to_lowercase(); + use std::cmp::Ordering; + let cmp: Option = match (v, num) { + (Value::Number(a), Some(b)) => a.partial_cmp(&b), + (Value::Number(_), None) => None, + (Value::Empty, _) => None, + (_, Some(_)) if op != "begins" => None, + _ => Some(gridcraft_core::compare_text(text, operand)), + }; + let wild = |pat: &str| crate::cmd::edit::wildcard(&lower_text, pat); + match op { + "=" => { + if num.is_none() && operand.contains(['*', '?']) { + wild(&lower_op) + } else if num.is_none() { + lower_text == lower_op + } else { + cmp == Some(Ordering::Equal) + } + } + "<>" => { + if num.is_none() && operand.contains(['*', '?']) { + !wild(&lower_op) + } else if num.is_none() { + lower_text != lower_op + } else { + cmp != Some(Ordering::Equal) + } + } + ">" => cmp == Some(Ordering::Greater), + "<" => cmp == Some(Ordering::Less), + ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), + "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), + _ => { + // Plain text criterion: begins with (wildcards allowed); a plain number equals. + match (v, num) { + (Value::Number(a), Some(b)) => (a - b).abs() <= 1e-12 * b.abs().max(1.0), + _ => !v.is_empty() && wild(&format!("{lower_op}*")), + } + } + } +} + +pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "data.advancedFilter"; + let d = s.doc()?; + let (si, list) = match str_param(p, "range") { + Some(t) => parse_range(&d.wb, t, CMD)?, + None => { + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + (d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active)) + } + }; + let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?; + if list.height() < 1 || list.count() > 50_000_000 { + return Err(bad(CMD, "the list range is too large")); + } + if let Some((_, crit)) = criteria + && (crit.width() > 1024 || crit.height() > 10_000) + { + return Err(bad(CMD, "the criteria range is too large")); + } + let wb = &d.wb; + let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?; + let headers: Vec = + (list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect(); + let first_data = list.start.row + 1; + // Criteria rows → conditions. + let mut rows: Vec> = Vec::new(); + if let Some((csi, crit)) = criteria { + let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?; + for cr in crit.start.row + 1..=crit.end.row { + let mut conds = Vec::new(); + for cc in crit.start.col..=crit.end.col { + let at = CellRef::new(cr, cc); + let cell = csh.cell(at); + let v = csh.value(at); + let formula = cell.and_then(|c| c.formula.as_ref()); + if v.is_empty() && formula.is_none() { + continue; + } + let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase(); + match headers.iter().position(|h| !head.is_empty() && *h == head) { + Some(off) => conds.push(Cond::Compare(off as u32, v)), + None => match formula.and_then(|f| f.expr()) { + Some(e) => conds.push(Cond::Computed(e, at)), + None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))), + }, + } + } + rows.push(conds); + } + } + let matches_row = |row: u32| -> bool { + if rows.is_empty() { + return true; + } + rows.iter().any(|conds| { + conds.iter().all(|c| match c { + Cond::Compare(off, crit) => { + let at = CellRef::new(row, list.start.col + off); + let v = sh.value(at); + let text = crate::display::cell_text(wb, sh, at); + criterion_matches(&v, &text, crit) + } + Cond::Computed(e, at) => { + let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0); + let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted); + matches!(v.scalar().to_bool(), Ok(true)) + } + }) + }) + }; + let unique = bool_param(p, "unique").unwrap_or(false); + let mut seen: HashSet> = HashSet::new(); + let mut keep: Vec<(u32, bool)> = Vec::new(); + for row in first_data..=list.end.row { + let mut ok = matches_row(row); + if ok && unique { + let key: Vec = + (list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(row, c)).to_lowercase()).collect(); + ok = seen.insert(key); + } + keep.push((row, ok)); + } + let matched = keep.iter().filter(|(_, k)| *k).count(); + if let Some(t) = str_param(p, "copyTo") { + let (dsi, dest) = parse_range(wb, t, CMD)?; + let dest = dest.start; + let mut rows_out: Vec = vec![list.start.row]; + rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r)); + let end = + dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?; + let out_range = RangeRef::new(dest, end); + if dsi == si && out_range.intersects(&list) { + return Err(EngineError::Other("The extract range can't overlap the list range.".into())); + } + let cells: Vec<(CellRef, Cell)> = rows_out + .iter() + .enumerate() + .flat_map(|(i, row)| { + (list.start.col..=list.end.col).map(move |c| { + let src = CellRef::new(*row, c); + // The full output footprint was checked above. + let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col); + let style = sh.style_id(src); + (to, Cell { value: sh.value(src), formula: None, style }) + }) + }) + .collect(); + edit(s, |cx| { + let shm = cx.sheet_mut(dsi)?; + let old: Vec = shm.cells.iter_range(out_range).map(|(c, _)| c).collect(); + for c in &old { + shm.remove_cell(*c); + } + for (c, cell) in cells { + shm.set_cell(c, cell); + } + for c in old { + cx.touch(dsi, c); + } + for c in out_range.iter() { + cx.touch(dsi, c); + } + Ok(()) + })?; + return Ok(json!({"matched": matched, "range": out_range.a1()})); + } + let hidden = keep.iter().filter(|(_, k)| !*k).count(); + edit(s, |cx| { + let shm = cx.sheet_mut(si)?; + for (row, k) in &keep { + if *k { + if let Some(e) = shm.rows.get_mut(row) { + e.hidden = false; + if *e == gridcraft_model::LineInfo::default() { + shm.rows.remove(row); + } + } + } else { + shm.rows.entry(*row).or_default().hidden = true; + } + } + cx.structural = true; + Ok(()) + })?; + Ok(json!({"matched": matched, "hiddenRows": hidden})) +} diff --git a/crates/engine/src/cmd/whatif/consolidate.rs b/crates/engine/src/cmd/whatif/consolidate.rs new file mode 100644 index 000000000..1962f64e5 --- /dev/null +++ b/crates/engine/src/cmd/whatif/consolidate.rs @@ -0,0 +1,193 @@ +use super::*; + +// ---------------------------------------------------------------- consolidate + +#[derive(Clone, Copy, Debug, Default)] +struct Agg { + count: u64, + nums: u64, + sum: f64, + product: f64, + min: f64, + max: f64, +} + +impl Agg { + fn add(&mut self, v: &Value) { + if v.is_empty() { + return; + } + self.count += 1; + if let Value::Number(n) = v { + if self.nums == 0 { + self.min = *n; + self.max = *n; + self.product = 1.0; + } + self.nums += 1; + self.sum += n; + self.product *= n; + self.min = self.min.min(*n); + self.max = self.max.max(*n); + } + } + fn result(&self, func: &str) -> Value { + match func { + "count" => Value::number(self.count as f64), + "countNums" | "countnums" => Value::number(self.nums as f64), + "average" => { + if self.nums == 0 { + Value::Error(gridcraft_core::CellError::Div0) + } else { + Value::number(self.sum / self.nums as f64) + } + } + "max" => Value::number(if self.nums == 0 { 0.0 } else { self.max }), + "min" => Value::number(if self.nums == 0 { 0.0 } else { self.min }), + "product" => Value::number(if self.nums == 0 { 0.0 } else { self.product }), + _ => Value::number(self.sum), + } + } +} + +/// A row/column key: a position or a label (matched case-insensitively). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum Key { + Pos(u32), + Label(String), +} + +const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000; + +pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "data.consolidate"; + let func = str_param(p, "function").unwrap_or("sum").to_string(); + if !["sum", "count", "countNums", "countnums", "average", "max", "min", "product"].contains(&func.as_str()) { + return Err(bad(CMD, format!("unknown function `{func}`"))); + } + let top = bool_param(p, "topRow").unwrap_or(false); + let left = bool_param(p, "leftColumn").unwrap_or(false); + let d = s.doc()?; + let srcs: Vec = p + .get("sources") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()) + .or_else(|| str_param(p, "sources").map(|t| t.split(';').map(str::to_string).collect())) + .unwrap_or_default(); + if srcs.is_empty() { + return Err(bad(CMD, "give at least one source range in `sources`")); + } + if srcs.len() > 255 { + return Err(bad(CMD, "too many sources (255 max)")); + } + let (dsi, dest) = match str_param(p, "destination") { + Some(t) => { + let (si, r) = parse_range(&d.wb, t, CMD)?; + (si, r.start) + } + None => (d.wb.active_sheet(), d.selection.active), + }; + let mut row_keys: Vec = Vec::new(); + let mut col_keys: Vec = Vec::new(); + let mut row_label: HashMap = HashMap::new(); + let mut col_label: HashMap = HashMap::new(); + let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new(); + let mut total: u64 = 0; + let index_of = |keys: &mut Vec, k: Key| -> usize { + match keys.iter().position(|x| *x == k) { + Some(i) => i, + None => { + keys.push(k); + keys.len() - 1 + } + } + }; + for t in &srcs { + let (si, r) = parse_range(&d.wb, t, CMD)?; + total = total.saturating_add(r.count()); + if total > MAX_CONSOLIDATE_CELLS { + return Err(bad(CMD, "the sources are too large")); + } + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let r0 = r.start.row + top as u32; + let c0 = r.start.col + left as u32; + if r0 > r.end.row || c0 > r.end.col { + continue; + } + let label = |c: CellRef| crate::display::cell_text(&d.wb, sh, c).trim().to_string(); + // Column keys of this source. + let mut cols: Vec<(u32, usize)> = Vec::new(); + for col in c0..=r.end.col { + let k = if top { + let l = label(CellRef::new(r.start.row, col)); + if l.is_empty() { + continue; + } + let k = Key::Label(l.to_lowercase()); + col_label.entry(k.clone()).or_insert(l); + k + } else { + Key::Pos(col - c0) + }; + cols.push((col, index_of(&mut col_keys, k))); + } + for row in r0..=r.end.row { + let k = if left { + let l = label(CellRef::new(row, r.start.col)); + if l.is_empty() { + continue; + } + let k = Key::Label(l.to_lowercase()); + row_label.entry(k.clone()).or_insert(l); + k + } else { + Key::Pos(row - r0) + }; + let ri = index_of(&mut row_keys, k); + for (col, ci) in &cols { + aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col))); + } + } + } + let (out_r0, out_c0) = (dest.row + top as u32, dest.col + left as u32); + if out_r0 as u64 + row_keys.len() as u64 > gridcraft_core::MAX_ROWS as u64 + || out_c0 as u64 + col_keys.len() as u64 > gridcraft_core::MAX_COLS as u64 + { + return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into())); + } + let mut cells: Vec<(CellRef, Value)> = Vec::new(); + if top { + for (ci, k) in col_keys.iter().enumerate() { + cells.push((CellRef::new(dest.row, out_c0 + ci as u32), Value::text(col_label.get(k).cloned().unwrap_or_default()))); + } + } + if left { + for (ri, k) in row_keys.iter().enumerate() { + cells.push((CellRef::new(out_r0 + ri as u32, dest.col), Value::text(row_label.get(k).cloned().unwrap_or_default()))); + } + } + for ri in 0..row_keys.len() { + for ci in 0..col_keys.len() { + let v = aggs.get(&(ri, ci)).map(|a| if a.count == 0 { Value::Empty } else { a.result(&func) }).unwrap_or_default(); + cells.push((CellRef::new(out_r0 + ri as u32, out_c0 + ci as u32), v)); + } + } + let end = CellRef::new( + (out_r0 + row_keys.len() as u32).saturating_sub(1).max(dest.row), + (out_c0 + col_keys.len() as u32).saturating_sub(1).max(dest.col), + ); + edit(s, |cx| { + for (c, v) in cells { + let sh = cx.sheet_mut(dsi)?; + let style = sh.style_id(c); + if v.is_empty() && style == StyleId::DEFAULT { + sh.remove_cell(c); + } else { + sh.set_cell(c, Cell { value: v, formula: None, style }); + } + cx.touch(dsi, c); + } + Ok(()) + })?; + Ok(json!({"range": RangeRef::new(dest, end).a1(), "rows": row_keys.len(), "columns": col_keys.len()})) +} diff --git a/crates/engine/src/cmd/whatif/scenarios.rs b/crates/engine/src/cmd/whatif/scenarios.rs new file mode 100644 index 000000000..15aa053eb --- /dev/null +++ b/crates/engine/src/cmd/whatif/scenarios.rs @@ -0,0 +1,294 @@ +use super::*; + +// ---------------------------------------------------------------- scenarios + +/// Hidden workbook-scope name holding the scenarios as a quoted JSON string. +pub(crate) const SCENARIO_NAME: &str = "_sc_scenarios"; + +const MAX_SCENARIO_CELLS: usize = 32; + +const MAX_SCENARIOS: usize = 1000; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct Scenario { + pub name: String, + pub sheet: String, + pub changing: String, + pub values: Vec, + #[serde(default)] + pub comment: String, +} + +pub(crate) fn load_scenarios(wb: &Workbook) -> Vec { + let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)) else { return vec![] }; + serde_json::from_str(&string_literals(&n.formula)).unwrap_or_default() +} + +/// Concatenates the string literals of a `"a"&"b""c"` formula. +fn string_literals(f: &str) -> String { + let mut out = String::new(); + let mut it = f.chars().peekable(); + let mut in_str = false; + while let Some(c) = it.next() { + if !in_str { + if c == '"' { + in_str = true; + } + continue; + } + if c == '"' { + if it.peek() == Some(&'"') { + it.next(); + out.push('"'); + } else { + in_str = false; + } + } else { + out.push(c); + } + } + out +} + +fn store_scenarios(wb: &mut Workbook, list: &[Scenario]) { + wb.names.retain(|n| !(n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME))); + if list.is_empty() { + return; + } + let text = serde_json::to_string(list).unwrap_or_else(|_| "[]".into()); + wb.names.push(DefinedName { + name: SCENARIO_NAME.into(), + scope: None, + // Excel caps string literals at 255 characters: store `"…"&"…"` chunks. + formula: text + .chars() + .collect::>() + .chunks(250) + .map(|c| format!("\"{}\"", c.iter().collect::().replace('"', "\"\""))) + .collect::>() + .join("&"), + comment: "GridCraft Scenario Manager data".into(), + hidden: true, + }); +} + +fn scenario_json(sc: &Scenario) -> Json { + json!({"name": sc.name, "sheet": sc.sheet, "changing": sc.changing, "values": sc.values, "comment": sc.comment}) +} + +pub(super) fn scenario_list(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let sheet = str_param(p, "sheet").map(str::to_string).unwrap_or_else(|| d.wb.active().map(|s| s.name.clone()).unwrap_or_default()); + let list: Vec = + load_scenarios(&d.wb).iter().filter(|sc| sheet == "*" || sc.sheet.eq_ignore_ascii_case(&sheet)).map(scenario_json).collect(); + Ok(json!({"scenarios": list})) +} + +pub(super) fn scenario_add(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "data.scenarioAdd"; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string(); + if name.chars().count() > 255 { + return Err(bad(CMD, "the name is too long (255 characters max)")); + } + let changing = str_param(p, "changing").ok_or_else(|| bad(CMD, "missing `changing`"))?.to_string(); + let d = s.doc()?; + let (si, cells) = cell_list(&d.wb, &changing, CMD, MAX_SCENARIO_CELLS)?; + if cells.is_empty() { + return Err(bad(CMD, "no changing cells")); + } + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let values: Vec = match p.get("values").and_then(Json::as_array) { + Some(a) => { + if a.len() != cells.len() { + return Err(bad(CMD, format!("{} values for {} changing cells", a.len(), cells.len()))); + } + a.clone() + } + None => cells.iter().map(|c| value_json(&sh.value(*c))).collect(), + }; + let changing = cells.iter().map(|c| c.a1()).collect::>().join(","); + let sc = Scenario { name: name.clone(), sheet: sh.name.clone(), changing, values, comment: str_param(p, "comment").unwrap_or("").to_string() }; + let replace = bool_param(p, "replace").unwrap_or(false); + let mut list = load_scenarios(&d.wb); + match list.iter().position(|x| x.name.eq_ignore_ascii_case(&name) && x.sheet.eq_ignore_ascii_case(&sc.sheet)) { + Some(i) if replace => { + if let Some(slot) = list.get_mut(i) { + *slot = sc.clone(); + } + } + Some(_) => return Err(EngineError::Other(format!("A scenario named '{name}' already exists on this sheet."))), + None => { + if list.len() >= MAX_SCENARIOS { + return Err(bad(CMD, "too many scenarios")); + } + list.push(sc.clone()); + } + } + edit(s, |cx| { + store_scenarios(&mut cx.wb, &list); + Ok(()) + })?; + Ok(scenario_json(&sc)) +} + +fn find_scenario(wb: &Workbook, p: &Json, cmd: &str) -> Result<(Vec, usize)> { + let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing `name`"))?; + let list = load_scenarios(wb); + let active = wb.active().map(|s| s.name.clone()).unwrap_or_default(); + let i = list + .iter() + .position(|x| x.name.eq_ignore_ascii_case(name) && x.sheet.eq_ignore_ascii_case(&active)) + .or_else(|| list.iter().position(|x| x.name.eq_ignore_ascii_case(name))) + .ok_or_else(|| bad(cmd, format!("no scenario named `{name}`")))?; + Ok((list, i)) +} + +pub(super) fn scenario_show(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "data.scenarioShow"; + let d = s.doc()?; + let (list, i) = find_scenario(&d.wb, p, CMD)?; + let sc = list.get(i).cloned().ok_or_else(|| bad(CMD, "no such scenario"))?; + let si = d.wb.sheet_index(&sc.sheet).ok_or_else(|| bad(CMD, format!("the scenario's sheet `{}` no longer exists", sc.sheet)))?; + let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?; + edit(s, |cx| { + for (c, v) in cells.iter().zip(sc.values.iter()) { + // Stored numbers are values, not typed text: entering them through the parser would + // apply percent entry (9 -> 9%) to a Percentage-formatted cell. + let cell = match v.as_f64() { + Some(n) => { + let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default(); + Some(Cell { value: Value::Number(n), formula: None, style }) + } + None => crate::cmd::edit::input_to_cell(&crate::cmd::edit::json_to_input(v), si, *c, &mut cx.wb)?, + }; + let sh = cx.sheet_mut(si)?; + match cell { + Some(cell) => sh.set_cell(*c, cell), + None => { + sh.remove_cell(*c); + } + } + cx.touch(si, *c); + } + Ok(()) + })?; + Ok(scenario_json(&sc)) +} + +pub(super) fn scenario_delete(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let (mut list, i) = find_scenario(&d.wb, p, "data.scenarioDelete")?; + let removed = list.remove(i); + edit(s, |cx| { + store_scenarios(&mut cx.wb, &list); + Ok(()) + })?; + Ok(json!({"deleted": removed.name})) +} + +pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "data.scenarioSummary"; + let d = s.doc()?; + let si = d.wb.active_sheet(); + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let scenarios: Vec = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect(); + if scenarios.is_empty() { + return Err(EngineError::Other("There are no scenarios on this sheet.".into())); + } + let results: Vec = match str_param(p, "resultCells").map(str::trim).filter(|t| !t.is_empty()) { + Some(t) => { + let (rsi, cells) = cell_list(&d.wb, t, CMD, 255)?; + if rsi != si { + return Err(bad(CMD, "result cells must be on the scenarios' sheet")); + } + cells + } + None => vec![], + }; + // Changing cells of every scenario, first-seen order; per scenario its values. + let mut changing: Vec = Vec::new(); + let mut per: Vec> = Vec::new(); + for sc in &scenarios { + let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?; + let mut m = HashMap::new(); + for (c, v) in cells.iter().zip(sc.values.iter()) { + if !changing.contains(c) { + changing.push(*c); + } + m.insert(*c, json_value(v, &d.wb)); + } + per.push(m); + } + let current: Vec = changing.iter().map(|c| sh.value(*c)).collect(); + let current_results: Vec = results.iter().map(|c| sh.value(*c)).collect(); + let mut trial = Trial::new(d); + let reads: Vec<(usize, CellRef)> = results.iter().map(|c| (si, *c)).collect(); + let scenario_results: Vec> = per + .iter() + .map(|m| { + let sets: Vec<(usize, CellRef, Value)> = m.iter().map(|(c, v)| (si, *c, v.clone())).collect(); + trial.run(&sets, &reads) + }) + .collect(); + let fmt_of = |c: CellRef| d.wb.styles.get(sh.style_id(c)).num_fmt.clone(); + let changing_fmts: Vec<_> = changing.iter().map(|c| fmt_of(*c)).collect(); + let result_fmts: Vec<_> = results.iter().map(|c| fmt_of(*c)).collect(); + let name = (1..) + .map(|n| if n == 1 { "Scenario Summary".to_string() } else { format!("Scenario Summary {n}") }) + .find(|n| d.wb.sheet_index(n).is_none()) + .unwrap_or_else(|| "Scenario Summary".into()); + let names: Vec = scenarios.iter().map(|x| x.name.clone()).collect(); + let new_index = edit(s, |cx| { + let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); + let mut sheet = Sheet::new(name.clone()); + let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { + sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); + }; + put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold); + put(&mut sheet, 2, 2, Value::text("Current Values:"), bold); + for (k, n) in names.iter().enumerate() { + put(&mut sheet, 2, 3 + k as u32, Value::text(n.as_str()), bold); + } + put(&mut sheet, 3, 1, Value::text("Changing Cells:"), bold); + let mut row = 4u32; + for (i, c) in changing.iter().enumerate() { + let fmt = changing_fmts.get(i).cloned().unwrap_or_default(); + let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt); + put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT); + let cur = current.get(i).cloned().unwrap_or_default(); + put(&mut sheet, row, 2, cur.clone(), st); + for (k, m) in per.iter().enumerate() { + put(&mut sheet, row, 3 + k as u32, m.get(c).cloned().unwrap_or_else(|| cur.clone()), st); + } + row += 1; + } + if !results.is_empty() { + put(&mut sheet, row, 1, Value::text("Result Cells:"), bold); + row += 1; + for (i, c) in results.iter().enumerate() { + let fmt = result_fmts.get(i).cloned().unwrap_or_default(); + let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt); + put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT); + put(&mut sheet, row, 2, current_results.get(i).cloned().unwrap_or_default(), st); + for (k, r) in scenario_results.iter().enumerate() { + put(&mut sheet, row, 3 + k as u32, r.get(i).cloned().unwrap_or_default(), st); + } + row += 1; + } + } + put( + &mut sheet, + row + 1, + 1, + Value::text("Notes: Current Values column represents values of changing cells at time Scenario Summary Report was created."), + StyleId::DEFAULT, + ); + sheet.cols.insert(1, gridcraft_model::LineInfo { size: Some(110.0), ..Default::default() }); + sheet.cols.insert(2, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); + cx.wb.sheets.push(std::sync::Arc::new(sheet)); + cx.structural = true; + Ok(cx.wb.sheets.len() - 1) + })?; + s.doc_mut()?.wb_switch_sheet(new_index); + Ok(json!({"sheet": name, "scenarios": names.len(), "changingCells": changing.len(), "resultCells": results.len()})) +}