Skip to main content

truecalc_workbook/
workbook.rs

1use serde::de::Error as _;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use icu_casemap::CaseMapperBorrowed;
5
6use crate::canonical;
7use crate::casefold::simple_fold;
8use crate::engine::EngineFlavor;
9use crate::error::WorkbookError;
10use crate::limits;
11use crate::named_range::NamedRange;
12use crate::strict_json;
13use crate::validate;
14use crate::worksheet::Worksheet;
15
16/// The schema version this library writes (schema spec §10). A string, not
17/// an integer: compared by exact match, never numerically.
18pub const SCHEMA_VERSION: &str = "1";
19
20/// An engine-locked spreadsheet workbook — a pure value object (no hidden
21/// state, no callbacks). Schema spec §2.
22///
23/// All four fields are always serialized, even when empty. Field declaration
24/// order (`engine`, `names`, `sheets`, `version`) matches canonical (JCS)
25/// key order.
26#[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct Workbook {
29    engine: EngineFlavor,
30    names: Vec<NamedRange>,
31    sheets: Vec<Worksheet>,
32    #[serde(deserialize_with = "de_version")]
33    version: String,
34}
35
36impl Workbook {
37    /// Creates an empty workbook locked to `engine`.
38    ///
39    /// The engine flavor is required at creation and immutable for the
40    /// workbook's lifetime (ADR 2026-04-27-engine-flavor-explicit-everywhere):
41    /// there is no default and no setter.
42    pub fn new(engine: EngineFlavor) -> Self {
43        Self {
44            engine,
45            names: Vec::new(),
46            sheets: Vec::new(),
47            version: SCHEMA_VERSION.to_owned(),
48        }
49    }
50
51    /// The engine flavor every formula in this workbook targets.
52    pub fn engine(&self) -> EngineFlavor {
53        self.engine
54    }
55
56    /// The schema version of this workbook document.
57    pub fn version(&self) -> &str {
58        &self.version
59    }
60
61    /// The worksheets, in tab order (array position is tab position).
62    pub fn sheets(&self) -> &[Worksheet] {
63        &self.sheets
64    }
65
66    /// Mutable access to the worksheets.
67    pub fn sheets_mut(&mut self) -> &mut Vec<Worksheet> {
68        &mut self.sheets
69    }
70
71    /// The workbook-scoped named ranges.
72    pub fn names(&self) -> &[NamedRange] {
73        &self.names
74    }
75
76    /// Mutable access to the named ranges.
77    pub fn names_mut(&mut self) -> &mut Vec<NamedRange> {
78        &mut self.names
79    }
80
81    /// The worksheet named `name` (case-insensitive, simple case folding per
82    /// schema spec §2), or `None` if no sheet matches.
83    pub fn sheet(&self, name: &str) -> Option<&Worksheet> {
84        self.sheet_index(name).map(|i| &self.sheets[i])
85    }
86
87    /// Mutable access to the worksheet named `name` (case-insensitive).
88    pub fn sheet_mut(&mut self, name: &str) -> Option<&mut Worksheet> {
89        match self.sheet_index(name) {
90            Some(i) => Some(&mut self.sheets[i]),
91            None => None,
92        }
93    }
94
95    /// The tab position (0-based array index) of the sheet named `name`
96    /// (case-insensitive per schema spec §2), or `None` if no sheet matches.
97    pub fn sheet_index(&self, name: &str) -> Option<usize> {
98        let folder = CaseMapperBorrowed::new();
99        let target = simple_fold(&folder, name);
100        self.sheets
101            .iter()
102            .position(|s| simple_fold(&folder, s.name()) == target)
103    }
104
105    /// Appends `sheet` after the last tab and returns its 0-based position.
106    ///
107    /// Errors if the name collides with an existing sheet under simple case
108    /// folding (schema spec §2), is empty or too long (schema spec §3), or
109    /// would exceed the per-workbook sheet cap (scope ADR Decision 5).
110    pub fn add_sheet(&mut self, sheet: Worksheet) -> Result<usize, WorkbookError> {
111        let pos = self.sheets.len();
112        self.insert_sheet(pos, sheet)?;
113        Ok(pos)
114    }
115
116    /// Inserts `sheet` at tab position `index`, shifting later tabs right.
117    /// `index == sheets().len()` appends. Position semantics: array index is
118    /// tab position (schema spec §2 — order is significant).
119    ///
120    /// Errors on a duplicate name (case-insensitive, §2), an empty/too-long
121    /// name (§3), the sheet cap (Decision 5), or `index` out of `0..=len`.
122    pub fn insert_sheet(&mut self, index: usize, sheet: Worksheet) -> Result<(), WorkbookError> {
123        if self.sheets.len() >= limits::MAX_SHEETS {
124            return Err(WorkbookError::SheetManagement(format!(
125                "cannot add sheet: workbook already has {} sheets, the limit (scope ADR Decision 5)",
126                limits::MAX_SHEETS
127            )));
128        }
129        if index > self.sheets.len() {
130            return Err(WorkbookError::SheetManagement(format!(
131                "cannot insert sheet at position {index}: only {} tab slots exist",
132                self.sheets.len() + 1
133            )));
134        }
135        validate_sheet_name(sheet.name())?;
136        if let Some(existing) = self.sheet(sheet.name()) {
137            return Err(WorkbookError::SheetManagement(format!(
138                "cannot add sheet {:?}: it collides with the existing sheet {:?} under simple \
139                 case folding (schema spec §2)",
140                sheet.name(),
141                existing.name()
142            )));
143        }
144        self.sheets.insert(index, sheet);
145        Ok(())
146    }
147
148    /// Removes and returns the sheet named `name` (case-insensitive),
149    /// shifting later tabs left, or `None` if no sheet matches.
150    ///
151    /// A workbook-scoped named range may now dangle to the removed sheet; the
152    /// dangling-ref invariant is re-checked at [`to_json`](Self::to_json) /
153    /// [`from_json`](Self::from_json) (schema spec §7).
154    pub fn remove_sheet(&mut self, name: &str) -> Option<Worksheet> {
155        self.sheet_index(name).map(|i| self.sheets.remove(i))
156    }
157
158    /// Renames the sheet currently named `from` (case-insensitive) to `to`.
159    ///
160    /// A pure case change of the *same* sheet is allowed (it does not collide
161    /// with itself). Errors if `from` does not exist, if `to` is empty/too
162    /// long (§3), or if `to` collides with a *different* sheet (§2).
163    pub fn rename_sheet(&mut self, from: &str, to: &str) -> Result<(), WorkbookError> {
164        let idx = self.sheet_index(from).ok_or_else(|| {
165            WorkbookError::SheetManagement(format!("cannot rename: no sheet named {from:?}"))
166        })?;
167        validate_sheet_name(to)?;
168        if let Some(other) = self.sheet_index(to) {
169            if other != idx {
170                return Err(WorkbookError::SheetManagement(format!(
171                    "cannot rename sheet to {to:?}: it collides with another sheet under simple \
172                     case folding (schema spec §2)"
173                )));
174            }
175        }
176        self.sheets[idx].set_name(to);
177        Ok(())
178    }
179
180    /// Moves the sheet at tab position `from` to position `to`, shifting the
181    /// sheets in between (schema spec §2 — array position is tab position).
182    /// Errors if either index is out of `0..len`.
183    pub fn move_sheet(&mut self, from: usize, to: usize) -> Result<(), WorkbookError> {
184        let len = self.sheets.len();
185        if from >= len || to >= len {
186            return Err(WorkbookError::SheetManagement(format!(
187                "cannot move sheet from {from} to {to}: valid tab positions are 0..{len}"
188            )));
189        }
190        let sheet = self.sheets.remove(from);
191        self.sheets.insert(to, sheet);
192        Ok(())
193    }
194
195    /// Parses a workbook from JSON bytes, enforcing every document-level rule
196    /// of the schema (schema spec §1–§10) and the resource limits of the scope
197    /// ADR (Decision 5).
198    ///
199    /// Accepts any schema-valid JSON — pretty-printed, reordered keys, extra
200    /// whitespace are all fine; only the *content* must be valid (schema spec
201    /// §8: non-canonical-but-valid input is accepted, output is always
202    /// canonical). Beyond the serde layer's checks (unknown fields, value
203    /// encodings incl. NaN/Inf and `-0`, empty-literal, exact version match),
204    /// this enforces the rules serde cannot express:
205    ///
206    /// - **§1** duplicate object keys are rejected; a UTF-8 BOM and invalid
207    ///   UTF-8 are rejected at the byte boundary (hence `&[u8]`, not `&str`);
208    /// - **§2/§3** sheet names are non-empty, ≤ 100 scalar values, and unique
209    ///   under Unicode **simple** case folding;
210    /// - **§3** cell keys match `^[A-Z]{1,3}[1-9][0-9]{0,7}$` and lie within
211    ///   the address bounds;
212    /// - **§5** spill rectangles are document-valid (no authored cell inside an
213    ///   anchor's rectangle, no overlapping rectangles, none out of bounds);
214    /// - **§7** named-range names and `ref`s are valid and canonical, names are
215    ///   unique case-insensitively, and no `ref` dangles to a missing sheet;
216    /// - **Decision 5** input size and all structural limits are enforced.
217    pub fn from_json(bytes: &[u8]) -> Result<Self, WorkbookError> {
218        if bytes.len() > limits::MAX_SERIALIZED_BYTES {
219            return Err(WorkbookError::Validation(format!(
220                "input is {} bytes, exceeding the {}-byte limit (scope ADR Decision 5)",
221                bytes.len(),
222                limits::MAX_SERIALIZED_BYTES
223            )));
224        }
225        // §1: duplicate-key- and BOM-rejecting parse into a JSON tree.
226        let tree = strict_json::parse_no_dup_keys(bytes).map_err(WorkbookError::Validation)?;
227        // Document-level invariants serde cannot express (§2/§3/§5/§7, limits).
228        validate::validate_document(&tree).map_err(WorkbookError::Validation)?;
229        // Typed deserialization (unknown fields, value encodings, version,
230        // empty-literal) — the serde layer of P2.2.
231        serde_json::from_value(tree).map_err(|e| WorkbookError::Validation(e.to_string()))
232    }
233
234    /// Serializes the workbook to its canonical RFC 8785 (JCS) byte form
235    /// (schema spec §8): one line, no insignificant whitespace, no trailing
236    /// newline, object keys sorted by UTF-16 code units, ECMAScript number
237    /// formatting, `names` sorted by `name`.
238    ///
239    /// Errors if a value is non-finite (forbidden, schema spec §8.4) or if the
240    /// canonical bytes exceed the 100 MiB cap (scope ADR Decision 5).
241    pub fn to_json(&self) -> Result<String, WorkbookError> {
242        // Serialize through the typed serde layer (which already emits the §6
243        // value encodings and rejects NaN/Inf), then canonicalize the tree.
244        let mut tree =
245            serde_json::to_value(self).map_err(|e| WorkbookError::Validation(e.to_string()))?;
246        sort_names_by_name(&mut tree);
247        let canonical = canonical::to_canonical_string(&tree).map_err(WorkbookError::Validation)?;
248        if canonical.len() > limits::MAX_SERIALIZED_BYTES {
249            return Err(WorkbookError::Validation(format!(
250                "canonical workbook is {} bytes, exceeding the {}-byte limit                  (scope ADR Decision 5)",
251                canonical.len(),
252                limits::MAX_SERIALIZED_BYTES
253            )));
254        }
255        Ok(canonical)
256    }
257}
258
259/// Validates a sheet name for the mutation API: non-empty and ≤ 100 Unicode
260/// scalar values (schema spec §3). Uniqueness is checked separately against the
261/// existing sheet set; this is only the per-name shape check, mirroring the
262/// rule [`Workbook::from_json`] applies to a deserialized document.
263///
264/// [`Workbook::from_json`]: crate::Workbook::from_json
265fn validate_sheet_name(name: &str) -> Result<(), WorkbookError> {
266    let len = name.chars().count();
267    if len == 0 {
268        return Err(WorkbookError::SheetManagement(
269            "a worksheet name must be non-empty (schema spec §3)".to_owned(),
270        ));
271    }
272    if len > limits::MAX_SHEET_NAME_LEN {
273        return Err(WorkbookError::SheetManagement(format!(
274            "worksheet name {name:?} has {len} scalar values, exceeding the limit of {} \
275             (schema spec §3)",
276            limits::MAX_SHEET_NAME_LEN
277        )));
278    }
279    Ok(())
280}
281
282/// Domain ordering of schema spec §8.7: `names` is serialized sorted by `name`
283/// in ascending UTF-16 code-unit order (matching JCS string ordering).
284/// `sheets` keeps authored tab order (it is data, not a set) and is left
285/// untouched.
286fn sort_names_by_name(tree: &mut serde_json::Value) {
287    if let Some(names) = tree.get_mut("names").and_then(|v| v.as_array_mut()) {
288        names.sort_by(|a, b| {
289            let an = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
290            let bn = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
291            an.encode_utf16().cmp(bn.encode_utf16())
292        });
293    }
294}
295
296/// Reader rule of schema spec §10: accept every version this library
297/// knows (`"1"`), reject unknown versions with a clear "upgrade" error.
298fn de_version<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
299    let version = String::deserialize(deserializer)?;
300    if version != SCHEMA_VERSION {
301        return Err(D::Error::custom(format!(
302            "unsupported schema version {version:?}: this version of \
303             truecalc-workbook reads version \"1\" (schema spec §10); \
304             upgrade truecalc to load this workbook"
305        )));
306    }
307    Ok(version)
308}