Skip to main content

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}