Skip to main content

pmcp_workbook_runtime/render/
mod.rs

1//! The serve-side, WRITER-ONLY render module (Phase 12).
2//!
3//! Plan 01 landed the shared, versioned [`LayoutDescriptor`] serde shape
4//! ([`layout`]) — the umya-free, zip-free single definition the offline emitter
5//! and the serve-time writer share. Plan 02 (this code) adds the writer itself:
6//! [`render_xlsx`] replays a [`LayoutDescriptor`] and injects the executor's
7//! already-computed values into a "copy of the workbook, filled in," producing
8//! valid, DETERMINISTIC `.xlsx` bytes IN MEMORY (no filesystem — Lambda-safe,
9//! RESEARCH Pitfall 6).
10//!
11//! The writer links `rust_xlsxwriter` (a WRITER; it pulls the `zip` deflate
12//! container but NO workbook reader — D-01, the single deliberate cross-phase
13//! purity-gate relaxation). It is reader-free: `just purity-check` proves
14//! `umya`/`quick-xml` stay absent from the served tree while asserting the
15//! writer is present.
16//!
17//! Determinism (review item 8, T-12-15) is a FIRST-CLASS invariant: the writer
18//! pins the workbook's document properties to a FIXED creation datetime + empty
19//! author/metadata so two renders of the same `(layout, run)` are byte-identical.
20//! Plan 03's regenerate-on-read returns fresh bytes every read and relies on
21//! this byte-stability; the invariant is proven HERE (a determinism test) where
22//! it is introduced, not deferred.
23
24use std::collections::HashMap;
25
26use rust_xlsxwriter::{Color, DocProperties, ExcelDateTime, Format, Formula, Workbook};
27use serde::{Deserialize, Serialize};
28
29use crate::cell_key;
30use crate::resolve::{a1_to_zero_indexed_row_col, parse_a1};
31use crate::sheet_ir::value::CellValue;
32use crate::sheet_ir::RunResult;
33
34/// Map a `rust_xlsxwriter` error into [`RenderError::Writer`]. One shared
35/// converter so every writer call site uses `.map_err(writer_err)` instead of
36/// re-spelling the closure (simplify pass).
37fn writer_err(e: rust_xlsxwriter::XlsxError) -> RenderError {
38    RenderError::Writer(e.to_string())
39}
40
41/// The shared, versioned `LayoutDescriptor`/`SheetLayout`/`CellLayout` serde
42/// shapes (D-05) — the FULL workbook-layout descriptor the bundle's `layout.json`
43/// member serializes and the writer replays.
44pub mod layout;
45
46pub use layout::*;
47
48/// A fallible render failure (review item 8 — the writer value path is
49/// panic-free; a malformed coordinate / non-finite value / writer error surfaces
50/// as an `Err`, NEVER a panic and NEVER a bogus cell). Owned `String` detail to
51/// match the crate's `LintFinding` error style (no borrow across the API).
52#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum RenderError {
54    /// A cell's A1 address in the descriptor did not parse to a `(row, col)`
55    /// coordinate (T-12 panic-freedom — a malformed addr is an `Err`).
56    #[error("malformed cell address {addr} on sheet {sheet}")]
57    MalformedAddr {
58        /// The owning sheet name.
59        sheet: String,
60        /// The A1 address that failed to parse.
61        addr: String,
62    },
63    /// A merge range in the descriptor did not parse into two valid endpoints
64    /// (or is degenerate / out of order).
65    #[error("malformed merge range {range} on sheet {sheet}")]
66    MalformedMerge {
67        /// The owning sheet name.
68        sheet: String,
69        /// The merge range that failed to parse.
70        range: String,
71    },
72    /// A computed value for a cell was a non-finite `f64` (NaN/Inf), which Excel
73    /// cannot represent (handler.rs WR-06 reuse, T-12-05) — never written as a
74    /// bogus number.
75    #[error("non-finite computed value at {cell}")]
76    NonFiniteValue {
77        /// The `cell_key` (`sheet!addr`) whose computed value was non-finite.
78        cell: String,
79    },
80    /// The underlying `rust_xlsxwriter` writer returned an error (e.g. a name or
81    /// dimension limit). Carried as an owned `String` (the crate's error is not
82    /// `Clone`/`Eq`).
83    #[error("xlsx writer error: {0}")]
84    Writer(String),
85}
86
87/// A fixed creation datetime for byte-stable output (review item 8, T-12-15):
88/// the UFH milestone epoch `2024-01-01T00:00:00`. ANY constant works — what
89/// matters is that it does NOT vary per render. Building it is fallible only on
90/// an out-of-range constant, which this one is not.
91fn fixed_creation_datetime() -> Result<ExcelDateTime, RenderError> {
92    ExcelDateTime::from_ymd(2024, 1, 1)
93        .and_then(|d| d.and_hms(0, 0, 0))
94        .map_err(writer_err)
95}
96
97/// Normalize a captured formula for the `rust_xlsxwriter` writer (review item 4).
98///
99/// `rust_xlsxwriter` expects a formula string WITH a single leading `=`. The
100/// descriptor MAY carry a formula already prefixed (`=SUM(A1:A2)`) or bare
101/// (`SUM(A1:A2)`). This returns the formula with EXACTLY one leading `=`: a bare
102/// formula is prefixed, an already-prefixed formula is returned UNCHANGED (never
103/// double-prefixed into `==`). Whitespace before a leading `=` is tolerated.
104#[must_use]
105pub fn normalize_formula_for_writer(f: &str) -> String {
106    if f.trim_start().starts_with('=') {
107        f.to_string()
108    } else {
109        format!("={f}")
110    }
111}
112
113/// Build a `Color` from a captured 8-hex ARGB string (`FFE2EFDA`) or a 6-hex RGB
114/// string (`E2EFDA`). `rust_xlsxwriter`'s `Color::RGB` is a 24-bit value, so the
115/// leading alpha byte (when present) is dropped. Returns `None` for an
116/// unparseable string (the caller treats colour as best-effort).
117fn argb_to_color(argb: &str) -> Option<Color> {
118    let hex = argb.trim();
119    let rgb_hex = match hex.len() {
120        // ARGB -> drop the alpha byte. `get` (not a byte-indexed slice) so an
121        // 8-BYTE string whose byte 2 is not a char boundary (multibyte UTF-8)
122        // is `None`, never a slice panic (CR-01 — `layout.json` is untrusted
123        // bundle input; the writer value path must stay panic-free).
124        8 => hex.get(2..)?,
125        6 => hex, // already RGB
126        _ => return None,
127    };
128    let rgb = u32::from_str_radix(rgb_hex, 16).ok()?;
129    Some(Color::RGB(rgb))
130}
131
132/// Build an optional `Format` from a cell's number-format + fill + font ARGBs.
133/// Returns `None` when the cell carries no styling so unstyled cells skip the
134/// format allocation. Colour/format application is BEST-EFFORT (full visual
135/// fidelity is explicitly NOT the bar — RESEARCH anti-pattern); an unparseable
136/// ARGB is silently skipped, never an error.
137fn cell_format(cell: &CellLayout) -> Option<Format> {
138    if cell.number_format.is_none() && cell.fill_argb.is_none() && cell.font_argb.is_none() {
139        return None;
140    }
141    let mut fmt = Format::new();
142    if let Some(nf) = &cell.number_format {
143        fmt = fmt.set_num_format(nf.clone());
144    }
145    if let Some(fill) = cell.fill_argb.as_deref().and_then(argb_to_color) {
146        fmt = fmt.set_background_color(fill);
147    }
148    if let Some(font) = cell.font_argb.as_deref().and_then(argb_to_color) {
149        fmt = fmt.set_font_color(font);
150    }
151    Some(fmt)
152}
153
154/// The set of `(row, col)` coordinates that are INTERIOR to (but not the
155/// top-left of) a merged range — written by `merge_range`, NEVER again by the
156/// per-cell loop (review item 8: writing the interior of a merge is an
157/// overwrite error in Excel).
158type MergeInterior = std::collections::HashSet<(u32, u16)>;
159
160/// Replay every merge range on a sheet, writing the value/format ONLY to the
161/// top-left cell (review item 8). Returns the interior coordinates the per-cell
162/// loop must SKIP. Merges are processed in the descriptor's stored order
163/// (deterministic). A degenerate / malformed / single-cell merge is a
164/// `RenderError`, never a panic.
165fn replay_merges(
166    ws: &mut rust_xlsxwriter::Worksheet,
167    sheet: &SheetLayout,
168    top_left_text: &HashMap<(u32, u16), String>,
169) -> Result<MergeInterior, RenderError> {
170    let mut interior = MergeInterior::new();
171    let blank = Format::new();
172    for range in &sheet.merges {
173        let (start, end) = range
174            .split_once(':')
175            .ok_or_else(|| RenderError::MalformedMerge {
176                sheet: sheet.name.clone(),
177                range: range.clone(),
178            })?;
179        let malformed = || RenderError::MalformedMerge {
180            sheet: sheet.name.clone(),
181            range: range.clone(),
182        };
183        let (r0, c0) = a1_to_zero_indexed_row_col(start.trim()).ok_or_else(malformed)?;
184        let (r1, c1) = a1_to_zero_indexed_row_col(end.trim()).ok_or_else(malformed)?;
185        let (row_lo, row_hi) = (r0.min(r1), r0.max(r1));
186        let (col_lo, col_hi) = (c0.min(c1), c0.max(c1));
187        // merge_range rejects a single cell; a 1x1 "merge" is malformed input.
188        if row_lo == row_hi && col_lo == col_hi {
189            return Err(malformed());
190        }
191        // Write the top-left cell text via merge_range (it owns the interior).
192        let text = top_left_text
193            .get(&(row_lo, col_lo))
194            .cloned()
195            .unwrap_or_default();
196        ws.merge_range(row_lo, col_lo, row_hi, col_hi, &text, &blank)
197            .map_err(writer_err)?;
198        // Record every interior coordinate (including the top-left, which
199        // merge_range already wrote) so the per-cell loop skips them all.
200        for r in row_lo..=row_hi {
201            for c in col_lo..=col_hi {
202                interior.insert((r, c));
203            }
204        }
205    }
206    Ok(interior)
207}
208
209/// How [`render_xlsx`] writes the workbook's formula output cells (WBVER-02).
210///
211/// `Filled` (the default) is the historical behavior: every formula cell is
212/// written as a formula-with-cached-result (`<f>` + the executor's cached `<v>`),
213/// so the download is a "copy of the workbook, filled in." `InputsOnly` is the
214/// double-entry verification copy: input/non-formula cells are still seeded with
215/// the caller's values, but formula cells are written as BARE formulas — the
216/// server contributes ZERO output values, so Excel recomputes everything from
217/// scratch (D-05: a CLEAN copy, no extra highlighting/formatting/comments).
218///
219/// NOTE: `rust_xlsxwriter` always structurally emits a `<v>` for a formula cell
220/// and always sets `fullCalcOnLoad=1`, so a BARE formula carries the writer's
221/// NEUTRAL `<v>0</v>` placeholder (NOT the executor's value) with no value-type
222/// attribute — the literal-`<v>`-absent shape is not expressible. The verification
223/// guarantee holds: the cached value is the executor's NONE in `InputsOnly`, and
224/// Excel (which always recalculates on load) is the sole oracle.
225///
226/// The serde rename makes it (de)serialize as `"filled"` / `"inputs_only"` so it
227/// can ride inside the `workbook://` URI payload. An ABSENT field defaults to
228/// `Filled` (via [`Default`]); a PRESENT-but-unknown string (e.g. `"bogus"`) is a
229/// serde DECODE ERROR, NOT a silent `Filled` — there is deliberately no
230/// `#[serde(other)]`/catch-all variant.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum RenderMode {
234    /// Formula cells carry `<f>` + the executor's cached `<v>` (the default —
235    /// byte-identical to the pre-WBVER-02 single-mode render).
236    #[default]
237    Filled,
238    /// Formula cells carry a BARE `<f>` with NO cached `<v>`; input/non-formula
239    /// cells are seeded unchanged. Excel recomputes every output on load.
240    InputsOnly,
241}
242
243/// Render a [`LayoutDescriptor`] + the executor's [`RunResult`] into valid,
244/// DETERMINISTIC `.xlsx` bytes IN MEMORY (review item 8, D-01).
245///
246/// The writer replays the descriptor's sheets/cells/merges and INJECTS each
247/// computed value from `run.computed` (keyed `sheet!addr`). Default lean (D-05):
248/// a cell with a formula + a FINITE numeric result is written as a
249/// formula-with-cached-result (`write_formula` + `Formula::set_result`); a
250/// cell with no formula is written as a plain number/string. Every numeric value
251/// is finiteness-guarded before write (handler.rs WR-06 reuse, T-12-05) — a
252/// non-finite value is a [`RenderError::NonFiniteValue`], never a bogus NaN/Inf
253/// cell. The value path is panic-free (`deny(unwrap/expect/panic)`): a malformed
254/// addr/merge surfaces as an `Err`.
255///
256/// Determinism: the workbook's document properties are pinned to a FIXED
257/// creation datetime + empty author/metadata so repeated renders are
258/// byte-identical (Plan 03 regenerate-on-read relies on this; T-12-15).
259///
260/// Output is via `save_to_buffer()` ONLY — never a file path (Lambda-safe,
261/// RESEARCH Pitfall 6).
262///
263/// `mode` (WBVER-02) selects how formula cells are written:
264/// [`RenderMode::Filled`] (the default) writes `<f>` + the executor's cached
265/// `<v>`; [`RenderMode::InputsOnly`] writes a BARE `<f>` (the neutral `<v>0</v>`
266/// placeholder, NOT the executor's value) so Excel recomputes every output.
267/// Non-formula (input/seeded) cells are written the same way in BOTH modes. Each
268/// mode is byte-deterministic across reads (per-mode determinism; the two modes
269/// differ from each other by design).
270pub fn render_xlsx(
271    layout: &LayoutDescriptor,
272    run: &RunResult,
273    mode: RenderMode,
274) -> Result<Vec<u8>, RenderError> {
275    let mut wb = init_workbook()?;
276    for sheet in &layout.sheets {
277        let ws = wb.add_worksheet();
278        render_sheet(ws, sheet, run, mode)?;
279    }
280    wb.save_to_buffer().map_err(writer_err)
281}
282
283/// Build the workbook with its determinism-pinned document properties (review
284/// item 8, T-12-15): a FIXED creation datetime + empty author so two renders of
285/// the same `(layout, run)` are byte-identical.
286fn init_workbook() -> Result<Workbook, RenderError> {
287    let mut wb = Workbook::new();
288    let props = DocProperties::new()
289        .set_author("")
290        .set_creation_datetime(&fixed_creation_datetime()?);
291    wb.set_properties(&props);
292    Ok(wb)
293}
294
295/// Render a single sheet: scaffold (name/hidden/columns) → top-left text map →
296/// merge replay → per-cell value injection. A thin per-sheet orchestrator over
297/// the three phase helpers; the per-cell write order is preserved exactly so
298/// output stays byte-deterministic.
299fn render_sheet(
300    ws: &mut rust_xlsxwriter::Worksheet,
301    sheet: &SheetLayout,
302    run: &RunResult,
303    mode: RenderMode,
304) -> Result<(), RenderError> {
305    apply_sheet_scaffold(ws, sheet)?;
306    // PASS 1: resolve each cell's merge-top-left display TEXT so a merge can
307    // fetch it without re-deriving (also validates each addr panic-free).
308    let top_left_text = build_top_left_text(sheet, run)?;
309    // Replay merges first (top-left only); collect interior coords to skip.
310    let interior = replay_merges(ws, sheet, &top_left_text)?;
311    // PASS 2: write every non-merge-interior cell, injecting computed values.
312    for cell in &sheet.cells {
313        write_cell(ws, sheet, run, cell, &interior, mode)?;
314    }
315    Ok(())
316}
317
318/// Apply the sheet-level scaffold: name, hidden flag, per-column widths and
319/// hidden columns (best-effort, deterministic descriptor order).
320fn apply_sheet_scaffold(
321    ws: &mut rust_xlsxwriter::Worksheet,
322    sheet: &SheetLayout,
323) -> Result<(), RenderError> {
324    ws.set_name(&sheet.name).map_err(writer_err)?;
325    if sheet.hidden {
326        ws.set_hidden(true);
327    }
328    for (col_1based, width) in &sheet.col_widths {
329        if let Some(col) = col_1based.checked_sub(1) {
330            ws.set_column_width(col, *width).map_err(writer_err)?;
331        }
332    }
333    for col_1based in &sheet.hidden_cols {
334        if let Some(col) = col_1based.checked_sub(1) {
335            ws.set_column_hidden(col).map_err(writer_err)?;
336        }
337    }
338    Ok(())
339}
340
341/// PASS 1: resolve each cell to `(row, col)` + the text it would carry, so a
342/// merge can fetch its top-left text without re-deriving it. Validates each addr
343/// up front (panic-free — a bad addr is an `Err`) and rejects a non-finite
344/// computed number (T-12-05) before it can leak into a merged cell.
345fn build_top_left_text(
346    sheet: &SheetLayout,
347    run: &RunResult,
348) -> Result<HashMap<(u32, u16), String>, RenderError> {
349    let mut top_left_text: HashMap<(u32, u16), String> = HashMap::new();
350    for cell in &sheet.cells {
351        // Validate the addr up front (panic-free): a bad addr is an Err.
352        if a1_to_zero_indexed_row_col(&cell.addr).is_none() {
353            // Distinguish a genuinely malformed addr from one parse_a1 rejects
354            // only because it overflows u16: parse_a1 succeeding but the
355            // conversion failing is still malformed-for-the-writer.
356            let _ = parse_a1(&cell.addr); // documents the reuse; result unused
357            return Err(RenderError::MalformedAddr {
358                sheet: sheet.name.clone(),
359                addr: cell.addr.clone(),
360            });
361        }
362        let key = cell_key(&sheet.name, &cell.addr);
363        let display = display_text(run, &key, cell)?;
364        if let (Some((r, c)), Some(text)) = (a1_to_zero_indexed_row_col(&cell.addr), display) {
365            top_left_text.insert((r, c), text);
366        }
367    }
368    Ok(top_left_text)
369}
370
371/// The text a merged top-left should display: prefer the computed value, else
372/// the descriptor's captured value text. A non-finite computed number is an
373/// `Err` (T-12-05), never a bogus merged cell.
374fn display_text(
375    run: &RunResult,
376    key: &str,
377    cell: &CellLayout,
378) -> Result<Option<String>, RenderError> {
379    let display = match run.computed.get(key) {
380        Some(CellValue::Number(n)) if n.is_finite() => Some(format_number(*n)),
381        Some(CellValue::Number(_)) => {
382            return Err(RenderError::NonFiniteValue {
383                cell: key.to_string(),
384            })
385        },
386        Some(CellValue::Text(s)) => Some(s.clone()),
387        Some(CellValue::Bool(b)) => Some(b.to_string()),
388        _ => cell.value.clone(),
389    };
390    Ok(display)
391}
392
393/// PASS 2: write a single non-merge-interior cell, injecting its computed value.
394/// A coordinate owned by a merge range is skipped (merge_range already wrote it).
395fn write_cell(
396    ws: &mut rust_xlsxwriter::Worksheet,
397    sheet: &SheetLayout,
398    run: &RunResult,
399    cell: &CellLayout,
400    interior: &MergeInterior,
401    mode: RenderMode,
402) -> Result<(), RenderError> {
403    let (row, col) =
404        a1_to_zero_indexed_row_col(&cell.addr).ok_or_else(|| RenderError::MalformedAddr {
405            sheet: sheet.name.clone(),
406            addr: cell.addr.clone(),
407        })?;
408    if interior.contains(&(row, col)) {
409        return Ok(()); // merge_range already owns this coordinate
410    }
411    let key = cell_key(&sheet.name, &cell.addr);
412    let computed = run.computed.get(&key);
413    let fmt = cell_format(cell);
414    write_computed_value(ws, row, col, cell, computed, key, fmt.as_ref(), mode)
415}
416
417/// Dispatch a cell's computed value to the right writer (flat match): a finite
418/// number → number/formula cell; text/bool → string cell; error/empty/not-computed
419/// → fall back to the captured literal. A non-finite number is an `Err` (T-12-05).
420fn write_computed_value(
421    ws: &mut rust_xlsxwriter::Worksheet,
422    row: u32,
423    col: u16,
424    cell: &CellLayout,
425    computed: Option<&CellValue>,
426    key: String,
427    fmt: Option<&Format>,
428    mode: RenderMode,
429) -> Result<(), RenderError> {
430    match computed {
431        Some(CellValue::Number(n)) => {
432            // WR-06 / T-12-05: a non-finite computed number is never written as
433            // a bogus cell — fail loud.
434            if !n.is_finite() {
435                return Err(RenderError::NonFiniteValue { cell: key });
436            }
437            let n = *n;
438            write_formula_or_value(
439                ws,
440                row,
441                col,
442                &cell.formula,
443                format_number(n),
444                fmt,
445                mode,
446                |ws, fmt| write_number_literal(ws, row, col, n, fmt),
447            )?;
448        },
449        Some(CellValue::Text(s)) => {
450            // WBVER-01: a TEXT formula output carries <f>+<v> (cached result = s);
451            // a non-formula text cell stays a plain string literal.
452            write_formula_or_value(
453                ws,
454                row,
455                col,
456                &cell.formula,
457                s.clone(),
458                fmt,
459                mode,
460                |ws, fmt| write_string_cell(ws, row, col, s, fmt),
461            )?;
462        },
463        Some(CellValue::Bool(b)) => {
464            // WBVER-01: a BOOL formula output carries <f>+<v> (cached result =
465            // TRUE/FALSE); a non-formula bool stays its existing string literal.
466            let literal = b.to_string();
467            let cached = if *b { "TRUE" } else { "FALSE" }.to_string();
468            write_formula_or_value(ws, row, col, &cell.formula, cached, fmt, mode, |ws, fmt| {
469                write_string_cell(ws, row, col, &literal, fmt)
470            })?;
471        },
472        // Error / Empty / not-computed: fall back to the captured value text (the
473        // descriptor's "copy of the workbook" content) so a non-output cell still
474        // renders its original literal.
475        _ => {
476            if let Some(v) = &cell.value {
477                write_string_cell(ws, row, col, v, fmt)?;
478            }
479        },
480    }
481    Ok(())
482}
483
484/// Format a finite f64 for a fallback text cell deterministically. Full-precision
485/// numbers go through Rust's shortest-round-trip `{}`; this is only used for the
486/// merged-top-left TEXT path (numbers in normal cells are written as numbers).
487fn format_number(n: f64) -> String {
488    // {} on f64 is the shortest round-trip representation — deterministic.
489    format!("{n}")
490}
491
492/// Write a cell as a formula-with-cached-result when it HAS a formula, else as a
493/// plain literal (default lean D-05; WBVER-01 extends this to text/bool outputs).
494///
495/// The 4-arm `(cell.formula, fmt)` matrix is shared across Number/Text/Bool: the
496/// formula arms write `Formula::new(normalize_formula_for_writer(f))` (with the
497/// cached result attached ONLY in [`RenderMode::Filled`]) so Excel's
498/// `fullCalcOnLoad` can independently recompute the output; the non-formula arms
499/// invoke `write_literal` — a TYPED per-value-type closure that the caller
500/// supplies. Keeping the value-type knowledge in the closure means this helper
501/// never `match`es on `CellValue`, so it stays a flat 4-arm dispatcher under
502/// cog-25.
503///
504/// WBVER-02: when `mode == InputsOnly` the formula is written BARE (no
505/// `.set_result(..)`), so the cell carries `<f>` with the writer's NEUTRAL
506/// `<v>0</v>` placeholder (NOT the executor's value) — Excel recomputes it from
507/// scratch on load. Non-formula cells write their seeded literal in BOTH modes, so
508/// the InputsOnly "seed inputs, bare formulas for the rest" copy falls out for
509/// free. `cached_result` is consumed only on the `Filled` formula path; the build
510/// helper below applies it conditionally.
511fn write_formula_or_value<W>(
512    ws: &mut rust_xlsxwriter::Worksheet,
513    row: u32,
514    col: u16,
515    formula: &Option<String>,
516    cached_result: String,
517    fmt: Option<&Format>,
518    mode: RenderMode,
519    write_literal: W,
520) -> Result<(), RenderError>
521where
522    W: FnOnce(&mut rust_xlsxwriter::Worksheet, Option<&Format>) -> Result<(), RenderError>,
523{
524    match (formula, fmt) {
525        (Some(f), Some(fmt)) => {
526            let formula = build_formula(f, cached_result, mode);
527            ws.write_formula_with_format(row, col, formula, fmt)
528                .map_err(writer_err)?;
529        },
530        (Some(f), None) => {
531            let formula = build_formula(f, cached_result, mode);
532            ws.write_formula(row, col, formula).map_err(writer_err)?;
533        },
534        (None, _) => write_literal(ws, fmt)?,
535    }
536    Ok(())
537}
538
539/// Build the `rust_xlsxwriter` [`Formula`] for a formula cell, attaching the
540/// cached result ONLY in [`RenderMode::Filled`] (WBVER-02). In
541/// [`RenderMode::InputsOnly`] the formula is left BARE (no `.set_result`), so the
542/// cell carries `<f>` with the writer's neutral `<v>0</v>` placeholder rather than
543/// the executor's value — Excel recomputes it on load (the double-entry
544/// verification copy). The single `mode` branch lives here so
545/// [`write_formula_or_value`] stays a flat 4-arm dispatcher.
546fn build_formula(f: &str, cached_result: String, mode: RenderMode) -> Formula {
547    let formula = Formula::new(normalize_formula_for_writer(f));
548    match mode {
549        RenderMode::Filled => formula.set_result(cached_result),
550        RenderMode::InputsOnly => formula,
551    }
552}
553
554/// Write a plain numeric literal (format applied when present). The non-formula
555/// arm of the Number value type, passed to `write_formula_or_value` as its typed
556/// literal-writer.
557fn write_number_literal(
558    ws: &mut rust_xlsxwriter::Worksheet,
559    row: u32,
560    col: u16,
561    n: f64,
562    fmt: Option<&Format>,
563) -> Result<(), RenderError> {
564    match fmt {
565        Some(fmt) => ws
566            .write_number_with_format(row, col, n, fmt)
567            .map_err(writer_err)?,
568        None => ws.write_number(row, col, n).map_err(writer_err)?,
569    };
570    Ok(())
571}
572
573/// Write a string cell (format applied when present).
574fn write_string_cell(
575    ws: &mut rust_xlsxwriter::Worksheet,
576    row: u32,
577    col: u16,
578    s: &str,
579    fmt: Option<&Format>,
580) -> Result<(), RenderError> {
581    match fmt {
582        Some(fmt) => ws
583            .write_string_with_format(row, col, s, fmt)
584            .map_err(writer_err)?,
585        None => ws.write_string(row, col, s).map_err(writer_err)?,
586    };
587    Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::excel_error::ExcelError;
594    use std::collections::HashMap;
595
596    /// The ZIP local-file-header magic an `.xlsx` (a ZIP container) leads with.
597    const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
598
599    fn run_with(pairs: &[(&str, CellValue)]) -> RunResult {
600        let mut computed = HashMap::new();
601        for (k, v) in pairs {
602            computed.insert((*k).to_string(), v.clone());
603        }
604        RunResult {
605            computed,
606            traces: HashMap::new(),
607        }
608    }
609
610    fn cell(addr: &str, formula: Option<&str>, value: Option<&str>) -> CellLayout {
611        CellLayout {
612            addr: addr.to_string(),
613            formula: formula.map(str::to_string),
614            value: value.map(str::to_string),
615            number_format: None,
616            fill_argb: None,
617            font_argb: None,
618        }
619    }
620
621    /// Unzip an in-memory `.xlsx` buffer (the writer's output is a ZIP container)
622    /// and return the UTF-8 XML of a named worksheet entry (e.g.
623    /// `"xl/worksheets/sheet1.xml"`). DEV-ONLY: consumed by the WBVER-01/02 unit
624    /// tests (Plans 02/03) to assert `<f>`/`<v>` presence/absence on SPECIFIC known
625    /// cells. Current render tests only check the ZIP magic — this opens the
626    /// container so callers can read worksheet XML.
627    ///
628    /// Total against the well-formed buffers `render_xlsx` produces; a test-local
629    /// `expect` surfaces a malformed buffer / missing entry as a test failure.
630    fn extract_sheet_xml(buf: &[u8], sheet_path: &str) -> String {
631        use std::io::Read;
632        let reader = std::io::Cursor::new(buf.to_vec());
633        let mut archive = zip::ZipArchive::new(reader).expect("xlsx buffer is a valid ZIP");
634        let mut entry = archive
635            .by_name(sheet_path)
636            .unwrap_or_else(|_| panic!("worksheet entry {sheet_path} present in the xlsx"));
637        let mut xml = String::new();
638        entry
639            .read_to_string(&mut xml)
640            .expect("worksheet entry is UTF-8 XML");
641        xml
642    }
643
644    /// Return the `<c r="A1"> … </c>` element SLICE for a given A1 address within a
645    /// worksheet XML string, or `None` when that cell is absent. Lets consumers
646    /// assert `<f>`/`<v>` presence/absence WITHIN one known cell rather than a
647    /// whole-sheet count (MEDIUM #6 — shared/inline strings false-positive a global
648    /// `<f>`/`<v>` tally). Scans for `<c r="<a1>"` and returns up to the matching
649    /// `</c>` (or the self-closing `/>` for an empty cell).
650    fn cell_xml<'a>(sheet_xml: &'a str, a1: &str) -> Option<&'a str> {
651        let needle = format!("<c r=\"{a1}\"");
652        let start = sheet_xml.find(&needle)?;
653        let rest = &sheet_xml[start..];
654        // A cell element ends at the first "</c>" (a cell with children) OR a
655        // self-closing "/>" that precedes any "</c>" / "<c " boundary (empty cell).
656        let close = rest.find("</c>").map(|i| i + "</c>".len());
657        let next_open = rest.find("<c ").unwrap_or(rest.len());
658        let self_close = rest[..next_open.min(rest.len())].find("/>").map(|i| i + 2);
659        let end = match (close, self_close) {
660            (Some(c), Some(s)) => c.min(s),
661            (Some(c), None) => c,
662            (None, Some(s)) => s,
663            (None, None) => return None,
664        };
665        Some(&rest[..end])
666    }
667
668    fn one_sheet(name: &str, cells: Vec<CellLayout>, merges: Vec<String>) -> LayoutDescriptor {
669        LayoutDescriptor {
670            descriptor_version: LAYOUT_DESCRIPTOR_VERSION,
671            source_workbook_hash: None,
672            sheets: vec![SheetLayout {
673                name: name.to_string(),
674                hidden: false,
675                cells,
676                merges,
677                col_widths: vec![],
678                hidden_cols: vec![],
679            }],
680        }
681    }
682
683    #[test]
684    fn render_xlsx_produces_valid_zip_container() {
685        let layout = one_sheet("7_Quote", vec![cell("C11", None, Some("0"))], vec![]);
686        let run = run_with(&[("7_Quote!C11", CellValue::Number(1594.93))]);
687        let bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("render");
688        assert!(!bytes.is_empty(), "non-empty output");
689        assert_eq!(
690            &bytes[..4],
691            ZIP_MAGIC,
692            "leads with the ZIP magic PK\\x03\\x04"
693        );
694    }
695
696    #[test]
697    fn render_xlsx_is_deterministic_byte_identical() {
698        // review item 8 / T-12-15: two renders of the SAME (layout, run) are
699        // byte-identical (creation datetime + metadata suppressed). WBVER-02
700        // extends this to PER-MODE determinism: two Filled renders are byte-equal
701        // AND two InputsOnly renders are byte-equal (the two modes differ from each
702        // other BY DESIGN — no cross-mode equality assertion).
703        let layout = one_sheet(
704            "7_Quote",
705            vec![cell("C11", Some("SUM(C9:C10)"), Some("0"))],
706            vec![],
707        );
708        let run = run_with(&[("7_Quote!C11", CellValue::Number(1594.93))]);
709        let a = render_xlsx(&layout, &run, RenderMode::Filled).expect("render a");
710        let b = render_xlsx(&layout, &run, RenderMode::Filled).expect("render b");
711        assert_eq!(
712            a, b,
713            "two Filled renders of the same input are byte-identical"
714        );
715
716        let io_a = render_xlsx(&layout, &run, RenderMode::InputsOnly).expect("render io a");
717        let io_b = render_xlsx(&layout, &run, RenderMode::InputsOnly).expect("render io b");
718        assert_eq!(
719            io_a, io_b,
720            "two InputsOnly renders of the same input are byte-identical"
721        );
722    }
723
724    #[test]
725    fn normalize_formula_for_writer_never_double_prefixes() {
726        // review item 4: a bare formula gains one '='; an already-prefixed one is
727        // unchanged (never '==').
728        assert_eq!(normalize_formula_for_writer("SUM(A1:A2)"), "=SUM(A1:A2)");
729        assert_eq!(normalize_formula_for_writer("=SUM(A1:A2)"), "=SUM(A1:A2)");
730        // Both forms round-trip to a single leading '='.
731        for f in ["SUM(A1:A2)", "=SUM(A1:A2)"] {
732            let out = normalize_formula_for_writer(f);
733            assert!(out.starts_with('='), "has a leading '='");
734            assert!(!out.starts_with("=="), "never double-prefixed");
735        }
736    }
737
738    #[test]
739    fn render_xlsx_rejects_non_finite_computed_value() {
740        // WR-06 / T-12-05: a NaN/Inf computed value is a RenderError, never a cell.
741        let layout = one_sheet("7_Quote", vec![cell("C11", None, None)], vec![]);
742        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
743            let run = run_with(&[("7_Quote!C11", CellValue::Number(bad))]);
744            let err =
745                render_xlsx(&layout, &run, RenderMode::Filled).expect_err("non-finite must be Err");
746            assert!(
747                matches!(err, RenderError::NonFiniteValue { .. }),
748                "got {err:?}"
749            );
750        }
751    }
752
753    #[test]
754    fn render_xlsx_surfaces_malformed_addr_as_error_not_panic() {
755        // A malformed descriptor addr is a RenderError (the value path is panic-free).
756        let layout = one_sheet("7_Quote", vec![cell("1A", None, Some("x"))], vec![]);
757        let run = run_with(&[]);
758        let err =
759            render_xlsx(&layout, &run, RenderMode::Filled).expect_err("malformed addr must be Err");
760        assert!(
761            matches!(err, RenderError::MalformedAddr { .. }),
762            "got {err:?}"
763        );
764    }
765
766    #[test]
767    fn render_xlsx_writes_formula_with_finite_cached_result() {
768        // A formula cell + a finite result writes the (normalized, single '=')
769        // formula with its cached result; render succeeds and bytes are produced.
770        let layout = one_sheet(
771            "7_Quote",
772            vec![cell("C11", Some("=SUM(C9:C10)"), None)],
773            vec![],
774        );
775        let run = run_with(&[("7_Quote!C11", CellValue::Number(1594.93))]);
776        let bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("render");
777        assert_eq!(&bytes[..4], ZIP_MAGIC);
778    }
779
780    #[test]
781    fn render_xlsx_replays_merge_top_left_only() {
782        // review item 8: a merge A1:B2 with a value at the top-left A1 produces a
783        // valid xlsx. The interior cells (A2/B1/B2) being ALSO present in the
784        // descriptor must NOT cause a double-write error — they are skipped.
785        let layout = one_sheet(
786            "7_Quote",
787            vec![
788                cell("A1", None, Some("merged")),
789                cell("A2", None, Some("interior")),
790                cell("B1", None, Some("interior")),
791                cell("B2", None, Some("interior")),
792            ],
793            vec!["A1:B2".to_string()],
794        );
795        let run = run_with(&[("7_Quote!A1", CellValue::Text("merged".to_string()))]);
796        let bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("render with merge");
797        assert_eq!(
798            &bytes[..4],
799            ZIP_MAGIC,
800            "merge replay still yields a valid xlsx"
801        );
802    }
803
804    #[test]
805    fn render_xlsx_rejects_single_cell_merge() {
806        // A degenerate 1x1 merge is malformed input (Excel rejects single-cell
807        // merges) — surfaced as MalformedMerge, never a panic.
808        let layout = one_sheet(
809            "7_Quote",
810            vec![cell("A1", None, Some("x"))],
811            vec!["A1:A1".to_string()],
812        );
813        let run = run_with(&[]);
814        let err = render_xlsx(&layout, &run, RenderMode::Filled)
815            .expect_err("single-cell merge must be Err");
816        assert!(
817            matches!(err, RenderError::MalformedMerge { .. }),
818            "got {err:?}"
819        );
820    }
821
822    #[test]
823    fn render_xlsx_writes_text_and_bool_and_falls_back_on_error_value() {
824        // Text/Bool computed values write; an Error value falls back to the
825        // captured descriptor text (no panic, no NaN).
826        let layout = one_sheet(
827            "7_Quote",
828            vec![
829                cell("A1", None, None),
830                cell("A2", None, None),
831                cell("A3", None, Some("orig")),
832            ],
833            vec![],
834        );
835        let run = run_with(&[
836            ("7_Quote!A1", CellValue::Text("hi".to_string())),
837            ("7_Quote!A2", CellValue::Bool(true)),
838            ("7_Quote!A3", CellValue::Error(ExcelError::DivZero)),
839        ]);
840        let bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("render");
841        assert_eq!(&bytes[..4], ZIP_MAGIC);
842    }
843
844    #[test]
845    fn extract_sheet_xml_locates_formula_and_value_on_a_specific_cell() {
846        // WBVER-01/02 groundwork: the Filled render of a SPECIFIC numeric formula
847        // cell carries BOTH an <f> (the formula) and a <v> (the cached result)
848        // WITHIN that cell's <c> element. The self-test scopes the assertion to the
849        // cell BY A1 ADDRESS via `cell_xml` (NOT a brittle whole-sheet <f>/<v> count
850        // — shared/inline strings false-positive a global count, MEDIUM #6).
851        let layout = one_sheet(
852            "7_Quote",
853            vec![cell("C11", Some("SUM(C9:C10)"), Some("0"))],
854            vec![],
855        );
856        let run = run_with(&[("7_Quote!C11", CellValue::Number(1594.93))]);
857        let bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("render");
858
859        let sheet_xml = extract_sheet_xml(&bytes, "xl/worksheets/sheet1.xml");
860        let c11 = cell_xml(&sheet_xml, "C11").expect("the C11 cell element is present");
861        assert!(
862            c11.contains("<f>") || c11.contains("<f "),
863            "the numeric formula cell carries an <f> element within its own <c>"
864        );
865        assert!(
866            c11.contains("<v>"),
867            "the numeric formula cell carries a cached <v> within its own <c>"
868        );
869
870        // A non-existent address yields None (total against well-formed buffers).
871        assert!(
872            cell_xml(&sheet_xml, "Z99").is_none(),
873            "an absent cell address resolves to None, never a panic"
874        );
875    }
876
877    #[test]
878    fn render_xlsx_text_and_bool_formula_cells_carry_f_and_v_per_cell() {
879        // WBVER-01: a TEXT formula output (cell.formula = Some) and a BOOL formula
880        // output must each render as a formula-with-cached-result — their OWN <c>
881        // element carries BOTH an <f> (the formula) AND a <v> (the cached result),
882        // exactly like the proven numeric formula cell. Scoped per cell BY A1 ADDRESS
883        // via cell_xml (NOT a whole-sheet count — shared/inline strings false-positive
884        // a global tally, MEDIUM #6).
885        let layout = one_sheet(
886            "3_Outputs",
887            vec![
888                // bracket_label: a text formula output (Plan-01 fixture B6).
889                cell(
890                    "B6",
891                    Some("IF(taxable_income>=40000,\"bracket_2\",\"bracket_1\")"),
892                    None,
893                ),
894                // is_taxable: a bool formula output (Plan-01 fixture B7).
895                cell("B7", Some("taxable_income>0"), None),
896            ],
897            vec![],
898        );
899        let run = run_with(&[
900            ("3_Outputs!B6", CellValue::Text("bracket_2".to_string())),
901            ("3_Outputs!B7", CellValue::Bool(true)),
902        ]);
903        let bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("render");
904
905        let sheet_xml = extract_sheet_xml(&bytes, "xl/worksheets/sheet1.xml");
906
907        let b6 = cell_xml(&sheet_xml, "B6").expect("the B6 text-formula cell is present");
908        assert!(
909            b6.contains("<f>") || b6.contains("<f "),
910            "the TEXT formula cell carries an <f> element within its own <c>: {b6}"
911        );
912        assert!(
913            b6.contains("<v>"),
914            "the TEXT formula cell carries a cached <v> within its own <c>: {b6}"
915        );
916
917        let b7 = cell_xml(&sheet_xml, "B7").expect("the B7 bool-formula cell is present");
918        assert!(
919            b7.contains("<f>") || b7.contains("<f "),
920            "the BOOL formula cell carries an <f> element within its own <c>: {b7}"
921        );
922        assert!(
923            b7.contains("<v>"),
924            "the BOOL formula cell carries a cached <v> within its own <c>: {b7}"
925        );
926    }
927
928    #[test]
929    fn render_xlsx_non_formula_text_and_bool_remain_plain_literals() {
930        // No-regression: a text/bool cell with cell.formula = None still renders as a
931        // plain value (NO <f>) — unchanged behavior.
932        let layout = one_sheet(
933            "3_Outputs",
934            vec![cell("A1", None, None), cell("A2", None, None)],
935            vec![],
936        );
937        let run = run_with(&[
938            ("3_Outputs!A1", CellValue::Text("plain".to_string())),
939            ("3_Outputs!A2", CellValue::Bool(false)),
940        ]);
941        let bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("render");
942        let sheet_xml = extract_sheet_xml(&bytes, "xl/worksheets/sheet1.xml");
943
944        let a1 = cell_xml(&sheet_xml, "A1").expect("A1 plain text cell present");
945        assert!(
946            !a1.contains("<f>") && !a1.contains("<f "),
947            "a non-formula text cell carries NO <f>: {a1}"
948        );
949        let a2 = cell_xml(&sheet_xml, "A2").expect("A2 plain bool cell present");
950        assert!(
951            !a2.contains("<f>") && !a2.contains("<f "),
952            "a non-formula bool cell carries NO <f>: {a2}"
953        );
954    }
955
956    #[test]
957    fn render_xlsx_inputs_only_emits_bare_formulas_no_cached_value_per_cell() {
958        // WBVER-02 (with DEVIATION note): the double-entry InputsOnly copy writes
959        // every FORMULA cell as a BARE formula — the server contributes ZERO output
960        // values. `rust_xlsxwriter` ALWAYS structurally emits a `<v>` for a formula
961        // cell (a bare formula defaults to the neutral `<v>0</v>` placeholder, and
962        // the workbook's `calcPr` is ALWAYS `fullCalcOnLoad=1`), so "the <v> element
963        // is literally absent" is not expressible via the writer. The ACHIEVABLE,
964        // semantically-equivalent invariant — and what this test asserts PER CELL by
965        // A1 (NOT a whole-sheet count, MEDIUM #6) — is:
966        //   - InputsOnly formula cell: `<f>` present, `<v>` is the NEUTRAL `0`
967        //     placeholder (NOT the executor's value), and NO value-type attribute
968        //     (`t="str"`/`t="b"`) is emitted — Excel recomputes from scratch.
969        //   - Filled formula cell: `<f>` present AND `<v>` carries the EXECUTOR's
970        //     cached result (123 / bracket_2 / 1), with the value-type attribute.
971        let layout = one_sheet(
972            "3_Outputs",
973            vec![
974                // A numeric formula output cell.
975                cell("B5", Some("=SUM(B1:B4)"), None),
976                // A text formula output cell (Plan-01 fixture B6).
977                cell(
978                    "B6",
979                    Some("IF(taxable_income>=40000,\"bracket_2\",\"bracket_1\")"),
980                    None,
981                ),
982                // A bool formula output cell (Plan-01 fixture B7).
983                cell("B7", Some("taxable_income>0"), None),
984                // A non-formula SEEDED input cell (formula = None).
985                cell("A1", None, None),
986            ],
987            vec![],
988        );
989        let run = run_with(&[
990            ("3_Outputs!B5", CellValue::Number(123.0)),
991            ("3_Outputs!B6", CellValue::Text("bracket_2".to_string())),
992            ("3_Outputs!B7", CellValue::Bool(true)),
993            ("3_Outputs!A1", CellValue::Number(40000.0)),
994        ]);
995
996        let io_bytes =
997            render_xlsx(&layout, &run, RenderMode::InputsOnly).expect("inputs_only render");
998        let io_xml = extract_sheet_xml(&io_bytes, "xl/worksheets/sheet1.xml");
999        let filled_bytes = render_xlsx(&layout, &run, RenderMode::Filled).expect("filled render");
1000        let filled_xml = extract_sheet_xml(&filled_bytes, "xl/worksheets/sheet1.xml");
1001
1002        // (a1, executor_cached_value_substring) for the formula cells. InputsOnly
1003        // must NOT carry these; Filled MUST.
1004        let formula_cells = [("B5", "123"), ("B6", "bracket_2"), ("B7", "1")];
1005        for (a1, exec_val) in formula_cells {
1006            let io =
1007                cell_xml(&io_xml, a1).unwrap_or_else(|| panic!("{a1} io formula cell present"));
1008            assert!(
1009                io.contains("<f>") || io.contains("<f "),
1010                "InputsOnly: {a1} carries a bare <f>: {io}"
1011            );
1012            assert!(
1013                io.contains("<v>0</v>"),
1014                "InputsOnly: {a1} carries the NEUTRAL 0 placeholder, not the executor value: {io}"
1015            );
1016            assert!(
1017                !io.contains(&format!("<v>{exec_val}</v>")) || exec_val == "0",
1018                "InputsOnly: {a1} must NOT carry the executor's cached value '{exec_val}': {io}"
1019            );
1020            assert!(
1021                !io.contains("t=\"str\"") && !io.contains("t=\"b\""),
1022                "InputsOnly: {a1} emits NO value-type attr (bare formula, Excel recomputes): {io}"
1023            );
1024
1025            // Filled carries the EXECUTOR's value on the same cell.
1026            let f =
1027                cell_xml(&filled_xml, a1).unwrap_or_else(|| panic!("{a1} filled formula present"));
1028            assert!(
1029                f.contains("<f>") || f.contains("<f "),
1030                "Filled: {a1} carries an <f>: {f}"
1031            );
1032            assert!(
1033                f.contains(&format!("<v>{exec_val}</v>")),
1034                "Filled: {a1} carries the executor's cached value '{exec_val}': {f}"
1035            );
1036        }
1037
1038        // The seeded non-formula input cell carries its value (a numeric <v>, no
1039        // <f>) in BOTH modes — input seeding falls out for free.
1040        for (label, xml) in [("InputsOnly", &io_xml), ("Filled", &filled_xml)] {
1041            let a1 = cell_xml(xml, "A1").expect("A1 seeded input present");
1042            assert!(
1043                !a1.contains("<f>") && !a1.contains("<f "),
1044                "{label}: a seeded input cell has NO <f>: {a1}"
1045            );
1046            assert!(
1047                a1.contains("<v>40000</v>"),
1048                "{label}: a seeded input cell still carries its value <v>: {a1}"
1049            );
1050        }
1051    }
1052
1053    #[test]
1054    fn render_mode_deserializes_known_strings_and_rejects_unknown() {
1055        // MEDIUM #3: RenderMode (de)serializes as "filled" / "inputs_only"; an
1056        // ABSENT value defaults to Filled (Default), but a PRESENT-but-unknown
1057        // string ("bogus") is a serde DECODE ERROR — never a silent Filled, never
1058        // a panic. There is deliberately NO #[serde(other)]/catch-all variant.
1059        let filled: RenderMode = serde_json::from_str("\"filled\"").expect("filled decodes");
1060        assert_eq!(filled, RenderMode::Filled);
1061        let io: RenderMode = serde_json::from_str("\"inputs_only\"").expect("inputs_only decodes");
1062        assert_eq!(io, RenderMode::InputsOnly);
1063        // The serde rename round-trips on the wire.
1064        assert_eq!(
1065            serde_json::to_string(&RenderMode::InputsOnly).expect("serialize"),
1066            "\"inputs_only\""
1067        );
1068        assert_eq!(
1069            RenderMode::default(),
1070            RenderMode::Filled,
1071            "Default is Filled"
1072        );
1073
1074        // A present malformed string is an Err (the URI/handler layers rely on this).
1075        let bogus = serde_json::from_str::<RenderMode>("\"bogus\"");
1076        assert!(
1077            bogus.is_err(),
1078            "an unknown mode string is a decode Err, not a silent Filled: {bogus:?}"
1079        );
1080    }
1081
1082    #[test]
1083    fn argb_to_color_non_ascii_eight_byte_input_is_none_not_a_panic() {
1084        // CR-01 regression: "€abcde" is 8 BYTES (3 + 5) but byte index 2 falls
1085        // inside the multibyte '€' — the old `&hex[2..]` slice panicked. The
1086        // fix returns None (unparseable ARGB is silently skipped, per the
1087        // documented contract).
1088        assert_eq!("€abcde".len(), 8, "the reproducer is byte-length 8");
1089        assert_eq!(argb_to_color("€abcde"), None);
1090        // Valid forms still parse.
1091        assert!(argb_to_color("FFE2EFDA").is_some());
1092        assert!(argb_to_color("E2EFDA").is_some());
1093    }
1094
1095    #[test]
1096    fn render_xlsx_with_non_ascii_argb_renders_without_panic() {
1097        // CR-01 end-to-end: a corrupt/attacker-influenced bundle ARGB reaching
1098        // cell_format via CellLayout must render (colour skipped), never panic.
1099        let mut bad = cell("A1", None, Some("x"));
1100        bad.fill_argb = Some("€abcde".to_string());
1101        bad.font_argb = Some("€abcde".to_string());
1102        let layout = one_sheet("7_Quote", vec![bad], vec![]);
1103        let bytes = render_xlsx(&layout, &run_with(&[]), RenderMode::Filled).expect("render");
1104        assert_eq!(&bytes[..4], ZIP_MAGIC);
1105    }
1106}