//! Running a macro. //! //! 1. **Gate**: every step's command goes through [`macro_policy`]. Denied commands stop the macro //! before anything runs; commands that touch files must be listed in the macro's permissions; //! a macro from a file someone else made, or one that touches files, runs only once the person //! approves it (once per macro content, for the session). //! 2. **Inputs**: each declared input is bound to a range (given, its default, or the selection) //! and checked against its shape and column types, with messages that name the cells. //! 3. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run //! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it //! would write outside its inputs that already hold data are conflicts, handled by the output //! rule: ask, fail, overwrite, or shift the data down or right (and check again). A dry run //! that stops early (a file step, Solver) proves nothing about later steps, so the run then //! checks each step as it goes. //! 4. **Run**: one undo step (`Session::grouped`); an error at any step rolls everything back and //! names the step. //! //! The steps run on a small machine with an explicit frame stack, so the debugger can stop //! between any two steps and pick up again on a later frame. use std::collections::BTreeSet; use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook}; use serde::Serialize; use serde_json::{Value as Json, json}; use super::policy::{Policy, macro_policy, previewable}; use super::resolve::{self, Env, Place, ResolveError}; use crate::{EngineError, Result, Selection, Session, Trust, UiRequest}; /// The most steps one run executes (counting each pass through a Repeat). pub const MAX_EXECUTED_STEPS: u64 = 100_000; /// How long a run may take, in milliseconds. The web build has no clock; there only the step /// limit applies. pub const DEFAULT_TIME_LIMIT_MS: f64 = 60_000.0; /// The most cells an input may have for its content to be checked. pub const MAX_INPUT_CELLS: u64 = 1_000_000; /// The most ranges a footprint lists per sheet (the counts stay exact). const MAX_LISTED_RANGES: usize = 500; /// An input bound to a range: name, where, whether its first row is a header. pub type Bound = (String, Place, bool); /// Where and why a step failed. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct StepError { /// The step's path: `3`, `2.1` inside a Repeat, `4.then.1` inside an If. pub step: String, #[serde(skip_serializing_if = "Option::is_none")] pub command: Option, pub message: String, /// The step ran past the edge of the sheet. #[serde(skip_serializing_if = "std::ops::Not::not")] pub edge: bool, /// The step would write over data and the conflict rule is ask: overwriting answers it. #[serde(skip_serializing_if = "std::ops::Not::not")] pub confirm: bool, } impl StepError { fn new(step: &str, command: Option<&str>, message: impl Into) -> StepError { StepError { step: step.to_string(), command: command.map(str::to_string), message: message.into(), edge: false, confirm: false } } fn resolve(step: &str, command: Option<&str>, e: ResolveError) -> StepError { let edge = matches!(e, ResolveError::Edge(_)); StepError { edge, ..StepError::new(step, command, e.message()) } } /// `Step 3 (cell.set): …` pub fn describe(&self) -> String { match &self.command { Some(c) => format!("Step {} ({c}): {}", self.step, self.message), None => format!("Step {}: {}", self.step, self.message), } } pub fn into_engine(self, name: &str) -> EngineError { let text = format!("Macro '{name}' stopped. {} Nothing was changed.", self.describe()); if self.confirm { EngineError::Confirm(text) } else { EngineError::Other(text) } } } // ---------------------------------------------------------------- gate /// Checks every enabled step against the policy: denied commands and undeclared file access are /// errors; otherwise returns what the person must approve (empty when nothing). pub fn gate(m: &Macro, trust: Trust) -> Result> { let mut asks = BTreeSet::new(); let mut err = None; walk_enabled(&m.steps, "", &mut |path, id, params| { if err.is_some() { return; } match macro_policy(id, params, trust) { Policy::Allow => {} Policy::Ask(why) => { asks.insert(why); } Policy::NeedsPermission(what) => { if m.permissions.iter().any(|p| p == id) { asks.insert(format!("runs {id}, which {what}")); } else { err = Some(format!("Step {path} ({id}) {what}. Add {id} to the macro's permissions to allow it.")); } } Policy::Deny(why) => err = Some(format!("Step {path} ({id}) can't run in a macro: {why}.")), } }); match err { Some(e) => Err(EngineError::Other(format!("Macro '{}' can't run. {e}", m.name))), None => Ok(asks.into_iter().collect()), } } /// Calls `f(path, id, params)` for each enabled command step (skipping disabled steps' bodies). fn walk_enabled(steps: &[Step], prefix: &str, f: &mut impl FnMut(&str, &str, &Json)) { for (i, st) in steps.iter().enumerate() { if !st.enabled { continue; } let path = child_path(prefix, i); match &st.kind { StepKind::Command { id, params } => f(&path, id, params), StepKind::Repeat { body, .. } => walk_enabled(body, &path, f), StepKind::If { then, otherwise, .. } => { walk_enabled(then, &format!("{path}.then"), f); walk_enabled(otherwise, &format!("{path}.else"), f); } StepKind::Stop { .. } => {} } } } fn child_path(prefix: &str, i: usize) -> String { if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) } } /// Approves the macro for this session when `approve` is set, or asks (an /// [`EngineError::Approve`]) when it needs approval it doesn't have. pub fn approve(s: &mut Session, m: &Macro, doc: u64, asks: &[String], approve: bool) -> Result<()> { if asks.is_empty() { return Ok(()); } let key = (doc, super::fingerprint(m)); if s.macro_approvals.contains(&key) { return Ok(()); } if approve { s.macro_approvals.insert(key); return Ok(()); } Err(EngineError::Approve(format!("Run macro '{}'? It {}.", m.name, join_and(asks)))) } fn join_and(items: &[String]) -> String { match items { [] => String::new(), [one] => one.clone(), [init @ .., last] => format!("{} and {last}", init.join(", ")), } } // ---------------------------------------------------------------- inputs /// A macro ready to run: its inputs bound and checked, its output anchor resolved. #[derive(Clone, Debug)] pub struct Prepared { pub mac: Macro, pub doc: u64, pub trust: Trust, pub inputs: Vec, pub output: Option, } impl Prepared { pub fn inputs_json(&self, wb: &Workbook) -> Json { let env = Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None }; Json::Object(self.inputs.iter().map(|(n, p, _)| (n.clone(), json!(resolve::a1(*p, &env)))).collect()) } } /// Binds the macro's inputs on the active document (`given`: `{"Input": "B2:D9"}`, or a reference /// for the first input) and checks them. Errors list every problem found. pub fn prepare(s: &Session, m: &Macro, given: Option<&Json>) -> Result { let d = s.doc()?; let wb = &d.wb; let mut inputs: Vec = Vec::new(); let mut problems = Vec::new(); for (i, input) in m.inputs.iter().enumerate() { let named = given.and_then(Json::as_object).and_then(|o| o.iter().find(|(k, _)| k.eq_ignore_ascii_case(&input.name)).map(|(_, v)| v)); let text = named.and_then(Json::as_str).or_else(|| if i == 0 { given.and_then(Json::as_str) } else { None }).or(input.default_ref.as_deref()); let place = match text { Some(t) => { let env = Env { wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; match resolve::resolve_ref(t, &env) { Ok(p) => p, Err(e) => { problems.push(format!("{}: {}", input.name, e.message())); continue; } } } None if i == 0 => Place { sheet: wb.active_sheet, range: d.selection.current() }, None => { problems.push(format!("{0} needs a range: pass inputs: {{\"{0}\": \"A1:B5\"}}.", input.name)); continue; } }; let place = trim(wb, place); problems.extend(check_input(wb, input, place)); inputs.push((input.name.clone(), place, input.header)); } if !problems.is_empty() { return Err(EngineError::Other(format!("Macro '{}' can't run on these inputs. {}", m.name, problems.join(" ")))); } let output = match &m.output.anchor { Some(a) if !a.trim().is_empty() => { let env = Env { wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; Some( resolve::resolve_ref(a, &env) .map_err(|e| EngineError::Other(format!("Macro '{}': its output anchor {}", m.name, lower_first(e.message()))))?, ) } _ => None, }; Ok(Prepared { mac: m.clone(), doc: d.uid, trust: d.trust, inputs, output }) } fn lower_first(s: &str) -> String { let mut c = s.chars(); match c.next() { Some(f) => f.to_lowercase().chain(c).collect(), None => String::new(), } } /// Whole rows or columns shrink to the part of the sheet in use. fn trim(wb: &Workbook, p: Place) -> Place { if !(p.range.is_full_cols() || p.range.is_full_rows()) { return p; } let used = wb.sheet(p.sheet).and_then(Sheet::used_range); let range = used.and_then(|u| p.range.intersection(&u)).unwrap_or(RangeRef::cell(p.range.start)); Place { sheet: p.sheet, range } } /// What is wrong with `place` as `input`, one sentence each. fn check_input(wb: &Workbook, input: &gridcraft_model::NamedInput, place: Place) -> Vec { let mut out = Vec::new(); let r = place.range; let name = &input.name; let at = r.a1(); if input.kind == InputKind::Cell && !r.is_single() { out.push(format!("{name} must be a single cell; {at} is {} cells.", r.count())); return out; } let header = u32::from(input.header && r.height() > 0); let rows = r.height().saturating_sub(header); let between = |what: &str, n: u32, [min, max]: [u32; 2]| -> Option { let plural = |k: u32| if k == 1 { what.to_string() } else { format!("{what}s") }; if max > 0 && min == max && n != min { Some(format!("exactly {min} {}", plural(min))) } else if n < min { Some(format!("at least {min} {}", plural(min))) } else if max > 0 && n > max { Some(format!("at most {max} {}", plural(max))) } else { None } }; if let Some(want) = between("data row", rows, input.rows) { out.push(format!("{name} needs {want}; {at} has {rows}.")); } if let Some(want) = between("column", r.width(), input.cols) { out.push(format!("{name} needs {want}; {at} has {}.", r.width())); } if r.count() > MAX_INPUT_CELLS { out.push(format!("{name} is too large to check ({} cells; at most {}).", r.count(), MAX_INPUT_CELLS)); return out; } let Some(sh) = wb.sheet(place.sheet) else { return out }; let mut blanks = Vec::new(); for (ci, col) in (r.start.col..=r.end.col).enumerate() { let ty = input.col_types.get(ci).copied().unwrap_or_default(); let mut wrong = Vec::new(); for row in r.start.row + header..=r.end.row { let c = CellRef::new(row, col); let v = sh.value(c); if v.is_empty() || v.as_text() == Some("") { if input.non_empty { blanks.push(c); } continue; } let date = || wb.styles.get(sh.style_id(c)).num_fmt.as_str().to_string(); let ok = match ty { gridcraft_model::ColType::Any => true, gridcraft_model::ColType::Number => v.is_number(), gridcraft_model::ColType::Text => v.is_text(), gridcraft_model::ColType::Bool => matches!(v, Value::Bool(_)), gridcraft_model::ColType::Date => v.is_number() && crate::display::number_format(&date()).is_date(), }; if !ok { wrong.push((c, holds(&v, ty))); } } if !wrong.is_empty() { let head = if input.header { Some(crate::display::cell_text(wb, sh, CellRef::new(r.start.row, col))).filter(|h| !h.trim().is_empty()) } else { None }; let column = match head { Some(h) => format!("{name} column {} ({})", ci + 1, h.trim()), None => format!("{name} column {}", ci + 1), }; let listed: Vec = wrong.iter().take(3).map(|(c, what)| format!("{} holds {what}", c.a1())).collect(); let more = if wrong.len() > 3 { format!(" and {} more cells don't", wrong.len() - 3) } else { String::new() }; out.push(format!("{column} must hold {}; {}{more}.", ty.noun(), listed.join(", "))); } } if let Some(first) = blanks.first() { let more = if blanks.len() > 1 { format!(" (and {} more)", blanks.len() - 1) } else { String::new() }; out.push(format!("{name} must have no blank cells; {} is blank{more}.", first.a1())); } out } /// What a cell holds, for "C7 holds text". fn holds(v: &Value, wanted: gridcraft_model::ColType) -> &'static str { match v { Value::Number(_) if wanted == gridcraft_model::ColType::Date => "a number that isn't a date", Value::Number(_) => "a number", Value::Text(_) => "text", Value::Bool(_) => "TRUE or FALSE", Value::Error(_) => "an error", Value::Array(_) => "an array", Value::Empty => "nothing", } } // ---------------------------------------------------------------- the machine /// A stopwatch for the time limit. `Instant::now` traps on wasm, so there it measures nothing. #[derive(Clone, Copy, Debug)] pub struct Stopwatch { #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, } impl Stopwatch { pub fn start() -> Stopwatch { #[cfg(not(target_arch = "wasm32"))] #[allow(clippy::disallowed_methods)] // never on wasm (see above) let w = Stopwatch { start: std::time::Instant::now() }; #[cfg(target_arch = "wasm32")] let w = Stopwatch {}; w } /// Milliseconds since the start, or `None` where there is no clock. fn elapsed_ms(&self) -> Option { #[cfg(not(target_arch = "wasm32"))] let ms = Some(self.start.elapsed().as_secs_f64() * 1000.0); #[cfg(target_arch = "wasm32")] let ms = None; ms } } #[derive(Clone, Debug)] struct Frame { steps: Arc>, next: usize, /// Path of the step that opened this frame (`""` at the top, `3` for a Repeat's body, /// `4.then` for an If's branch). prefix: String, repeat: Option, } #[derive(Clone, Copy, Debug)] struct Loop { of: RepeatOf, over: Place, /// The current item (0-based) and how many there are. at: u32, count: u32, } impl Loop { fn item(&self) -> Place { let r = self.over.range; let range = match self.of { RepeatOf::Rows => { let row = r.start.row.saturating_add(self.at); RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)) } RepeatOf::Columns => { let col = r.start.col.saturating_add(self.at); RangeRef::new(CellRef::new(r.start.row, col), CellRef::new(r.end.row, col)) } }; Place { sheet: self.over.sheet, range } } } /// A macro run in progress: which step is next, and what has run. #[derive(Clone, Debug)] pub struct Machine { pub name: String, pub doc: u64, pub inputs: Vec, pub output: Option, permissions: Vec, trust: Trust, frames: Vec, /// Steps executed so far (each pass through a Repeat counts). pub executed: u64, clock: Stopwatch, pub time_limit_ms: f64, /// Dry run (the footprint check): steps that would touch files or can't be previewed stop /// the run early instead of running. pub dry_run: bool, /// A Stop step ended the run: its message, and whether it asked for a rollback. pub stopped: Option<(String, bool)>, /// A dry run stopped before this step (path, reason): what follows isn't in the footprint. pub partial: Option<(String, String)>, /// The last step's resolved parameters (for the debugger). pub last_params: Option, /// Set when the footprint was partial: each step is checked for writes over data as it runs. guard: Option, } /// Checks each step of a real run for writes over data the footprint couldn't see. #[derive(Clone, Debug)] struct Guard { /// The workbook when the run started, and for each sheet now, its index there (`None` for a /// sheet the run added). start: Arc, origin: Vec>, /// Where the footprint stopped: "step 2 (file.exportCsv writes a file)". partial: String, /// The conflict rule is ask. ask: bool, } impl Guard { fn new(start: Arc, partial: &Json, ask: bool) -> Guard { let text = |k: &str| partial.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); let partial = format!("step {} ({})", text("step"), text("reason")); Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, partial, ask } } /// What one step wrote over that held data when the run started: cells outside the inputs, /// sheets and objects it deleted ("B1 on Sheet1; 1 object"), or `""`. fn check(&mut self, prev: &Workbook, after: &Workbook, inputs: &[Bound]) -> String { let (pairs, _) = pair_sheets(prev, after); let mut origin = vec![None; after.sheets.len()]; let mut parts = Vec::new(); let mut objects = 0usize; for (pi, ai) in pairs { // A sheet the run added holds nothing of the person's. let Some(si) = self.origin.get(pi).copied().flatten() else { continue }; let Some(ss) = self.start.sheets.get(si) else { continue }; let Some(ai) = ai else { parts.push(format!("the sheet {}", ss.name)); continue; }; if let Some(o) = origin.get_mut(ai) { *o = Some(si); } let (Some(ps), Some(sa)) = (prev.sheets.get(pi), after.sheets.get(ai)) else { continue }; if Arc::ptr_eq(ps, sa) { continue; } let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == si && p.range.contains(c)); let mut hit: Vec = ps .cells .diff(&sa.cells) .into_iter() .filter(|&c| !in_input(c) && !recalculated_only(ps.cell(c), sa.cell(c))) .filter(|&c| ss.cell(c).is_some_and(has_content) && content_differs(ss.cell(c), sa.cell(c))) .collect(); if !hit.is_empty() { let mut listed = ranges(&mut hit); if listed.len() > 3 { listed.truncate(3); listed.push("…".into()); } parts.push(format!("{} on {}", listed.join(", "), sa.name)); } objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id) + deleted(&ss.images, &ps.images, &sa.images, |o| o.id) + deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id); } if objects > 0 { parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" })); } self.origin = origin; parts.join("; ") } } /// Objects of `prev` that `after` no longer has and that were there at the start. fn deleted(start: &[T], prev: &[T], after: &[T], id: impl Fn(&T) -> u32) -> usize { let kept: BTreeSet = after.iter().map(&id).collect(); let had: BTreeSet = start.iter().map(&id).collect(); prev.iter().map(&id).filter(|i| !kept.contains(i) && had.contains(i)).count() } impl Machine { pub fn new(p: &Prepared, dry_run: bool) -> Machine { let mut m = Machine { name: p.mac.name.clone(), doc: p.doc, inputs: p.inputs.clone(), output: p.output, permissions: p.mac.permissions.clone(), trust: p.trust, frames: vec![Frame { steps: Arc::new(p.mac.steps.clone()), next: 0, prefix: String::new(), repeat: None }], executed: 0, clock: Stopwatch::start(), time_limit_ms: DEFAULT_TIME_LIMIT_MS, dry_run, stopped: None, partial: None, last_params: None, guard: None, }; m.settle(); m } /// Moves to the next enabled step: past disabled steps, finished frames and loop passes. fn settle(&mut self) { while let Some(f) = self.frames.last_mut() { if let Some(st) = f.steps.get(f.next) { if st.enabled { return; } f.next += 1; continue; } if let Some(l) = f.repeat.as_mut() && l.at.saturating_add(1) < l.count { l.at += 1; f.next = 0; continue; } self.frames.pop(); } } pub fn is_done(&self) -> bool { self.frames.is_empty() } /// Starts the time limit again (the debugger gives each action its own). pub fn restart_clock(&mut self) { self.clock = Stopwatch::start(); } /// The current step for the debugger: its path, kind, command, parameters as they would run /// now, and the Repeat pass. pub fn current_json(&self, wb: &Workbook) -> Json { let Some((path, st)) = self.current() else { return Json::Null }; let env = self.env(wb); let mut out = json!({"step": path, "breakpoint": st.breakpoint}); if !st.note.is_empty() { out["note"] = json!(st.note); } match &st.kind { StepKind::Command { id, params } => { out["kind"] = json!("command"); out["command"] = json!(id); match resolve::resolve_params(params, &env) { Ok(p) => out["params"] = p, Err(e) => out["paramsError"] = json!(e.message()), } } StepKind::Repeat { over, of, .. } => { out["kind"] = json!("repeat"); out["over"] = json!(resolve::resolve_ref(over, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string())); out["of"] = json!(if *of == RepeatOf::Rows { "rows" } else { "columns" }); } StepKind::If { cell, .. } => { out["kind"] = json!("if"); out["cell"] = json!(resolve::resolve_ref(cell, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string())); } StepKind::Stop { message, .. } => { out["kind"] = json!("stop"); out["message"] = json!(message); } } if let Some((at, count)) = self.iteration() { out["iteration"] = json!({"at": at, "of": count}); } out } /// The step that runs next, with its path. pub fn current(&self) -> Option<(String, &Step)> { let f = self.frames.last()?; f.steps.get(f.next).map(|st| (child_path(&f.prefix, f.next), st)) } /// The innermost Repeat's pass (1-based) and count. pub fn iteration(&self) -> Option<(u32, u32)> { self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.at + 1, l.count)) } fn item(&self) -> Option<(RepeatOf, Place)> { self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.of, l.item())) } /// Runs every remaining step. pub fn run_to_end(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { while !self.is_done() { self.step(s)?; } Ok(()) } /// Runs the current step and moves to the next one. pub fn step(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { let Some((path, st)) = self.current().map(|(p, st)| (p, st.clone())) else { return Ok(()) }; if let Some(f) = self.frames.last_mut() { f.next += 1; } let r = self.exec(s, &path, &st); if r.is_err() { self.frames.clear(); } self.settle(); r } fn exec(&mut self, s: &mut Session, path: &str, st: &Step) -> std::result::Result<(), StepError> { self.executed += 1; let at = |msg: String| StepError::new(path, None, msg); if self.executed > MAX_EXECUTED_STEPS { return Err(at(format!("The macro ran more than {MAX_EXECUTED_STEPS} steps and was stopped."))); } if self.clock.elapsed_ms().is_some_and(|ms| ms > self.time_limit_ms) { return Err(at(format!("The macro ran longer than {} seconds and was stopped.", (self.time_limit_ms / 1000.0).round()))); } // Steps run on the macro's workbook, whichever one is in front. let Some(i) = s.docs.iter().position(|d| d.uid == self.doc) else { return Err(at("The workbook was closed.".into())) }; s.set_active(i); match &st.kind { StepKind::Command { id, params } => self.command(s, path, id, params), StepKind::Repeat { over, of, max, body } => { let place = { let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; let env = self.env(wb); let p = resolve::resolve_ref(over, &env).map_err(|e| StepError::resolve(path, None, e))?; trim(wb, p) }; let items = match of { RepeatOf::Rows => place.range.height(), RepeatOf::Columns => place.range.width(), }; let cap = (*max).min(MAX_EXECUTED_STEPS as u32); if items > cap { let what = if *of == RepeatOf::Rows { "rows" } else { "columns" }; return Err(at(format!("Repeat over {} has {items} {what}, more than its limit of {cap}.", place.range.a1()))); } if items > 0 && body.iter().any(|b| b.enabled) { let repeat = Some(Loop { of: *of, over: place, at: 0, count: items }); self.frames.push(Frame { steps: Arc::new(body.clone()), next: 0, prefix: path.to_string(), repeat }); } Ok(()) } StepKind::If { cell, op, value, then, otherwise } => { let v = { let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; let env = self.env(wb); let p = resolve::resolve_ref(cell, &env).map_err(|e| StepError::resolve(path, None, e))?; if !p.range.is_single() { return Err(at(format!("If looks at one cell; {} is {} cells.", p.range.a1(), p.range.count()))); } wb.sheet(p.sheet).map(|sh| sh.value(p.range.start)).unwrap_or_default() }; let (branch, name) = if compare(&v, *op, value) { (then, "then") } else { (otherwise, "else") }; if branch.iter().any(|b| b.enabled) { self.frames.push(Frame { steps: Arc::new(branch.clone()), next: 0, prefix: format!("{path}.{name}"), repeat: None }); } Ok(()) } StepKind::Stop { message, rollback } => { self.stopped = Some((message.clone(), *rollback)); self.frames.clear(); Ok(()) } } } fn env<'a>(&'a self, wb: &'a Workbook) -> Env<'a> { Env { wb, active_sheet: wb.active_sheet, inputs: &self.inputs, output: self.output, item: self.item() } } fn command(&mut self, s: &mut Session, path: &str, id: &str, params: &Json) -> std::result::Result<(), StepError> { let fail = |msg: String| StepError::new(path, Some(id), msg); // The policy sees the parameters the command will get, after tokens became A1 text. let resolved = { let wb = &s.doc().map_err(|e| fail(e.to_string()))?.wb; resolve::resolve_params(params, &self.env(wb)).map_err(|e| StepError::resolve(path, Some(id), e))? }; match (macro_policy(id, params, self.trust), macro_policy(id, &resolved, self.trust)) { (_, Policy::Deny(why)) | (Policy::Deny(why), _) => return Err(fail(format!("{id} can't run in a macro: {why}."))), (written, Policy::NeedsPermission(what)) => { if !self.permissions.iter().any(|p| p == id) { return Err(fail(format!("{id} {what}. Add it to the macro's permissions to allow it."))); } // The gate asked the person about this step only if it saw the file access. if !matches!(written, Policy::NeedsPermission(_)) { return Err(fail(format!("{id} {what} only once its parameters are filled in, so it wasn't approved."))); } if self.dry_run { self.partial = Some((path.to_string(), format!("{id} {what}"))); self.frames.clear(); return Ok(()); } } (_, Policy::Allow | Policy::Ask(_)) => {} } if self.dry_run && !previewable(id) { self.partial = Some((path.to_string(), format!("{id} can't be previewed"))); self.frames.clear(); return Ok(()); } self.last_params = Some(resolved.clone()); let prev = self.guard.as_ref().and_then(|_| s.docs.iter().find(|d| d.uid == self.doc)).map(|d| d.wb.clone()); let queued = s.ui_requests.len(); let r = s.execute(id, resolved); // Macros never open dialogs or web pages, and never start editing a cell; messages for // the person stay queued. let mut blocked = None; let asked: Vec = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect(); for q in asked { match q { UiRequest::Dialog(name, _) => blocked = Some(format!("{id} would open the {name} dialog; give the step the parameters it needs.")), UiRequest::OpenUrl(_) => blocked = Some(format!("{id} would open a web page, which macros don't do.")), UiRequest::EditCell(_) | UiRequest::InsertName(_) => {} m @ UiRequest::Message(_) => s.ui_requests.push(m), } } r.map_err(|e| fail(e.to_string()))?; if let Some(b) = blocked { return Err(fail(b)); } if let (Some(g), Some(prev)) = (self.guard.as_mut(), prev) { let after = s.docs.iter().find(|d| d.uid == self.doc).map(|d| d.wb.clone()).ok_or_else(|| fail("The workbook was closed.".into()))?; let what = g.check(&prev, &after, &self.inputs); if !what.is_empty() { let msg = format!("It would write over data ({what}); the check before the run stopped at {} and couldn't see this.", g.partial); return Err(StepError { confirm: g.ask, ..fail(msg) }); } } Ok(()) } } /// If's comparison of a cell's value with a step's `value`, the way Excel's `=`, `<`… compare. fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { let empty = v.is_empty() || v.as_text() == Some(""); let want_text = || match want { Json::String(t) => t.clone(), Json::Null => String::new(), other => other.to_string(), }; let ord = || -> Option { let w = match want { Json::Null => Value::Empty, Json::Bool(b) => Value::Bool(*b), Json::Number(n) => Value::number(n.as_f64()?), // "5" compares as a number with a number in the cell. Json::String(t) => match (v, t.trim().parse::()) { (Value::Number(_), Ok(n)) => Value::number(n), _ => Value::text(t.as_str()), }, _ => return None, }; match (v, &w) { (Value::Error(e), Value::Text(t)) => { Some(if e.as_str().eq_ignore_ascii_case(t.trim()) { std::cmp::Ordering::Equal } else { std::cmp::Ordering::Less }) } (Value::Error(_), _) | (_, Value::Error(_)) | (Value::Array(_), _) => None, _ => Some(gridcraft_core::value::compare(v, &w)), } }; use std::cmp::Ordering::*; match op { CompareOp::Empty => empty, CompareOp::NotEmpty => !empty, CompareOp::Contains => v.display().to_lowercase().contains(&want_text().to_lowercase()), CompareOp::Eq => ord() == Some(Equal), CompareOp::Ne => ord() != Some(Equal), CompareOp::Gt => ord() == Some(Greater), CompareOp::Ge => matches!(ord(), Some(Greater | Equal)), CompareOp::Lt => ord() == Some(Less), CompareOp::Le => matches!(ord(), Some(Less | Equal)), } } // ---------------------------------------------------------------- footprint /// What a macro would change, from a dry run on the real workbook. #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct Footprint { /// Steps executed by the end of the dry run that made this footprint (the run's one budget). #[serde(skip)] pub executed: u64, /// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value /// may be missing from `cells`: a shift cannot trust the block. #[serde(skip_serializing_if = "std::ops::Not::not")] pub log_full: bool, pub sheets: Vec, pub sheets_added: Vec, pub sheets_removed: Vec, pub objects_added: usize, pub objects_changed: usize, pub objects_removed: usize, /// The dry run stopped early before this step; later steps aren't in the footprint. #[serde(skip_serializing_if = "Option::is_none")] pub partial: Option, /// The step the run would fail at. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, /// A Stop step's message. #[serde(skip_serializing_if = "Option::is_none")] pub stopped: Option, } /// The cells a macro would write on one sheet, outside its inputs. #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct SheetFootprint { pub sheet: String, #[serde(skip)] pub index: usize, /// The written cells as ranges (at most 500 listed), and how many cells. pub cells: Vec, pub changed: usize, /// Written cells that hold data now. pub conflicts: Vec, pub conflict_count: usize, /// The block that holds every written cell. #[serde(skip_serializing_if = "Option::is_none")] pub bounds: Option, #[serde(skip)] pub bounds_range: Option, } impl Footprint { /// Cells that hold data and would be written, plus sheets and objects that would go. pub fn conflict_count(&self) -> usize { self.sheets.iter().map(|s| s.conflict_count).sum::() + self.sheets_removed.len() + self.objects_removed } /// "C2:C9 on Sheet1, the sheet Old, 2 objects". pub fn describe_conflicts(&self) -> String { let mut parts: Vec = self .sheets .iter() .filter(|s| s.conflict_count > 0) .map(|s| { let mut listed = s.conflicts.iter().take(3).cloned().collect::>().join(", "); if s.conflicts.len() > 3 { listed.push_str(", …"); } format!("{listed} on {}", s.sheet) }) .collect(); parts.extend(self.sheets_removed.iter().map(|n| format!("the sheet {n}"))); if self.objects_removed > 0 { parts.push(format!("{} object{}", self.objects_removed, if self.objects_removed == 1 { "" } else { "s" })); } parts.join("; ") } } /// Session state a dry run may touch, put back afterwards. The workbook, its selection and its /// history are put back by the dry run's undo group. struct Saved { active: usize, docs: Vec, /// The workbook as last saved: `sheet.activate` and the view commands move it to the /// current workbook on a clean document, and the rollback puts the workbook back but not this. saved: Option>, sheet_selections: Vec, unlocked_ranges: Vec<(String, String)>, trust: Trust, clipboard: Option, prefs: crate::Prefs, mode: crate::Mode, /// The journal's length and how many entries it had dropped from its front; a dry run that /// pushes past the journal's limits drops more, which a length alone cannot see. journal: usize, journal_dropped: u64, recording: Option, ui_requests: usize, format_painter: Option<(Arc, usize, RangeRef, bool)>, last_find: Option, hide_ruler: bool, hidden_windows: Vec, draw: (String, String, f32), comments_pane: bool, show_all_notes: bool, } impl Saved { fn take(s: &mut Session, doc: u64) -> Saved { let d = s.docs.iter().find(|d| d.uid == doc); Saved { active: s.active, docs: s.docs.iter().map(|d| d.uid).collect(), saved: d.map(|d| d.saved.clone()), sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), trust: d.map_or(Trust::Local, |d| d.trust), clipboard: s.clipboard.clone(), prefs: s.prefs.clone(), mode: s.mode, journal: s.journal.len(), journal_dropped: s.journal_dropped(), recording: s.recording, ui_requests: s.ui_requests.len(), format_painter: s.format_painter.clone(), last_find: s.last_find.clone(), hide_ruler: s.hide_ruler, hidden_windows: s.hidden_windows.clone(), draw: (s.draw_tool.clone(), s.draw_color.clone(), s.draw_width), comments_pane: s.comments_pane, show_all_notes: s.show_all_notes, } } fn restore(self, s: &mut Session, doc: u64) { s.docs.retain(|d| self.docs.contains(&d.uid)); if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { if let Some(saved) = self.saved { d.saved = saved; } d.sheet_selections = self.sheet_selections; d.unlocked_ranges = self.unlocked_ranges; d.trust = self.trust; } s.active = self.active.min(s.docs.len().saturating_sub(1)); s.clipboard = self.clipboard; s.prefs = self.prefs; s.mode = self.mode; // Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good, // and the recording's start moves back with them. let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX); s.truncate_journal(self.journal.saturating_sub(dropped)); s.recording = self.recording.map(|r| r.saturating_sub(dropped)); s.ui_requests.truncate(self.ui_requests); s.format_painter = self.format_painter; s.last_find = self.last_find; s.hide_ruler = self.hide_ruler; s.hidden_windows = self.hidden_windows; (s.draw_tool, s.draw_color, s.draw_width) = self.draw; s.comments_pane = self.comments_pane; s.show_all_notes = self.show_all_notes; } } /// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would /// change. The dry run stops once `time_limit_ms` has passed on `clock`. pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result { let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; s.set_active(i); let saved = Saved::take(s, p.doc); let before = s.doc()?.wb.clone(); let token = s.group_begin("Check Macro")?; if let Some(d) = s.docs.iter_mut().find(|d| d.uid == p.doc) { d.touched = Some(Vec::new()); } let mut m = Machine::new(p, true); (m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed); let outcome = m.run_to_end(s); let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), None => (before.clone(), Vec::new()), }; let rolled = s.group_rollback(token); saved.restore(s, p.doc); rolled?; let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; let mut fp = diff(&before, &after, &p.inputs, &touched); fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT; fp.executed = m.executed; fp.error = outcome.err(); fp.stopped = m.stopped.map(|(msg, _)| msg); fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); Ok(fp) } /// Compares the workbook before and after a dry run. `touched` adds the cells commands wrote /// without changing them (a formula written again), so a shift moves everything the macro writes. fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { let mut fp = Footprint::default(); let (pairs, added) = pair_sheets(before, after); fp.sheets_added = added.into_iter().filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); for (bi, ai) in pairs { let Some(bs) = before.sheets.get(bi) else { continue }; let Some(ai) = ai else { fp.sheets_removed.push(bs.name.clone()); continue; }; let Some(after_sheet) = after.sheets.get(ai) else { continue }; let rewritten: BTreeSet = touched.iter().filter(|(n, _)| *n == after_sheet.name).map(|(_, c)| *c).collect(); if Arc::ptr_eq(bs, after_sheet) && rewritten.is_empty() { continue; } let (added, changed, removed) = objects(bs, after_sheet); fp.objects_added += added; fp.objects_changed += changed; fp.objects_removed += removed; let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == bi && p.range.contains(c)); let mut written = Vec::new(); let mut conflicts = Vec::new(); let mut cells: BTreeSet = bs.cells.diff(&after_sheet.cells).into_iter().collect(); cells.extend(rewritten.iter().copied()); for c in cells { if in_input(c) { continue; } let (b, a) = (bs.cell(c), after_sheet.cell(c)); if recalculated_only(b, a) && !rewritten.contains(&c) { continue; } written.push(c); if b.is_some_and(has_content) && content_differs(b, a) { conflicts.push(c); } } let Some(first) = written.first() else { continue }; let bounds = written.iter().fold(RangeRef::cell(*first), |r, c| r.union(&RangeRef::cell(*c))); fp.sheets.push(SheetFootprint { sheet: bs.name.clone(), index: bi, changed: written.len(), conflict_count: conflicts.len(), cells: ranges(&mut written), conflicts: ranges(&mut conflicts), bounds: Some(bounds.a1()), bounds_range: Some(bounds), }); } fp } /// Each sheet of `before` with its index in `after` (`None` when deleted), and the sheets of /// `after` that are new. Sheets are matched by name; what's left is matched in order (a renamed /// sheet). fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, Option)>, Vec) { let mut pairs: Vec<(usize, Option)> = before.sheets.iter().enumerate().map(|(bi, sh)| (bi, after.sheet_index(&sh.name))).collect(); let matched: BTreeSet = pairs.iter().filter_map(|(_, a)| *a).collect(); let mut spare = (0..after.sheets.len()).filter(|a| !matched.contains(a)); for (_, a) in pairs.iter_mut().filter(|(_, a)| a.is_none()) { *a = spare.next(); } (pairs, spare.collect()) } /// Only a formula's cached value changed (a recalculation, not a write). fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool { match (b, a) { (Some(b), Some(a)) => match (&b.formula, &a.formula) { (Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style, _ => false, }, _ => false, } } fn has_content(c: &Cell) -> bool { c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some("")) } fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { match (b, a) { (Some(b), Some(a)) => match (&b.formula, &a.formula) { (Some(x), Some(y)) => x.text != y.text, (None, None) => b.value != a.value, _ => true, }, (Some(b), None) => has_content(b), _ => false, } } /// Objects added, changed and removed between two versions of a sheet. fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { fn count(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) { let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count(); let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count(); let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); (added, changed, removed) } let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)]; parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2)) } /// Cells as A1 ranges: runs along each row, then runs with the same columns on following rows. pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { cells.sort_unstable_by_key(|c| (c.row, c.col)); let mut runs: Vec = Vec::new(); for c in cells.iter() { match runs.last_mut() { Some(r) if r.end.row == c.row && r.end.col.checked_add(1) == Some(c.col) => r.end.col = c.col, _ => runs.push(RangeRef::cell(*c)), } } let mut merged: Vec = Vec::new(); for r in runs { // The block above with the same columns that ends on the row before. match merged .iter_mut() .rev() .take(64) .find(|m| m.start.col == r.start.col && m.end.col == r.end.col && m.end.row.checked_add(1) == Some(r.start.row)) { Some(m) => m.end.row = r.end.row, None => merged.push(r), } } merged.iter().take(MAX_LISTED_RANGES).map(RangeRef::a1).collect() } /// Inserts blank cells where the footprint lands (moving what is there down or right), so the /// macro writes into empty cells. The block is every written cell's, so a row the macro writes /// stays together. A shift that would move one of the macro's inputs is refused: the inputs were /// checked and bound where they are, and only the block's columns (or rows) would move. fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> { if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); } if fp.log_full { return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into())); } let wb = s.doc()?.wb.clone(); let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; let mut moves = Vec::new(); for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { let Some(block) = sh.bounds_range else { continue }; // What the insertion moves: the block's columns from its top row down, or its rows from // its left column rightwards. let moved = |r: &RangeRef| { if down { r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row } else { r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col } }; // The output is where the macro writes, so moving what is there is the point; the second // footprint catches data the shift moved into it. if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) { return Err(EngineError::Other(format!( "Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.", resolve::a1(pl, &env) ))); } moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env)); } for range in moves { s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; } Ok(()) } // ---------------------------------------------------------------- run /// A macro that passed its checks and is about to run. pub struct Begun { pub mac: Macro, pub prepared: Prepared, pub rule: OnConflict, pub time_limit_ms: f64, pub inputs: Json, } /// The checks before a run, on the active workbook: the macro exists, the policy allows it, the /// person approved it if it needs that, and its inputs are right. pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { let d = s.doc()?; let (doc, trust) = (d.uid, d.trust); let m = super::find(&d.wb, name) .and_then(|i| d.wb.macros.get(i)) .cloned() .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; let asks = gate(&m, trust)?; approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?; let prepared = prepare(s, &m, p.get("inputs"))?; let rule = conflict_rule(p, &m)?; let time_limit_ms = time_limit(p); let inputs = prepared.inputs_json(&s.doc()?.wb); Ok(Begun { mac: m, prepared, rule, time_limit_ms, inputs }) } /// The footprint check and the output rule, inside the run's undo group: an error when the macro /// would fail or write over data it may not, after shifting the data if the rule says so. Returns /// the machine for the run and whether it shifted. The dry runs count towards the run's time /// limit. /// /// A partial footprint proves nothing about the steps it didn't reach, so then the machine /// checks each step as it runs and stops the run at one that writes over data. pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { let (m, rule) = (&b.mac, b.rule); let mut machine = Machine::new(&b.prepared, false); machine.time_limit_ms = b.time_limit_ms; if rule == OnConflict::Overwrite { return Ok((machine, false)); } // The dry runs and the run share one step budget as they share one clock. let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; machine.executed = fp.executed; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } let mut shifted = false; if fp.conflict_count() > 0 { let what = fp.describe_conflicts(); match rule { OnConflict::Ask => { return Err(EngineError::Confirm(format!( "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", m.name ))); } OnConflict::Fail | OnConflict::Overwrite => { return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))); } OnConflict::ShiftDown | OnConflict::ShiftRight => { make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?; fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; machine.executed = fp.executed; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } if fp.conflict_count() > 0 { return Err(EngineError::Other(format!( "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", m.name, fp.describe_conflicts() ))); } shifted = true; } } } if let Some(p) = &fp.partial { let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone(); machine.guard = Some(Guard::new(start, p, rule == OnConflict::Ask)); } Ok((machine, shifted)) } /// Runs macro `name` on the active workbook: `{inputs?, onConflict?, overwrite?, approve?, /// timeLimit?}` (see `macro.run`). pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { let b = begin(s, name, p)?; let label = format!("Run Macro: {}", b.mac.name); s.grouped(&label, |s| { let (mut machine, shifted) = make_way(s, &b)?; let name = &b.mac.name; // `ran` counts the run's own steps; the budget also holds the dry runs'. let before_run = machine.executed; machine.run_to_end(s).map_err(|e| e.into_engine(name))?; match machine.stopped { Some((msg, true)) => Err(EngineError::Other(format!("Macro '{name}' stopped: {msg} Nothing was changed."))), stopped => { let mut out = json!({"macro": name, "ran": machine.executed.saturating_sub(before_run), "inputs": b.inputs}); if let Some((msg, _)) = stopped { out["stopped"] = json!(msg); } if shifted { out["shifted"] = json!(b.rule.name()); } Ok(out) } } }) } /// The run's `timeLimit` (seconds) in milliseconds, or the default. fn time_limit(p: &Json) -> f64 { p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS) } /// The conflict rule for this run: `onConflict`, else the macro's own. `overwrite: true` is the /// answer to the question, so it turns `ask` into `overwrite` whichever way `ask` was given. fn conflict_rule(p: &Json, m: &Macro) -> Result { let rule = match p.get("onConflict").and_then(Json::as_str) { Some(t) => OnConflict::parse(t) .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'.")))?, None => m.output.on_conflict, }; if rule == OnConflict::Ask && p.get("overwrite").and_then(Json::as_bool) == Some(true) { return Ok(OnConflict::Overwrite); } Ok(rule) } /// `macro.check`: what running the macro would do, without changing anything. pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { let d = s.doc()?; let (doc, trust) = (d.uid, d.trust); let m = super::find(&d.wb, name) .and_then(|i| d.wb.macros.get(i)) .cloned() .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; let mut out = json!({"macro": m.name, "ok": false, "onConflict": m.output.on_conflict.name()}); let asks = match gate(&m, trust) { Ok(a) => a, Err(e) => { out["errors"] = json!([e.to_string()]); return Ok(out); } }; let approved = s.macro_approvals.contains(&(doc, super::fingerprint(&m))); if !asks.is_empty() && !approved { // Nothing from an unapproved macro runs, not even in a dry run. out["needsApproval"] = json!(asks); } let prepared = match prepare(s, &m, p.get("inputs")) { Ok(pr) => pr, Err(e) => { out["errors"] = json!([e.to_string()]); return Ok(out); } }; out["inputs"] = prepared.inputs_json(&s.doc()?.wb); if let Some(o) = prepared.output { let wb = &s.doc()?.wb; out["output"] = json!(resolve::a1(o, &Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None })); } if !asks.is_empty() && !approved { return Ok(out); } let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p), 0)?; out["ok"] = json!(fp.error.is_none()); out["conflicts"] = json!(fp.conflict_count()); if let Some(e) = &fp.error { out["errors"] = json!([e.describe()]); } out["footprint"] = serde_json::to_value(&fp).unwrap_or(Json::Null); Ok(out) }