Skip to main content

xl3_core/
render.rs

1//! Top-level renderer. Glues `plan` + `source` + `eval` + `output`.
2//!
3//! Phase 1 P1-A scope: single source per workbook, expand-down rows,
4//! no manifest preservation. Returns the rendered workbook as a byte
5//! buffer.
6
7use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use crate::directives::Directive;
15use crate::eval::{
16    compare, eval_cell, eval_expression_str, inject_rownum, inject_rows, is_truthy, EvalContext,
17};
18use crate::output::{write_workbook_with_manifest, RenderedSheet};
19use crate::output_model::{OutputFile, XtlWarning};
20use crate::plan::{
21    inputs_to_value, lists_to_value, parse_template, CellSource, RowPlan, SheetPlan, WorkbookPlan,
22};
23use crate::source::{CalamineSourceReader, SourceData, SourceReader};
24use crate::styles::NumFmtKind;
25use crate::value::Value;
26
27/// Convenience for the conformance runner: parse the template, load the
28/// source workbook, render, return bytes of the first output file.
29///
30/// For the multi-file `OutputFile[]` surface — matching xl3 (TS) /
31/// xl3-py's `convert()` — use [`render_from_paths_to_files`] /
32/// [`render_to_files`] / [`render_to_files_with_sources`].
33pub fn render_from_paths(template: &Path, data: &Path) -> Result<Vec<u8>> {
34    first_file_bytes(render_from_paths_to_files(template, data)?)
35}
36
37/// Variant that lets the host supply `__inputs__` overrides — used by
38/// the conformance runner when a fixture's `meta.yaml` declares
39/// runtime inputs (ADR-0010). Returns the first output file's bytes;
40/// see the `_to_files` variants for the multi-file surface.
41pub fn render_from_paths_with_inputs(
42    template: &Path,
43    data: &Path,
44    host_inputs: &HashMap<String, Value>,
45) -> Result<Vec<u8>> {
46    first_file_bytes(render_from_paths_to_files_with_inputs(
47        template,
48        data,
49        host_inputs,
50    )?)
51}
52
53fn first_file_bytes(files: Vec<OutputFile>) -> Result<Vec<u8>> {
54    files
55        .into_iter()
56        .next()
57        .map(|f| f.data)
58        .ok_or_else(|| anyhow::anyhow!("renderer produced no output files"))
59}
60
61/// Multi-file render entry point, matching the xl3 (TS) and xl3-py
62/// `convert()` surface. The current implementation always returns a
63/// single `OutputFile` — `output_file_pattern`-driven multi-file
64/// splitting will land alongside `xl3-wasm`'s real entry point.
65pub fn render_from_paths_to_files(template: &Path, data: &Path) -> Result<Vec<OutputFile>> {
66    render_from_paths_to_files_with_inputs(template, data, &HashMap::new())
67}
68
69pub fn render_from_paths_to_files_with_inputs(
70    template: &Path,
71    data: &Path,
72    host_inputs: &HashMap<String, Value>,
73) -> Result<Vec<OutputFile>> {
74    let plan = parse_template(template).context("parse template")?;
75    let source_reader = CalamineSourceReader::open(data).context("open source workbook")?;
76    render_with_reader(plan, source_reader, host_inputs)
77}
78
79/// In-memory render entry — same surface as
80/// `render_from_paths_to_files_with_inputs` but takes the template
81/// and data workbooks as raw XLSX byte buffers. This is the entry
82/// point the WASM wrapper drives.
83pub fn render_from_bytes_to_files(
84    template_bytes: &[u8],
85    data_bytes: Vec<u8>,
86) -> Result<Vec<OutputFile>> {
87    render_from_bytes_to_files_with_inputs(template_bytes, data_bytes, &HashMap::new())
88}
89
90pub fn render_from_bytes_to_files_with_inputs(
91    template_bytes: &[u8],
92    data_bytes: Vec<u8>,
93    host_inputs: &HashMap<String, Value>,
94) -> Result<Vec<OutputFile>> {
95    render_from_bytes_to_files_full(template_bytes, data_bytes, host_inputs, None)
96}
97
98/// Full byte-buffer entry, accepting an optional style manifest
99/// extracted by the host (xl3 TS). The manifest preserves fonts,
100/// fills, alignment, merges, and column widths that aren't carried
101/// by xl3-core's own template-styles pass. `None` is the same as
102/// calling `render_from_bytes_to_files_with_inputs` — no manifest,
103/// styles fall back to what we extract from styles.xml ourselves.
104pub fn render_from_bytes_to_files_full(
105    template_bytes: &[u8],
106    data_bytes: Vec<u8>,
107    host_inputs: &HashMap<String, Value>,
108    manifest: Option<crate::manifest::StyleManifest>,
109) -> Result<Vec<OutputFile>> {
110    // Hand the manifest to the planner so the per-cell style index
111    // is stamped onto each CellSource::Template up front — that's
112    // the only place we still see the template (row, col) before
113    // the planner collapses positions during expansion.
114    let plan = crate::plan::parse_template_bytes_with_manifest(template_bytes, manifest.as_ref())
115        .context("parse template")?;
116    let source_reader =
117        CalamineSourceReader::open_bytes(data_bytes).context("open source workbook")?;
118    render_with_reader_and_manifest(plan, source_reader, host_inputs, manifest)
119}
120
121fn render_with_reader(
122    plan: WorkbookPlan,
123    source_reader: CalamineSourceReader,
124    host_inputs: &HashMap<String, Value>,
125) -> Result<Vec<OutputFile>> {
126    render_with_reader_and_manifest(plan, source_reader, host_inputs, None)
127}
128
129fn render_with_reader_and_manifest(
130    mut plan: WorkbookPlan,
131    mut source_reader: CalamineSourceReader,
132    host_inputs: &HashMap<String, Value>,
133    manifest: Option<crate::manifest::StyleManifest>,
134) -> Result<Vec<OutputFile>> {
135    for (key, value) in host_inputs {
136        plan.inputs.insert(key.clone(), value.clone());
137    }
138    let source_sheet = match plan.config.source_sheet() {
139        Some(pattern) => source_reader.resolve_sheet_name(pattern).ok_or_else(|| {
140            // Spec-stable message: matches xl3 (TS) /xl3-py wording so a
141            // host can substring-match on the code OR the human text.
142            anyhow::Error::from(crate::errors::XtlError::new(
143                crate::errors::code::SOURCE_SHEET_MISSING,
144                format!("Source sheet \"{pattern}\" was not found"),
145            ))
146        })?,
147        None => source_reader.first_sheet().ok_or_else(|| {
148            anyhow::Error::from(crate::errors::XtlError::new(
149                crate::errors::code::SOURCE_SHEET_MISSING,
150                "Source workbook is empty",
151            ))
152        })?,
153    };
154    let source_table = plan.config.source_table();
155    let source = source_reader.read(&source_sheet, &source_table)?;
156    // Load every additional named source declared on `__sources__`.
157    let mut named_sources: HashMap<String, SourceData> = HashMap::new();
158    for (name, decl) in &plan.named_sources {
159        let data = source_reader.read(&decl.sheet, &decl.table)?;
160        named_sources.insert(name.clone(), data);
161    }
162    render_to_files_with_sources_and_manifest(&plan, &source, &named_sources, manifest.as_ref())
163}
164
165
166pub fn render(plan: &WorkbookPlan, source: &SourceData) -> Result<Vec<u8>> {
167    first_file_bytes(render_to_files(plan, source)?)
168}
169
170pub fn render_with_sources(
171    plan: &WorkbookPlan,
172    source: &SourceData,
173    named_sources: &HashMap<String, SourceData>,
174) -> Result<Vec<u8>> {
175    first_file_bytes(render_to_files_with_sources(plan, source, named_sources)?)
176}
177
178pub fn render_to_files(plan: &WorkbookPlan, source: &SourceData) -> Result<Vec<OutputFile>> {
179    render_to_files_with_sources(plan, source, &HashMap::new())
180}
181
182pub fn render_to_files_with_sources(
183    plan: &WorkbookPlan,
184    source: &SourceData,
185    named_sources: &HashMap<String, SourceData>,
186) -> Result<Vec<OutputFile>> {
187    render_to_files_with_sources_and_manifest(plan, source, named_sources, None)
188}
189
190pub fn render_to_files_with_sources_and_manifest(
191    plan: &WorkbookPlan,
192    source: &SourceData,
193    named_sources: &HashMap<String, SourceData>,
194    manifest: Option<&crate::manifest::StyleManifest>,
195) -> Result<Vec<OutputFile>> {
196    let group_keys = plan.config.file_group_keys();
197    if group_keys.is_empty() {
198        return Ok(vec![render_one_file(
199            plan,
200            source,
201            named_sources,
202            &HashMap::new(),
203            manifest,
204        )?]);
205    }
206    // ADR-0002: partition the source by the file-group keys in
207    // first-seen order, render one file per partition. Each partition
208    // inherits the group key values in its static ctx so the template
209    // can reference them as bare identifiers.
210    let mut groups: Vec<(Vec<String>, Vec<HashMap<String, Value>>)> = Vec::new();
211    for row in &source.rows {
212        let values: Vec<String> = group_keys
213            .iter()
214            .map(|k| row.get(k).cloned().unwrap_or(Value::Empty).canonical())
215            .collect();
216        if let Some(g) = groups.iter_mut().find(|g| g.0 == values) {
217            g.1.push(row.clone());
218        } else {
219            groups.push((values, vec![row.clone()]));
220        }
221    }
222    let mut out: Vec<OutputFile> = Vec::with_capacity(groups.len());
223    for (values, rows) in groups {
224        let group_ctx: HashMap<String, Value> = group_keys
225            .iter()
226            .cloned()
227            .zip(values.into_iter().map(Value::String))
228            .collect();
229        let group_source = SourceData {
230            name: source.name.clone(),
231            headers: source.headers.clone(),
232            rows,
233        };
234        out.push(render_one_file(
235            plan,
236            &group_source,
237            named_sources,
238            &group_ctx,
239            manifest,
240        )?);
241    }
242    Ok(out)
243}
244
245fn render_one_file(
246    plan: &WorkbookPlan,
247    source: &SourceData,
248    named_sources: &HashMap<String, SourceData>,
249    group_keys: &HashMap<String, Value>,
250    manifest: Option<&crate::manifest::StyleManifest>,
251) -> Result<OutputFile> {
252    let inputs_value = inputs_to_value(&plan.inputs);
253    let lists_value = lists_to_value(&plan.lists);
254    let named_source_handles: HashMap<String, Value> = named_sources
255        .iter()
256        .map(|(name, data)| {
257            let handle: Arc<Vec<HashMap<String, Value>>> = Arc::new(data.rows.clone());
258            (name.clone(), Value::Rows(handle))
259        })
260        .collect();
261    let mut out_sheets = Vec::with_capacity(plan.sheets.len());
262    for sheet in &plan.sheets {
263        if sheet.name.contains("{{") {
264            // Sheet-name template (ADR-0016) — partition the group
265            // source again by the sheet-name key.
266            let groups = split_source_by_sheet_name(
267                &sheet.name,
268                source,
269                &inputs_value,
270                &lists_value,
271                &named_source_handles,
272                group_keys,
273            )?;
274            for (group_name, group_source) in groups {
275                let mut rs = render_sheet(
276                    sheet,
277                    &group_source,
278                    &inputs_value,
279                    &lists_value,
280                    &named_source_handles,
281                    group_keys,
282                )?;
283                rs.name = sanitize_sheet_name(&group_name);
284                out_sheets.push(rs);
285            }
286        } else {
287            out_sheets.push(render_sheet(
288                sheet,
289                source,
290                &inputs_value,
291                &lists_value,
292                &named_source_handles,
293                group_keys,
294            )?);
295        }
296    }
297    let bytes = write_workbook_with_manifest(&out_sheets, manifest)?;
298    let pattern = plan
299        .config
300        .output_file_pattern()
301        .map(str::to_string)
302        .unwrap_or_else(|| "output.xlsx".to_string());
303    // The filename ctx layers (in priority order, lowest first):
304    // group_keys ◁ first source row ◁ reserved namespaces. Group keys
305    // win over the source row so the filename matches the partition's
306    // bucket value exactly.
307    let mut warnings: Vec<XtlWarning> = Vec::new();
308    let resolved = if pattern.contains("{{") {
309        let mut ctx: EvalContext = HashMap::new();
310        if let Some(row) = source.rows.first() {
311            ctx.extend(row.clone());
312        }
313        for (k, v) in group_keys {
314            ctx.insert(k.clone(), v.clone());
315        }
316        ctx.insert("__inputs__".to_string(), inputs_value.clone());
317        ctx.insert("__lists__".to_string(), lists_value.clone());
318        inject_named_sources(&mut ctx, &named_source_handles);
319        eval_cell(&pattern, &ctx)?.canonical()
320    } else {
321        pattern
322    };
323    let filename = sanitize_filename(&resolved, &mut warnings);
324    Ok(OutputFile {
325        filename,
326        data: bytes,
327        warnings,
328    })
329}
330
331/// Replace the OOXML / Windows forbidden filename characters
332/// `<>:"/\\|?*` with `_`, matching xl3 (TS)'s `sanitiseFilename`
333/// behaviour. Emits one warning per file when the result differs from
334/// the input, in the exact wording the conformance corpus checks for
335/// (ADR-0002 / fixture 006).
336fn sanitize_filename(name: &str, warnings: &mut Vec<XtlWarning>) -> String {
337    let cleaned: String = name
338        .chars()
339        .map(|c| match c {
340            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
341            _ => c,
342        })
343        .collect();
344    if cleaned != name {
345        warnings.push(XtlWarning {
346            message: format!("Output filename \"{name}\" sanitized to \"{cleaned}\""),
347        });
348    }
349    cleaned
350}
351
352/// xlsx limits sheet names to 31 characters and disallows `:\/?*[]`.
353/// Replace illegal chars with `_` and truncate so write_workbook does
354/// not error on a group key that happens to contain whitespace, dates,
355/// etc.
356fn sanitize_sheet_name(s: &str) -> String {
357    let cleaned: String = s
358        .chars()
359        .map(|c| match c {
360            ':' | '\\' | '/' | '?' | '*' | '[' | ']' => '_',
361            _ => c,
362        })
363        .collect();
364    if cleaned.chars().count() <= 31 {
365        cleaned
366    } else {
367        cleaned.chars().take(31).collect()
368    }
369}
370
371/// Partition the source rows by the evaluated sheet-name template
372/// (xl3 ADR-0016 first-seen order). Returns `(group_key, group_source)`
373/// pairs preserving order of first appearance.
374fn split_source_by_sheet_name(
375    template: &str,
376    source: &SourceData,
377    inputs_value: &Value,
378    lists_value: &Value,
379    named_sources: &HashMap<String, Value>,
380    group_keys: &HashMap<String, Value>,
381) -> Result<Vec<(String, SourceData)>> {
382    let mut groups: Vec<(String, Vec<HashMap<String, Value>>)> = Vec::new();
383    for row in &source.rows {
384        let mut ctx: EvalContext = row.clone();
385        for (k, v) in group_keys {
386            ctx.insert(k.clone(), v.clone());
387        }
388        ctx.insert("__inputs__".to_string(), inputs_value.clone());
389        ctx.insert("__lists__".to_string(), lists_value.clone());
390        inject_named_sources(&mut ctx, named_sources);
391        let key_value = eval_cell(template, &ctx)?;
392        let raw_key = key_value.canonical();
393        // ADR-0026: an empty / whitespace-only group key is substituted
394        // with the literal `(blank)` placeholder before sheet-name
395        // interpolation. Otherwise we'd hit rust_xlsxwriter's "sheet
396        // name cannot be blank" error.
397        let key = if raw_key.chars().all(char::is_whitespace) {
398            "(blank)".to_string()
399        } else {
400            raw_key
401        };
402        if let Some(g) = groups.iter_mut().find(|g| g.0 == key) {
403            g.1.push(row.clone());
404        } else {
405            groups.push((key, vec![row.clone()]));
406        }
407    }
408    Ok(groups
409        .into_iter()
410        .map(|(key, rows)| {
411            (
412                key,
413                SourceData {
414                    name: source.name.clone(),
415                    headers: source.headers.clone(),
416                    rows,
417                },
418            )
419        })
420        .collect())
421}
422
423fn render_sheet(
424    plan: &SheetPlan,
425    source: &SourceData,
426    inputs_value: &Value,
427    lists_value: &Value,
428    named_sources: &HashMap<String, Value>,
429    group_keys: &HashMap<String, Value>,
430) -> Result<RenderedSheet> {
431    // ADR-0068/0069 multi-block sheet: render each sub-block as if it
432    // were its own single-block sheet, then merge column-by-column.
433    if !plan.sub_blocks.is_empty() {
434        let mut sub_outputs: Vec<(
435            usize,
436            usize,
437            Vec<Vec<Value>>,
438            Vec<Vec<Option<String>>>,
439            Vec<Vec<Option<usize>>>,
440            Vec<Vec<Option<String>>>,
441        )> = Vec::new();
442        for sub in &plan.sub_blocks {
443            let sub_plan = SheetPlan {
444                name: plan.name.clone(),
445                rows: sub.rows.clone(),
446                sub_blocks: Vec::new(),
447                n_cols: sub.col_last - sub.col_first + 1,
448            };
449            let sub_rendered = render_sheet(
450                &sub_plan,
451                source,
452                inputs_value,
453                lists_value,
454                named_sources,
455                group_keys,
456            )?;
457            sub_outputs.push((
458                sub.col_first,
459                sub.col_last,
460                sub_rendered.rows,
461                sub_rendered.formats,
462                sub_rendered.style_indices,
463                sub_rendered.formulas,
464            ));
465        }
466        let max_rows = sub_outputs
467            .iter()
468            .map(|(_, _, r, _, _, _)| r.len())
469            .max()
470            .unwrap_or(0);
471        let n_cols = plan.n_cols.max(1);
472        let mut merged: Vec<Vec<Value>> = (0..max_rows)
473            .map(|_| vec![Value::Empty; n_cols])
474            .collect();
475        let mut merged_formats: Vec<Vec<Option<String>>> = (0..max_rows)
476            .map(|_| vec![None; n_cols])
477            .collect();
478        let mut merged_style_indices: Vec<Vec<Option<usize>>> = (0..max_rows)
479            .map(|_| vec![None; n_cols])
480            .collect();
481        let mut merged_formulas: Vec<Vec<Option<String>>> = (0..max_rows)
482            .map(|_| vec![None; n_cols])
483            .collect();
484        for (col_first, _col_last, sub_rows, sub_formats, sub_styles, sub_formulas) in sub_outputs {
485            for (r_idx, sub_row) in sub_rows.iter().enumerate() {
486                for (c_off, v) in sub_row.iter().enumerate() {
487                    let c = col_first + c_off;
488                    if c < n_cols {
489                        merged[r_idx][c] = v.clone();
490                    }
491                }
492                if let Some(sub_fr) = sub_formats.get(r_idx) {
493                    for (c_off, f) in sub_fr.iter().enumerate() {
494                        let c = col_first + c_off;
495                        if c < n_cols {
496                            merged_formats[r_idx][c] = f.clone();
497                        }
498                    }
499                }
500                if let Some(sub_sr) = sub_styles.get(r_idx) {
501                    for (c_off, s) in sub_sr.iter().enumerate() {
502                        let c = col_first + c_off;
503                        if c < n_cols {
504                            merged_style_indices[r_idx][c] = *s;
505                        }
506                    }
507                }
508                if let Some(sub_fl) = sub_formulas.get(r_idx) {
509                    for (c_off, f) in sub_fl.iter().enumerate() {
510                        let c = col_first + c_off;
511                        if c < n_cols {
512                            merged_formulas[r_idx][c] = f.clone();
513                        }
514                    }
515                }
516            }
517        }
518        return Ok(RenderedSheet {
519            name: plan.name.clone(),
520            rows: merged,
521            formats: merged_formats,
522            style_indices: merged_style_indices,
523            formulas: merged_formulas,
524        });
525    }
526
527    let mut rows: Vec<Vec<Value>> = Vec::new();
528    let mut formats: Vec<Vec<Option<String>>> = Vec::new();
529    let mut style_indices: Vec<Vec<Option<usize>>> = Vec::new();
530    let mut formulas: Vec<Vec<Option<String>>> = Vec::new();
531    for row in &plan.rows {
532        match row {
533            RowPlan::Static(cells) => {
534                rows.push(render_static_row(
535                    cells,
536                    inputs_value,
537                    lists_value,
538                    named_sources,
539                    group_keys,
540                )?);
541                formats.push(row_formats(cells));
542                style_indices.push(row_style_indices(cells));
543                formulas.push(row_formulas(cells));
544            }
545            RowPlan::ExpandDown {
546                cells,
547                directives,
548                subtotal_rows,
549                side_rows,
550                col_range,
551            } => {
552                let _ = col_range;
553                let block_rows = resolve_block_rows(directives, source, named_sources);
554                let effective =
555                    apply_directives(&block_rows, directives, lists_value, named_sources)?;
556                let group_fields: Vec<String> = directives
557                    .iter()
558                    .find_map(|d| match d {
559                        Directive::Group(fs) => Some(fs.clone()),
560                        _ => None,
561                    })
562                    .unwrap_or_default();
563                let active_source: Option<String> = directives.iter().find_map(|d| match d {
564                    Directive::Source(n) => Some(n.clone()),
565                    _ => None,
566                });
567                let rows_handle: Arc<Vec<HashMap<String, Value>>> = Arc::new(effective.clone());
568
569                let mut global_idx = 0usize;
570                let emit_expansion =
571                    |group_rows: &Vec<HashMap<String, Value>>,
572                     rows: &mut Vec<Vec<Value>>,
573                     formats: &mut Vec<Vec<Option<String>>>,
574                     style_indices: &mut Vec<Vec<Option<usize>>>,
575                     formulas: &mut Vec<Vec<Option<String>>>,
576                     global_idx: &mut usize|
577                     -> Result<()> {
578                        for (iter_idx, source_row) in group_rows.iter().enumerate() {
579                            *global_idx += 1;
580                            let mut ctx: EvalContext = source_row.clone();
581                            inject_rows(&mut ctx, Arc::clone(&rows_handle));
582                            inject_rownum(&mut ctx, *global_idx);
583                            ctx.insert("__inputs__".to_string(), inputs_value.clone());
584                            ctx.insert("__lists__".to_string(), lists_value.clone());
585                            if let Some(name) = &active_source {
586                                ctx.insert(
587                                    name.clone(),
588                                    Value::Map(Arc::new(source_row.clone())),
589                                );
590                            }
591                            inject_named_sources(&mut ctx, named_sources);
592                            let effective_cells = compose_iteration_cells(
593                                cells, side_rows, *col_range, iter_idx,
594                            );
595                            rows.push(render_template_row(&effective_cells, &ctx)?);
596                            formats.push(row_formats(&effective_cells));
597                            style_indices.push(row_style_indices(&effective_cells));
598                            formulas.push(row_formulas(&effective_cells));
599                        }
600                        Ok(())
601                    };
602
603                if group_fields.is_empty() {
604                    emit_expansion(
605                        &effective,
606                        &mut rows,
607                        &mut formats,
608                        &mut style_indices,
609                        &mut formulas,
610                        &mut global_idx,
611                    )?;
612                    let consumed = effective.len().saturating_sub(1);
613                    if side_rows.len() > consumed {
614                        for extra in &side_rows[consumed..] {
615                            rows.push(render_static_row(
616                                extra,
617                                inputs_value,
618                                lists_value,
619                                named_sources,
620                                group_keys,
621                            )?);
622                            formats.push(row_formats(extra));
623                            style_indices.push(row_style_indices(extra));
624                            formulas.push(row_formulas(extra));
625                        }
626                    }
627                    for subtotal_cells in subtotal_rows {
628                        rows.push(render_subtotal_row(
629                            subtotal_cells,
630                            &rows_handle,
631                            inputs_value,
632                            lists_value,
633                            named_sources,
634                        )?);
635                        formats.push(row_formats(subtotal_cells));
636                        style_indices.push(row_style_indices(subtotal_cells));
637                        formulas.push(row_formulas(subtotal_cells));
638                    }
639                } else {
640                    render_grouped(
641                        &effective,
642                        &group_fields,
643                        0,
644                        cells,
645                        subtotal_rows,
646                        side_rows,
647                        *col_range,
648                        &mut rows,
649                        &mut formats,
650                        &mut style_indices,
651                        &mut formulas,
652                        &mut global_idx,
653                        &rows_handle,
654                        inputs_value,
655                        lists_value,
656                        named_sources,
657                        active_source.as_deref(),
658                    )?;
659                }
660            }
661            RowPlan::ExpandRight { cells, directives } => {
662                let block_rows = resolve_block_rows(directives, source, named_sources);
663                let effective = apply_directives(&block_rows, directives, lists_value, named_sources)?;
664                rows.push(render_expand_right_row(
665                    cells,
666                    &effective,
667                    inputs_value,
668                    lists_value,
669                    named_sources,
670                )?);
671                formats.push(row_formats_for_expand_right(cells, effective.len()));
672                style_indices
673                    .push(row_style_indices_for_expand_right(cells, effective.len()));
674                formulas.push(row_formulas_for_expand_right(cells, effective.len()));
675            }
676        }
677    }
678    Ok(RenderedSheet {
679        name: plan.name.clone(),
680        rows,
681        formats,
682        style_indices,
683        formulas,
684    })
685}
686
687/// ExpandRight emits one row whose template cell is repeated for each
688/// source row in the block. The format vec mirrors that shape — for
689/// each input `CellSource`, emit the format code once for literals /
690/// empties or `n_iters` times for the (single) Template cell.
691fn row_formats_for_expand_right(cells: &[CellSource], n_iters: usize) -> Vec<Option<String>> {
692    let mut out = Vec::with_capacity(cells.len() + n_iters);
693    for cell in cells {
694        match cell {
695            CellSource::Template { format_code, .. } => {
696                for _ in 0..n_iters {
697                    out.push(format_code.clone());
698                }
699            }
700            _ => out.push(cell_format(cell)),
701        }
702    }
703    out
704}
705
706/// Mirror of `row_formats_for_expand_right` for the parallel formula
707/// channel. A Template cell isn't a native formula, so it always emits
708/// `None` regardless of iteration count.
709fn row_formulas_for_expand_right(cells: &[CellSource], n_iters: usize) -> Vec<Option<String>> {
710    let mut out = Vec::with_capacity(cells.len() + n_iters);
711    for cell in cells {
712        match cell {
713            CellSource::Template { .. } => {
714                for _ in 0..n_iters {
715                    out.push(None);
716                }
717            }
718            _ => out.push(cell_formula(cell)),
719        }
720    }
721    out
722}
723
724/// Resolve which row set the expansion block iterates over. With no
725/// `@source` directive (the common case) it's the default source's
726/// rows. With `@source Name`, look up the named source — error
727/// permissively to an empty row set if the name isn't declared, so
728/// later directives still apply consistently.
729fn resolve_block_rows(
730    directives: &[Directive],
731    default_source: &SourceData,
732    named_sources: &HashMap<String, Value>,
733) -> Vec<HashMap<String, Value>> {
734    if let Some(name) = directives.iter().find_map(|d| match d {
735        Directive::Source(n) => Some(n.as_str()),
736        _ => None,
737    }) {
738        match named_sources.get(name) {
739            Some(Value::Rows(handle)) => handle.as_ref().clone(),
740            _ => Vec::new(),
741        }
742    } else {
743        default_source.rows.clone()
744    }
745}
746
747/// Split `rows` into consecutive groups of equal-valued `field`. With
748/// `field = None` the whole row set is one group. Equality uses the
749/// expression-language `compare()` so numeric/string differences match
750/// xl3's evaluator. Assumes the caller already applied any `@sort`
751/// directive — xl3 groups *consecutive* rows, not all rows with the
752/// same key.
753fn partition_into_groups(
754    rows: &[HashMap<String, Value>],
755    field: Option<&str>,
756) -> Vec<Vec<HashMap<String, Value>>> {
757    let Some(field) = field else {
758        return vec![rows.to_vec()];
759    };
760    let mut out: Vec<Vec<HashMap<String, Value>>> = Vec::new();
761    let mut current_key: Option<Value> = None;
762    for row in rows {
763        let key = row.get(field).cloned().unwrap_or(Value::Empty);
764        let same = current_key
765            .as_ref()
766            .map(|prev| crate::eval::compare(prev, &key).map(|c| c == 0).unwrap_or(false))
767            .unwrap_or(false);
768        if same {
769            out.last_mut().unwrap().push(row.clone());
770        } else {
771            out.push(vec![row.clone()]);
772            current_key = Some(key);
773        }
774    }
775    out
776}
777
778/// Recursive nested-group emission. ADR-0038:
779/// - groups are nested left-to-right (`@group [Outer], [Inner]`)
780/// - leaf groups (deepest level) emit their data rows
781/// - on the way back up, each completed group emits one subtotal row
782///   per attached `subtotal_rows` slot, where slot index 0 is the
783///   innermost level's row, slot 1 is the next outer, etc.
784fn render_grouped(
785    rows: &[HashMap<String, Value>],
786    group_fields: &[String],
787    depth: usize,
788    cells: &[CellSource],
789    subtotal_rows: &[Vec<CellSource>],
790    side_rows: &[Vec<CellSource>],
791    col_range: Option<(usize, usize)>,
792    out_rows: &mut Vec<Vec<Value>>,
793    out_formats: &mut Vec<Vec<Option<String>>>,
794    out_style_indices: &mut Vec<Vec<Option<usize>>>,
795    out_formulas: &mut Vec<Vec<Option<String>>>,
796    global_idx: &mut usize,
797    rows_handle: &Arc<Vec<HashMap<String, Value>>>,
798    inputs_value: &Value,
799    lists_value: &Value,
800    named_sources: &HashMap<String, Value>,
801    active_source: Option<&str>,
802) -> Result<()> {
803    if depth == group_fields.len() {
804        for (iter_idx, source_row) in rows.iter().enumerate() {
805            *global_idx += 1;
806            let mut ctx: EvalContext = source_row.clone();
807            inject_rows(&mut ctx, Arc::clone(rows_handle));
808            inject_rownum(&mut ctx, *global_idx);
809            ctx.insert("__inputs__".to_string(), inputs_value.clone());
810            ctx.insert("__lists__".to_string(), lists_value.clone());
811            if let Some(name) = active_source {
812                ctx.insert(name.to_string(), Value::Map(Arc::new(source_row.clone())));
813            }
814            inject_named_sources(&mut ctx, named_sources);
815            let effective_cells =
816                compose_iteration_cells(cells, side_rows, col_range, iter_idx);
817            out_rows.push(render_template_row(&effective_cells, &ctx)?);
818            out_formats.push(row_formats(&effective_cells));
819            out_style_indices.push(row_style_indices(&effective_cells));
820            out_formulas.push(row_formulas(&effective_cells));
821        }
822        return Ok(());
823    }
824    let groups = partition_into_groups(rows, Some(&group_fields[depth]));
825    for group in &groups {
826        render_grouped(
827            group,
828            group_fields,
829            depth + 1,
830            cells,
831            subtotal_rows,
832            side_rows,
833            col_range,
834            out_rows,
835            out_formats,
836            out_style_indices,
837            out_formulas,
838            global_idx,
839            rows_handle,
840            inputs_value,
841            lists_value,
842            named_sources,
843            active_source,
844        )?;
845        let slot = group_fields.len() - 1 - depth;
846        if slot < subtotal_rows.len() {
847            let group_handle: Arc<Vec<HashMap<String, Value>>> = Arc::new(group.clone());
848            out_rows.push(render_subtotal_row(
849                &subtotal_rows[slot],
850                &group_handle,
851                inputs_value,
852                lists_value,
853                named_sources,
854            )?);
855            out_formats.push(row_formats(&subtotal_rows[slot]));
856            out_style_indices.push(row_style_indices(&subtotal_rows[slot]));
857            out_formulas.push(row_formulas(&subtotal_rows[slot]));
858        }
859    }
860    Ok(())
861}
862
863fn render_subtotal_row(
864    cells: &[CellSource],
865    group_handle: &Arc<Vec<HashMap<String, Value>>>,
866    inputs_value: &Value,
867    lists_value: &Value,
868    named_sources: &HashMap<String, Value>,
869) -> Result<Vec<Value>> {
870    // Subtotal cells aggregate over the group's rows, with no current
871    // row in scope. Inputs / lists / named sources stay reachable so a
872    // mixed-content subtotal row (literal label + aggregate value) can
873    // reference them if needed.
874    let mut ctx: EvalContext = HashMap::new();
875    inject_rows(&mut ctx, Arc::clone(group_handle));
876    ctx.insert("__inputs__".to_string(), inputs_value.clone());
877    ctx.insert("__lists__".to_string(), lists_value.clone());
878    inject_named_sources(&mut ctx, named_sources);
879    let mut out = Vec::with_capacity(cells.len());
880    for cell in cells {
881        let value = match cell {
882            CellSource::Empty => Value::Empty,
883            CellSource::Literal(v) => v.clone(),
884            CellSource::CellFormula { cached, .. } => cached.clone(),
885            CellSource::Template { text, num_fmt, .. } => {
886                coerce_for_num_fmt(eval_cell(text, &ctx)?, *num_fmt)
887            }
888            CellSource::Subtotal { aggregate, field } => {
889                // Build an `<FN>([<field>])` expression and run it
890                // through the evaluator — that gives us a single,
891                // well-tested aggregate path instead of a parallel
892                // implementation here.
893                let synthetic = format!("{aggregate}([{field}])");
894                eval_expression_str(&synthetic, &ctx)?
895            }
896        };
897        out.push(value);
898    }
899    Ok(out)
900}
901
902/// Build the cell list for one expansion iteration. Inside the
903/// `col_range` we use the original expansion row's cells (which the
904/// evaluator will substitute against the current source row). Outside
905/// the range:
906/// - iteration 0 → the expansion row's own outside-range cells
907///   (literals, side templates) live in this row
908/// - iteration N > 0 → look up `side_rows[N-1]` for the same column;
909///   absent slots emit Empty (ADR-0066 column-scoped splice).
910fn compose_iteration_cells(
911    cells: &[CellSource],
912    side_rows: &[Vec<CellSource>],
913    col_range: Option<(usize, usize)>,
914    iter_idx: usize,
915) -> Vec<CellSource> {
916    let Some((lo, hi)) = col_range else {
917        return cells.to_vec();
918    };
919    cells
920        .iter()
921        .enumerate()
922        .map(|(i, cell)| {
923            let inside = i >= lo && i <= hi;
924            if inside || iter_idx == 0 {
925                cell.clone()
926            } else {
927                side_rows
928                    .get(iter_idx - 1)
929                    .and_then(|r| r.get(i))
930                    .cloned()
931                    .unwrap_or(CellSource::Empty)
932            }
933        })
934        .collect()
935}
936
937/// Split a row's cells into outside-only and inside-only copies for
938/// ADR-0066's column-scoped splice. Empty cells stay empty in both
939/// halves. Returns `(outside, inside, has_outside_content, has_inside_content)`.
940fn split_inside_outside(
941    cells: &[CellSource],
942    range: (usize, usize),
943) -> (Vec<CellSource>, Vec<CellSource>, bool, bool) {
944    let (lo, hi) = range;
945    let mut outside = vec![CellSource::Empty; cells.len()];
946    let mut inside = vec![CellSource::Empty; cells.len()];
947    let mut has_outside = false;
948    let mut has_inside = false;
949    for (i, c) in cells.iter().enumerate() {
950        if matches!(c, CellSource::Empty) {
951            continue;
952        }
953        if i >= lo && i <= hi {
954            inside[i] = c.clone();
955            has_inside = true;
956        } else {
957            outside[i] = c.clone();
958            has_outside = true;
959        }
960    }
961    (outside, inside, has_outside, has_inside)
962}
963
964fn inject_named_sources(ctx: &mut EvalContext, named_sources: &HashMap<String, Value>) {
965    for (name, handle) in named_sources {
966        // Avoid clobbering a same-named source-row column. The current
967        // row's field takes precedence (xl3 doesn't allow conflicting
968        // names, but we lean permissive here rather than erroring).
969        if !ctx.contains_key(name) {
970            ctx.insert(name.clone(), handle.clone());
971        }
972    }
973}
974
975fn apply_directives(
976    rows: &[HashMap<String, Value>],
977    directives: &[Directive],
978    lists_value: &Value,
979    named_sources: &HashMap<String, Value>,
980) -> Result<Vec<HashMap<String, Value>>> {
981    let mut current: Vec<HashMap<String, Value>> = rows.to_vec();
982    // xl3 ADR-0016 multi-sort priority: directives appear in priority
983    // order (first = primary, last = least). We stably sort by the
984    // *least* priority field first and let later (= higher priority)
985    // sorts preserve the existing order for equal keys.
986    let mut ordered: Vec<&Directive> = directives.iter().collect();
987    {
988        let sort_positions: Vec<usize> = ordered
989            .iter()
990            .enumerate()
991            .filter(|(_, d)| matches!(d, Directive::Sort { .. }))
992            .map(|(i, _)| i)
993            .collect();
994        if sort_positions.len() > 1 {
995            // Reverse the order of Sort directives in `ordered`, keeping
996            // every other directive at its original position.
997            let mut reversed = sort_positions.clone();
998            reversed.reverse();
999            let originals: Vec<&Directive> =
1000                sort_positions.iter().map(|&i| ordered[i]).collect();
1001            for (slot, src) in reversed.iter().zip(originals.iter()) {
1002                ordered[*slot] = *src;
1003            }
1004        }
1005    }
1006    for d in ordered {
1007        match d {
1008            Directive::Filter(expr) => {
1009                let mut kept = Vec::with_capacity(current.len());
1010                for row in current.drain(..) {
1011                    // Filter expressions may reference `__lists__[Name]`
1012                    // for set-membership tests. Slot the lists value
1013                    // into the per-row ctx before evaluating.
1014                    let mut ctx = row.clone();
1015                    ctx.insert("__lists__".to_string(), lists_value.clone());
1016                    let v = eval_expression_str(expr, &ctx)?;
1017                    if is_truthy(&v) {
1018                        kept.push(row);
1019                    }
1020                }
1021                current = kept;
1022            }
1023            Directive::Sort { field, ascending } => {
1024                let asc = *ascending;
1025                current.sort_by(|a, b| {
1026                    let av = a.get(field).cloned().unwrap_or(Value::Empty);
1027                    let bv = b.get(field).cloned().unwrap_or(Value::Empty);
1028                    let ord = compare(&av, &bv).unwrap_or(0);
1029                    let ordering = ord.cmp(&0);
1030                    if asc {
1031                        ordering
1032                    } else {
1033                        ordering.reverse()
1034                    }
1035                });
1036            }
1037            Directive::Top(n) => {
1038                current.truncate(*n);
1039            }
1040            Directive::Join {
1041                source,
1042                match_field,
1043                primary_field,
1044            } => {
1045                let target_rows = match named_sources.get(source) {
1046                    Some(Value::Rows(handle)) => Arc::clone(handle),
1047                    _ => {
1048                        anyhow::bail!(
1049                            "@join source {source:?} is not declared in __sources__"
1050                        );
1051                    }
1052                };
1053                // ADR-0014: build a `(canonical_key) -> first matching row`
1054                // index once per directive so per-primary lookup is O(1)
1055                // instead of an O(M) scan. xl3 (TS) does the same via a
1056                // WeakMap-cached canonicalString → row index; we rebuild
1057                // here per render — caching across renders is a future
1058                // optimisation but already dwarfed by the linear scan.
1059                let mut index: HashMap<String, Arc<HashMap<String, Value>>> =
1060                    HashMap::with_capacity(target_rows.len());
1061                for t in target_rows.iter() {
1062                    if let Some(v) = t.get(match_field) {
1063                        let key = v.canonical();
1064                        index
1065                            .entry(key)
1066                            .or_insert_with(|| Arc::new(t.clone()));
1067                    }
1068                }
1069                let mut joined = Vec::with_capacity(current.len());
1070                for mut row in current.drain(..) {
1071                    let primary_val = row
1072                        .get(primary_field)
1073                        .cloned()
1074                        .unwrap_or(Value::Empty);
1075                    let key = primary_val.canonical();
1076                    if let Some(m) = index.get(&key) {
1077                        // Promote the joined row into the per-row ctx via
1078                        // the same key the named source occupies. The
1079                        // ReservedRef path then resolves `Source[Field]`
1080                        // against this Map instead of the full Rows.
1081                        row.insert(
1082                            source.clone(),
1083                            Value::Map(Arc::clone(m)),
1084                        );
1085                        joined.push(row);
1086                    }
1087                }
1088                current = joined;
1089            }
1090            Directive::Repeat(_)
1091            | Directive::Source(_)
1092            | Directive::Group(_)
1093            | Directive::Block { .. }
1094            | Directive::Unhandled(_) => {
1095                // Repeat: direction is absorbed by the planner.
1096                // Source: applied earlier by `resolve_block_rows`.
1097                // Group: applied at expansion time by the renderer.
1098                // Unhandled: inert at this milestone.
1099            }
1100        }
1101    }
1102    Ok(current)
1103}
1104
1105fn render_expand_right_row(
1106    cells: &[CellSource],
1107    rows: &[HashMap<String, Value>],
1108    inputs_value: &Value,
1109    lists_value: &Value,
1110    named_sources: &HashMap<String, Value>,
1111) -> Result<Vec<Value>> {
1112    let mut out = Vec::with_capacity(cells.len() + rows.len());
1113    let mut emitted_expansion = false;
1114    let rows_handle: Arc<Vec<HashMap<String, Value>>> = Arc::new(rows.to_vec());
1115    for cell in cells {
1116        match cell {
1117            CellSource::Empty => out.push(Value::Empty),
1118            CellSource::Literal(v) => out.push(v.clone()),
1119            CellSource::CellFormula { cached, .. } => out.push(cached.clone()),
1120            CellSource::Template { text, num_fmt, .. } => {
1121                if emitted_expansion {
1122                    anyhow::bail!(
1123                        "multi-column @repeat right (two template cells in one expansion row) not yet supported"
1124                    );
1125                }
1126                emitted_expansion = true;
1127                for (idx, source_row) in rows.iter().enumerate() {
1128                    let mut ctx: EvalContext = source_row.clone();
1129                    inject_rows(&mut ctx, Arc::clone(&rows_handle));
1130                    inject_rownum(&mut ctx, idx + 1);
1131                    ctx.insert("__inputs__".to_string(), inputs_value.clone());
1132                    ctx.insert("__lists__".to_string(), lists_value.clone());
1133                    inject_named_sources(&mut ctx, named_sources);
1134                    out.push(coerce_for_num_fmt(eval_cell(text, &ctx)?, *num_fmt));
1135                }
1136            }
1137            CellSource::Subtotal { .. } => {
1138                // @subtotal cells inside an ExpandRight block aren't a
1139                // pattern xl3 emits; if one shows up we keep going so
1140                // the rest of the row renders.
1141                out.push(Value::Empty);
1142            }
1143        }
1144    }
1145    Ok(out)
1146}
1147
1148/// Per-row format codes, parallel to the row's values. `cell_format`
1149/// reads the format_code field off a `CellSource::Template`; literals
1150/// and empties carry `None` until the literal-style pipeline lands.
1151fn cell_format(cell: &CellSource) -> Option<String> {
1152    match cell {
1153        CellSource::Template { format_code, .. } => format_code.clone(),
1154        CellSource::CellFormula { format_code, .. } => format_code.clone(),
1155        _ => None,
1156    }
1157}
1158
1159fn cell_style_idx(cell: &CellSource) -> Option<usize> {
1160    match cell {
1161        CellSource::Template { style_idx, .. } => *style_idx,
1162        CellSource::CellFormula { style_idx, .. } => *style_idx,
1163        _ => None,
1164    }
1165}
1166
1167fn cell_formula(cell: &CellSource) -> Option<String> {
1168    match cell {
1169        CellSource::CellFormula { text, .. } => Some(text.clone()),
1170        _ => None,
1171    }
1172}
1173
1174fn row_formats(cells: &[CellSource]) -> Vec<Option<String>> {
1175    cells.iter().map(cell_format).collect()
1176}
1177
1178fn row_style_indices(cells: &[CellSource]) -> Vec<Option<usize>> {
1179    cells.iter().map(cell_style_idx).collect()
1180}
1181
1182fn row_formulas(cells: &[CellSource]) -> Vec<Option<String>> {
1183    cells.iter().map(cell_formula).collect()
1184}
1185
1186fn row_style_indices_for_expand_right(
1187    cells: &[CellSource],
1188    n_iters: usize,
1189) -> Vec<Option<usize>> {
1190    let mut out = Vec::with_capacity(cells.len() + n_iters);
1191    for cell in cells {
1192        match cell {
1193            CellSource::Template { style_idx, .. } => {
1194                for _ in 0..n_iters {
1195                    out.push(*style_idx);
1196                }
1197            }
1198            _ => out.push(cell_style_idx(cell)),
1199        }
1200    }
1201    out
1202}
1203
1204fn render_static_row(
1205    cells: &[CellSource],
1206    inputs_value: &Value,
1207    lists_value: &Value,
1208    named_sources: &HashMap<String, Value>,
1209    group_keys: &HashMap<String, Value>,
1210) -> Result<Vec<Value>> {
1211    // "Static" rows can still contain `{{ ... }}` blocks that refer to
1212    // reserved namespaces (e.g. `Report month: {{ __inputs__[month] }}`)
1213    // or to a file/sheet group key (xl3 ADR-0002 / ADR-0016 — static
1214    // ctx inherits the keys the partitioner used). No source row, no
1215    // current-block aggregate handle.
1216    let mut ctx: EvalContext = HashMap::new();
1217    for (k, v) in group_keys {
1218        ctx.insert(k.clone(), v.clone());
1219    }
1220    ctx.insert("__inputs__".to_string(), inputs_value.clone());
1221    ctx.insert("__lists__".to_string(), lists_value.clone());
1222    inject_named_sources(&mut ctx, named_sources);
1223    let mut out = Vec::with_capacity(cells.len());
1224    for c in cells {
1225        let value = match c {
1226            CellSource::Empty => Value::Empty,
1227            CellSource::Literal(v) => v.clone(),
1228            CellSource::CellFormula { cached, .. } => cached.clone(),
1229            CellSource::Template { text, num_fmt, .. } => {
1230                coerce_for_num_fmt(eval_cell(text, &ctx)?, *num_fmt)
1231            }
1232            CellSource::Subtotal { .. } => Value::Empty,
1233        };
1234        out.push(value);
1235    }
1236    Ok(out)
1237}
1238
1239fn render_template_row(cells: &[CellSource], ctx: &EvalContext) -> Result<Vec<Value>> {
1240    let mut out = Vec::with_capacity(cells.len());
1241    for cell in cells {
1242        match cell {
1243            CellSource::Empty => out.push(Value::Empty),
1244            CellSource::Literal(v) => out.push(v.clone()),
1245            CellSource::CellFormula { cached, .. } => out.push(cached.clone()),
1246            CellSource::Template { text, num_fmt, .. } => {
1247                out.push(coerce_for_num_fmt(eval_cell(text, ctx)?, *num_fmt))
1248            }
1249            CellSource::Subtotal { .. } => out.push(Value::Empty),
1250        }
1251    }
1252    Ok(out)
1253}
1254
1255/// Apply ADR-0003 single-expression cell coercion driven by the
1256/// template cell's numFmt classification:
1257/// - numeric format + string value → parse to Number (fallback: keep
1258///   the string)
1259/// - date format + ISO-style date string → Excel serial Number
1260/// - text format (`@`) + Number → canonical string
1261fn coerce_for_num_fmt(value: Value, kind: NumFmtKind) -> Value {
1262    match kind {
1263        NumFmtKind::Numeric => match value {
1264            Value::String(s) => {
1265                let trimmed = s.trim();
1266                let cleaned: String = trimmed.chars().filter(|c| *c != ',').collect();
1267                match cleaned.parse::<f64>() {
1268                    Ok(n) => Value::Number(n),
1269                    Err(_) => Value::String(s),
1270                }
1271            }
1272            other => other,
1273        },
1274        NumFmtKind::Date => match value {
1275            Value::String(ref s) => {
1276                if let Some(serial) = parse_iso_date_to_serial(s.trim()) {
1277                    Value::Number(serial)
1278                } else {
1279                    value
1280                }
1281            }
1282            other => other,
1283        },
1284        NumFmtKind::Text => match value {
1285            Value::Number(n) => Value::String(canonical_number(n)),
1286            other => other,
1287        },
1288        NumFmtKind::General => value,
1289    }
1290}
1291
1292fn canonical_number(n: f64) -> String {
1293    crate::value::canonical_number(n)
1294}
1295
1296fn parse_iso_date_to_serial(s: &str) -> Option<f64> {
1297    // Accept `YYYY-MM-DD` (the only date-string form xl3 emits as input).
1298    let bytes = s.as_bytes();
1299    if bytes.len() < 10 {
1300        return None;
1301    }
1302    if bytes[4] != b'-' || bytes[7] != b'-' {
1303        return None;
1304    }
1305    let year: i32 = std::str::from_utf8(&bytes[..4]).ok()?.parse().ok()?;
1306    let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
1307    let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
1308    // Roundtrip through functions::serial_to_iso_date by constructing
1309    // the date directly. We use the DATE() builtin's serial path —
1310    // exposed via a small helper here to avoid a circular dep.
1311    excel_date_to_serial(year, month, day)
1312}
1313
1314fn excel_date_to_serial(year: i32, month: u32, day: u32) -> Option<f64> {
1315    // Minimal Gregorian → Excel serial (matches functions.rs internal
1316    // helper, but inlined to avoid cross-module plumbing).
1317    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
1318        return None;
1319    }
1320    let days = days_from_civil(year, month as i32, day as i32);
1321    // Excel epoch: 1899-12-30 = day 0. Add the 1900-02-29 leap-day
1322    // adjustment (`+1` for dates on or after 1900-03-01).
1323    let epoch = days_from_civil(1899, 12, 30);
1324    let mut serial = days - epoch;
1325    let leap_threshold = days_from_civil(1900, 3, 1);
1326    if days >= leap_threshold {
1327        // already correct
1328    } else if days >= days_from_civil(1900, 1, 1) {
1329        serial -= 1;
1330    }
1331    Some(serial as f64)
1332}
1333
1334/// Days since the proleptic Gregorian epoch (March-1, 2000-style civil
1335/// algorithm — Howard Hinnant). Returns a signed count; the caller
1336/// offsets by the Excel epoch.
1337fn days_from_civil(y: i32, m: i32, d: i32) -> i64 {
1338    let y = if m <= 2 { y - 1 } else { y };
1339    let era = if y >= 0 { y } else { y - 399 } / 400;
1340    let yoe = (y - era * 400) as i64;
1341    let doy = ((153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1) as i64;
1342    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1343    era as i64 * 146097 + doe - 719468
1344}