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}