truecalc_workbook/mutate.rs
1//! Workbook-level mutation API (plan item 3.4): cell `set` / `get` / `clear`
2//! and named-range CRUD, all preserving value-object semantics — no interior
3//! threads, no I/O, no hidden callbacks (issue #536, scope ADR Accepted).
4//!
5//! These methods build on P3.1's grid primitives ([`Worksheet::get`] /
6//! [`Worksheet::set`] / [`Worksheet::clear`]) and sheet management; they add the
7//! *workbook-scoped* invariants P3.1 deliberately deferred to the mutation API:
8//!
9//! - **Eager limit enforcement.** The per-mutation caps of scope ADR Decision 5
10//! are checked at the point of mutation — the workbook cell count
11//! ([`MAX_CELLS_PER_WORKBOOK`](crate::limits::MAX_CELLS_PER_WORKBOOK)), a
12//! `text` value's length, an `array` value's element count, a formula's
13//! length, and the named-range count — so a mutation that would breach a cap
14//! fails immediately rather than at serialize time. The serialized **byte**
15//! cap is the lone exception: it stays a serialize-time check
16//! ([`to_json`](crate::Workbook::to_json)), since recomputing canonical byte
17//! length per edit is O(document) per mutation (Decision 5, `limits` docs).
18//! - **No dangling named ranges.** Named-range CRUD validates the target sheet
19//! exists at definition time (schema spec §7), the mirror of `from_json`'s
20//! dangling-ref rejection. (Sheet *removal* can still orphan a name; that
21//! stays re-checked at the serialization boundary, as P3.1 documents on
22//! [`remove_sheet`](crate::Workbook::remove_sheet).)
23//!
24//! Formula cells are stored verbatim with a [`Value::Empty`] result — the
25//! "not yet evaluated" state. Recalc (P3.3) is out of scope here; `set` only
26//! validates a formula's *syntax* against the workbook's locked engine and
27//! leaves the cell awaiting a later recalc.
28
29use icu_casemap::CaseMapperBorrowed;
30use truecalc_core::Engine;
31
32use crate::address::Address;
33use crate::casefold::simple_fold;
34use crate::cell::Cell;
35use crate::error::WorkbookError;
36use crate::limits;
37use crate::named_range::NamedRange;
38use crate::named_ref;
39use crate::spill::spill_rect;
40use crate::value::Value;
41use crate::workbook::Workbook;
42
43/// What a [`Workbook::set`] writes into a cell: a literal value or a formula.
44///
45/// This is the parsed shape the caller hands `set`; the surface layers (WASM,
46/// MCP, REST) decide how to turn a user's raw string into one of these (a
47/// leading `=` selects [`CellInput::Formula`], everything else is a literal).
48/// Keeping the discriminator explicit here avoids guessing inside the value
49/// object and keeps `set` engine-agnostic about *input* syntax while still
50/// validating formula syntax against the locked engine.
51#[derive(Debug, Clone, PartialEq)]
52pub enum CellInput {
53 /// A literal cell value (schema spec §4 — a `value`-only cell). Must not be
54 /// [`Value::Empty`]: an empty literal is byte-indistinguishable from an
55 /// absent cell, so it is rejected (clear the cell instead).
56 Literal(Value),
57 /// A formula, stored verbatim including the leading `=`. Its syntax is
58 /// validated against the workbook's locked engine on `set`; its value is
59 /// [`Value::Empty`] until the next recalc (P3.3).
60 Formula(String),
61}
62
63/// The effective value at an address after spill resolution (schema spec §5),
64/// returned by [`Workbook::resolved`].
65///
66/// For an authored cell, `anchor` is `None` and `value` is the cell's stored
67/// value. For a *spilled* cell, `value` is the reconstructed array element and
68/// `anchor` is the address of the spilling anchor on the same sheet (the
69/// runtime `spilledFrom` marker of §5 — a derived view, never serialized).
70#[derive(Debug, Clone, PartialEq)]
71pub struct Resolved {
72 /// The effective value at the queried address.
73 pub value: Value,
74 /// The spill anchor, if the queried cell is spilled; `None` for an authored
75 /// cell.
76 pub anchor: Option<Address>,
77}
78
79impl Workbook {
80 /// Writes `input` at `addr` on the sheet named `sheet` (case-insensitive),
81 /// returning the cell previously there (if any).
82 ///
83 /// A literal is stored as a `value`-only cell; a formula is stored verbatim
84 /// (with its leading `=`) carrying a [`Value::Empty`] result until the next
85 /// recalc — `set` validates only the formula's *syntax* against the
86 /// workbook's locked engine, never evaluating it (recalc is P3.3).
87 ///
88 /// Enforces, eagerly, the per-mutation caps of scope ADR Decision 5: a
89 /// formula's length, a `text`/`array` value's size, and — when the write
90 /// introduces a *new* populated cell — the per-workbook cell count.
91 ///
92 /// Errors if the sheet does not exist, the input is an empty literal
93 /// (schema spec §4 — clear instead), the formula is syntactically invalid
94 /// for the locked engine, or any cap would be exceeded.
95 pub fn set(
96 &mut self,
97 sheet: &str,
98 addr: Address,
99 input: CellInput,
100 ) -> Result<Option<Cell>, WorkbookError> {
101 // Build (and validate) the cell before touching the grid, so a rejected
102 // input leaves the workbook untouched (value-object atomicity).
103 let cell = match input {
104 CellInput::Literal(value) => {
105 if matches!(value, Value::Empty) {
106 return Err(WorkbookError::EmptyLiteral);
107 }
108 check_value_limits(&value)?;
109 Cell::literal(value)?
110 }
111 CellInput::Formula(formula) => {
112 check_formula_limit(&formula)?;
113 self.validate_formula(&formula)?;
114 // Unevaluated until recalc (P3.3): empty result, formula verbatim.
115 Cell::with_formula(formula, Value::Empty)
116 }
117 };
118
119 let idx = self.sheet_index(sheet).ok_or_else(|| {
120 WorkbookError::Mutation(format!("cannot set cell: no sheet named {sheet:?}"))
121 })?;
122
123 // Eager workbook cell-count cap: only a *new* cell grows the count.
124 let introduces_new_cell = !self.sheets()[idx].contains(addr);
125 if introduces_new_cell && self.total_cells() >= limits::MAX_CELLS_PER_WORKBOOK {
126 return Err(WorkbookError::Mutation(format!(
127 "cannot set cell: workbook already holds {} populated cells, the limit \
128 (scope ADR Decision 5)",
129 limits::MAX_CELLS_PER_WORKBOOK
130 )));
131 }
132
133 Ok(self.sheets_mut()[idx].set(addr, cell))
134 }
135
136 /// The **authored** cell at `addr` on the sheet named `sheet`
137 /// (case-insensitive), or `None` if no cell is authored there.
138 ///
139 /// This returns only authored cells — a literal or a formula physically
140 /// present in `cells`. A *spilled* cell (one materialized by a spill anchor,
141 /// schema spec §5) is **not** authored and has no [`Cell`] to borrow, so
142 /// `get` returns `None` for it; use [`resolved`](Self::resolved) to read the
143 /// effective value at any address (authored *or* spilled) and learn the
144 /// spill anchor. Keeping `get` authored-only preserves the structural
145 /// distinguishability rule of §5 (a cell is authored iff it has an entry).
146 pub fn get(&self, sheet: &str, addr: Address) -> Option<&Cell> {
147 self.sheet(sheet).and_then(|ws| ws.get(addr))
148 }
149
150 /// The **effective** value at `addr` on the sheet named `sheet`
151 /// (case-insensitive), resolving through array spills (schema spec §5).
152 ///
153 /// Returns `None` only if `addr` is genuinely empty — neither authored nor
154 /// covered by a spill. Otherwise the returned [`Resolved`] carries the
155 /// value and, for a spilled cell, the `anchor` it spilled from (the
156 /// runtime `spilledFrom` view of §5 — never serialized). For an authored
157 /// cell `anchor` is `None`. A blocked-spill anchor is just an authored
158 /// formula cell whose value is the blocked-spill error, so it resolves as
159 /// an ordinary authored cell with no `anchor`.
160 ///
161 /// Resolution reads the **stored** grid (the last recalc's results): a
162 /// spilling anchor stores its full array (§6), and this reconstructs the
163 /// spilled element by the five-line rule of §5. It does not recalc.
164 pub fn resolved(&self, sheet: &str, addr: Address) -> Option<Resolved> {
165 let ws = self.sheet(sheet)?;
166 // Authored cell wins (a spill never overlaps an authored cell — §5).
167 if let Some(cell) = ws.get(addr) {
168 return Some(Resolved {
169 value: cell.value().clone(),
170 anchor: None,
171 });
172 }
173 // Otherwise look for an anchor whose stored array spills onto `addr`.
174 for (anchor_addr, cell) in ws.iter() {
175 let Value::Array(rows) = cell.value() else {
176 continue;
177 };
178 let nrows = rows.len();
179 let ncols = rows.first().map_or(0, Vec::len);
180 let Some(rect) = spill_rect(anchor_addr, nrows, ncols) else {
181 continue;
182 };
183 if anchor_addr == addr {
184 continue; // the anchor is authored, handled above
185 }
186 if let Some((i, j)) = rect.offset_of(addr) {
187 let value = rows[i][j].clone();
188 return Some(Resolved {
189 value,
190 anchor: Some(anchor_addr),
191 });
192 }
193 }
194 None
195 }
196
197 /// The spill anchor that materializes `addr` on the sheet named `sheet`
198 /// (case-insensitive), or `None` if `addr` is authored or empty (schema
199 /// spec §5). Convenience over [`resolved`](Self::resolved) when only the
200 /// anchor identity (the `spilledFrom` view) is needed.
201 pub fn spill_anchor(&self, sheet: &str, addr: Address) -> Option<Address> {
202 self.resolved(sheet, addr).and_then(|r| r.anchor)
203 }
204
205 /// Removes the cell at `addr` on the sheet named `sheet` (case-insensitive),
206 /// returning it if present. Clearing is *removing the entry*, never writing
207 /// an empty value (schema spec §4). Returns `None` if the sheet or cell is
208 /// absent.
209 pub fn clear(&mut self, sheet: &str, addr: Address) -> Option<Cell> {
210 self.sheet_mut(sheet).and_then(|ws| ws.clear(addr))
211 }
212
213 /// The total number of populated cells across every sheet — the quantity
214 /// the per-workbook cell cap (scope ADR Decision 5) bounds.
215 pub fn total_cells(&self) -> usize {
216 self.sheets().iter().map(|s| s.len()).sum()
217 }
218
219 /// Defines a new workbook-scoped named range `name` pointing at the
220 /// canonical reference `r` (`Sheet!A1` / `Sheet!A1:B2`), returning the
221 /// stored [`NamedRange`].
222 ///
223 /// Validates everything `from_json` checks for a name (schema spec §7): the
224 /// name's shape, the `ref`'s canonical form, that the target sheet exists
225 /// (no dangling ref), that the name does not already exist
226 /// (case-insensitively), and that the named-range cap (Decision 5) is not
227 /// exceeded. To replace an existing name use [`redefine_name`](Self::redefine_name).
228 pub fn define_name(&mut self, name: &str, r: &str) -> Result<&NamedRange, WorkbookError> {
229 self.validate_name_definition(name, r)?;
230 if self.names().len() >= limits::MAX_NAMED_RANGES {
231 return Err(WorkbookError::Mutation(format!(
232 "cannot define named range: workbook already has {} named ranges, the limit \
233 (scope ADR Decision 5)",
234 limits::MAX_NAMED_RANGES
235 )));
236 }
237 if let Some(existing) = self.name_index(name) {
238 return Err(WorkbookError::Mutation(format!(
239 "cannot define named range {name:?}: it collides with the existing name {:?} \
240 under simple case folding (schema spec §7)",
241 self.names()[existing].name
242 )));
243 }
244 self.names_mut().push(NamedRange {
245 name: name.to_owned(),
246 r#ref: r.to_owned(),
247 });
248 // Borrow the freshly pushed entry (it is last in declaration order;
249 // serialization re-sorts by name independently, §8.7).
250 Ok(self.names().last().expect("just pushed a named range"))
251 }
252
253 /// Redefines the existing named range `name` (case-insensitive) to point at
254 /// the canonical reference `r`, returning the updated [`NamedRange`]. The
255 /// name's identity and original casing are preserved; only the `ref`
256 /// changes.
257 ///
258 /// Validates the `ref` exactly as [`define_name`](Self::define_name) does,
259 /// including that the target sheet exists (no dangling ref). Errors if no
260 /// name currently matches `name` (case-insensitively) or if the `ref` is
261 /// not a valid canonical reference to an existing sheet.
262 pub fn redefine_name(&mut self, name: &str, r: &str) -> Result<&NamedRange, WorkbookError> {
263 self.validate_name_definition(name, r)?;
264 let idx = self.name_index(name).ok_or_else(|| {
265 WorkbookError::Mutation(format!(
266 "cannot redefine named range: no name {name:?} exists"
267 ))
268 })?;
269 // The lookup is case-insensitive, so this preserves the existing name's
270 // identity (including its original casing) and only swaps the `ref`.
271 self.names_mut()[idx].r#ref = r.to_owned();
272 Ok(&self.names()[idx])
273 }
274
275 /// Removes the named range `name` (case-insensitive), returning it if it
276 /// existed, or `None` otherwise.
277 pub fn remove_name(&mut self, name: &str) -> Option<NamedRange> {
278 self.name_index(name).map(|i| self.names_mut().remove(i))
279 }
280
281 /// The named range called `name` (case-insensitive), or `None`. Listing is
282 /// [`names`](crate::Workbook::names).
283 pub fn name(&self, name: &str) -> Option<&NamedRange> {
284 self.name_index(name).map(|i| &self.names()[i])
285 }
286
287 /// Declaration-order index of the named range `name` (case-insensitive,
288 /// simple case folding per schema spec §2/§7), or `None`.
289 fn name_index(&self, name: &str) -> Option<usize> {
290 let folder = CaseMapperBorrowed::new();
291 let target = simple_fold(&folder, name);
292 self.names()
293 .iter()
294 .position(|n| simple_fold(&folder, &n.name) == target)
295 }
296
297 /// Shared name/`ref` validation for define/redefine (schema spec §7):
298 /// name shape, canonical `ref`, and the target sheet's existence. Does not
299 /// check uniqueness or the count cap — those depend on whether the call is
300 /// a define or a redefine and are handled by each caller.
301 fn validate_name_definition(&self, name: &str, r: &str) -> Result<(), WorkbookError> {
302 if !named_ref::is_valid_name(name) {
303 return Err(WorkbookError::Mutation(format!(
304 "named-range name {name:?} is invalid: it must match ^[A-Za-z_][A-Za-z0-9_]*$ and \
305 must not be an A1 address, an R1C1-style reference, or a boolean (schema spec §7)"
306 )));
307 }
308 let parsed = named_ref::parse_canonical_ref(r).map_err(WorkbookError::Mutation)?;
309 if self.sheet(&parsed.sheet).is_none() {
310 return Err(WorkbookError::Mutation(format!(
311 "named range {name:?} refers to sheet {:?}, which does not exist (schema spec §7)",
312 parsed.sheet
313 )));
314 }
315 Ok(())
316 }
317
318 /// Validates a formula's syntax against the workbook's locked engine flavor
319 /// (issue #536: "parsed with the workbook's locked engine"). Parses only —
320 /// no evaluation — so an unevaluated formula cell is still guaranteed to
321 /// hold syntactically valid text.
322 fn validate_formula(&self, formula: &str) -> Result<(), WorkbookError> {
323 let engine = match self.engine() {
324 truecalc_core::EngineFlavor::Sheets => Engine::sheets(),
325 truecalc_core::EngineFlavor::Excel => Engine::excel(),
326 };
327 engine
328 .validate(formula)
329 .map_err(|e| WorkbookError::Mutation(format!("formula {formula:?} is invalid: {e}")))
330 }
331}
332
333/// Eager `text`/`array` size caps of scope ADR Decision 5, mirroring the
334/// serialize-boundary checks in `validate.rs` for the mutation path.
335fn check_value_limits(value: &Value) -> Result<(), WorkbookError> {
336 match value {
337 Value::Text(s) => {
338 let len = s.chars().count();
339 if len > limits::MAX_TEXT_LEN {
340 return Err(WorkbookError::Mutation(format!(
341 "text value has {len} scalar values, exceeding the limit of {} \
342 (scope ADR Decision 5)",
343 limits::MAX_TEXT_LEN
344 )));
345 }
346 }
347 Value::Array(rows) => {
348 let elems: usize = rows.iter().map(|r| r.len()).sum();
349 if elems > limits::MAX_ARRAY_ELEMENTS {
350 return Err(WorkbookError::Mutation(format!(
351 "array value has {elems} elements, exceeding the limit of {} \
352 (scope ADR Decision 5)",
353 limits::MAX_ARRAY_ELEMENTS
354 )));
355 }
356 }
357 _ => {}
358 }
359 Ok(())
360}
361
362/// Eager formula-length cap of scope ADR Decision 5 (bytes), mirroring the
363/// serialize-boundary check for the mutation path.
364fn check_formula_limit(formula: &str) -> Result<(), WorkbookError> {
365 if formula.len() > limits::MAX_FORMULA_LEN {
366 return Err(WorkbookError::Mutation(format!(
367 "formula is {} bytes, exceeding the limit of {} bytes (scope ADR Decision 5)",
368 formula.len(),
369 limits::MAX_FORMULA_LEN
370 )));
371 }
372 Ok(())
373}