Skip to main content

formualizer_cli/
lib.rs

1//! Shared command implementation. Arguments include the program name.
2//! No signal handlers, TTY assumptions or process exits occur here.
3use chrono::{DateTime, FixedOffset, Local, SecondsFormat, Utc};
4use clap::{Parser, Subcommand, ValueEnum};
5use formualizer_eval::{engine::DeterministicMode, timezone::TimeZoneSpec};
6pub use formualizer_workbook::CancelToken;
7use formualizer_workbook::{
8    ExternalLinkPolicy, IoError, XlsxRecalculateOptions, XlsxRecalculateResult,
9    recalculate_xlsx_bytes, recalculate_xlsx_file,
10};
11use serde::Serialize;
12use std::{
13    ffi::OsString,
14    io::{Read, Write},
15    path::PathBuf,
16    sync::Once,
17};
18
19thread_local! {
20    /// Location of the latest panic on this thread, recorded by the hook that
21    /// [`catch_internal_panic`] installs (the panic payload carries no
22    /// location). Per thread, so a panic elsewhere in the process (another
23    /// Python thread calling `run`, a rayon worker) cannot overwrite it.
24    static LAST_PANIC: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
25}
26static PANIC_HOOK: Once = Once::new();
27
28/// Runs `f`, turning a panic into the CLI's internal-error result
29/// (`status: "error"`, exit 1) with a message that names the panic location.
30///
31/// The location is the one the hook recorded on this thread. A panic on a
32/// worker thread (a rayon task) is recorded on that worker; rayon re-raises
33/// its payload on this thread with `resume_unwind`, which does not run the
34/// hook, so the message then carries the payload and says the location was
35/// on another thread. The previous hook still runs (and prints) for every
36/// panic.
37/// A last resort: the panics themselves are bugs to fix. It needs
38/// `panic = "unwind"`, which the release profile keeps (Cargo's default); on
39/// a `panic = "abort"` build the process still aborts.
40fn catch_internal_panic<T>(f: impl FnOnce() -> Result<T, IoError>) -> Result<T, IoError> {
41    PANIC_HOOK.call_once(|| {
42        let previous = std::panic::take_hook();
43        std::panic::set_hook(Box::new(move |info| {
44            let location = info
45                .location()
46                .map_or_else(|| "unknown location".to_owned(), ToString::to_string);
47            // `try_with`: a panic during thread teardown has no slot left.
48            let _ = LAST_PANIC.try_with(|last| {
49                if let Ok(mut last) = last.try_borrow_mut() {
50                    *last = Some(location);
51                }
52            });
53            previous(info);
54        }));
55    });
56    // A location left by an earlier, already handled panic on this thread
57    // must not be reported for this one.
58    LAST_PANIC.with(|last| last.borrow_mut().take());
59    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|payload| {
60        let message = payload
61            .downcast_ref::<&str>()
62            .map(|s| (*s).to_owned())
63            .or_else(|| payload.downcast_ref::<String>().cloned())
64            .unwrap_or_else(|| "non-string panic payload".to_owned());
65        let location = LAST_PANIC
66            .with(|last| last.borrow_mut().take())
67            .unwrap_or_else(|| "a location on another thread".to_owned());
68        Err(IoError::Backend {
69            backend: "formualizer".to_owned(),
70            message: format!("internal error: panicked at {location}: {message}"),
71        })
72    })
73}
74
75#[cfg(test)]
76thread_local! {
77    /// Makes the next `run` panic inside the guarded region (tests only).
78    static INJECT_PANIC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
79}
80
81const SCHEMA: &str = "formualizer.recalc/1";
82const DEFAULT_MAX_ERRORS: usize = 20;
83const RECALC_AFTER_HELP: &str = "\
84Writes INPUT in place unless -o or --check is given.
85Run recalc as the last step that writes the workbook: later edits leave its
86caches stale.
87
88RAND/RANDBETWEEN are reproducible run to run. Without --now, TODAY/NOW use
89the host clock (local time unless --tz).
90
91External links are never refreshed. By default formulas read the values
92Excel cached in the workbook for them, and the report says so. With
93--external-links refuse, a workbook whose formulas read any of those values
94is refused (exit 2) after calculation instead; nothing is written.
95
96Exit codes:
97  0    written, unchanged or current
98  1    error; nothing written
99  2    refused (unsupported input); nothing written
100  3    --check: caches are stale
101  64   usage error or invalid option value
102  130  interrupted; nothing written";
103
104#[derive(Parser)]
105#[command(
106    name = "formualizer",
107    version,
108    about = "Recalculate XLSX formula caches without reconstructing the workbook"
109)]
110struct Cli {
111    #[command(subcommand)]
112    command: Command,
113}
114#[derive(Subcommand)]
115enum Command {
116    /// Recalculate formula caches using the strict source-preserving path.
117    #[command(after_help = RECALC_AFTER_HELP)]
118    Recalc {
119        /// Workbook (.xlsx) to recalculate
120        input: PathBuf,
121        /// Write the result to PATH instead of replacing INPUT
122        #[arg(short, long, value_name = "PATH")]
123        output: Option<PathBuf>,
124        /// Compute without writing; exit 3 if any cache is stale
125        #[arg(long)]
126        check: bool,
127        /// Print one formualizer.recalc/1 JSON object on stdout
128        #[arg(long)]
129        json: bool,
130        /// List at most N error-cell locations
131        #[arg(long, value_name = "N", default_value_t = DEFAULT_MAX_ERRORS)]
132        max_errors: usize,
133        /// Fixed TODAY/NOW instant: RFC 3339 with an offset or Z
134        #[arg(long, value_name = "TIMESTAMP", value_parser = parse_now)]
135        now: Option<DateTime<FixedOffset>>,
136        /// TODAY/NOW timezone: UTC or ±HH:MM [default: --now offset, else local]
137        #[arg(long, value_name = "ZONE", value_parser = parse_tz, allow_hyphen_values = true)]
138        tz: Option<TimeZoneSpec>,
139        /// RAND/RANDBETWEEN seed [default: built-in seed]
140        #[arg(long, value_name = "U64")]
141        seed: Option<u64>,
142        /// Read the external link values cached in the workbook, or refuse (exit 2)
143        #[arg(long, value_name = "POLICY", value_enum, default_value_t = LinkPolicyArg::Cached)]
144        external_links: LinkPolicyArg,
145    },
146}
147/// `--external-links`: links are never refreshed under either policy.
148#[derive(Clone, Copy, ValueEnum)]
149enum LinkPolicyArg {
150    Cached,
151    Refuse,
152}
153impl LinkPolicyArg {
154    fn policy(self) -> ExternalLinkPolicy {
155        match self {
156            LinkPolicyArg::Cached => ExternalLinkPolicy::Cached,
157            LinkPolicyArg::Refuse => ExternalLinkPolicy::Refuse,
158        }
159    }
160}
161/// The refusal feature `refuse` reports when a formula reads a link value.
162const EXTERNAL_LINK_VALUES: &str = "external link values";
163#[derive(Serialize)]
164struct ErrorCell {
165    sheet: String,
166    cell: String,
167    error: String,
168    /// The engine's reason, e.g. `Unknown function: SPDVOL`.
169    message: Option<String>,
170}
171/// A function the engine does not implement and the error cells naming it.
172#[derive(Serialize)]
173struct UnknownFunction {
174    name: String,
175    cells: usize,
176}
177#[derive(Serialize)]
178struct Refusal {
179    feature: String,
180    context: String,
181}
182/// The clock a computed run used: enough, with `seed`, to replay it.
183#[derive(Serialize)]
184struct Clock {
185    /// RFC 3339 instant TODAY/NOW observed, written in the UTC offset that
186    /// was applied to it (so `--now <now>` alone replays it); null when the
187    /// build has no system clock and `--now` was not given.
188    now: Option<String>,
189    /// `Local`, `UTC` or `±HH:MM`.
190    timezone: String,
191    /// True when the instant came from `--now`.
192    fixed: bool,
193}
194/// External link values the calculation read: always the values Excel last
195/// stored in the workbook, never refreshed from the linked files.
196#[derive(Serialize)]
197struct ExternalLinks {
198    /// Links whose cached values a formula or used defined name read.
199    links_used: usize,
200    /// Always false: links are never refreshed.
201    refreshed: bool,
202    /// The `--external-links` policy applied: always `cached` here, since
203    /// `refuse` refuses instead of reading link values.
204    policy: &'static str,
205}
206#[derive(Serialize)]
207struct Report {
208    schema: &'static str,
209    status: &'static str,
210    input: Option<String>,
211    output: Option<String>,
212    written: bool,
213    formula_cells: Option<usize>,
214    cache_cells_changed: Option<usize>,
215    worksheet_parts_changed: Option<usize>,
216    error_cells: Option<usize>,
217    errors: Option<Vec<ErrorCell>>,
218    errors_truncated: Option<bool>,
219    unknown_functions: Option<Vec<UnknownFunction>>,
220    refusal: Option<Refusal>,
221    clock: Option<Clock>,
222    seed: Option<u64>,
223    /// Present only when a computed run read cached external link values.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    external_links: Option<ExternalLinks>,
226    message: String,
227}
228impl Report {
229    fn new(status: &'static str, message: String) -> Self {
230        Self {
231            schema: SCHEMA,
232            status,
233            input: None,
234            output: None,
235            written: false,
236            formula_cells: None,
237            cache_cells_changed: None,
238            worksheet_parts_changed: None,
239            error_cells: None,
240            errors: None,
241            errors_truncated: None,
242            unknown_functions: None,
243            refusal: None,
244            clock: None,
245            seed: None,
246            external_links: None,
247            message,
248        }
249    }
250    fn result(&mut self, result: &XlsxRecalculateResult, limit: usize) {
251        self.formula_cells = Some(result.formula_cells);
252        self.cache_cells_changed = Some(result.cache_cells_changed);
253        self.worksheet_parts_changed = Some(result.worksheet_parts_changed);
254        self.error_cells = Some(result.summary.errors);
255        self.external_links = (result.external_links_used > 0).then_some(ExternalLinks {
256            links_used: result.external_links_used,
257            refreshed: false,
258            policy: "cached",
259        });
260        let errors: Vec<_> = result
261            .summary
262            .error_summary
263            .iter()
264            .flat_map(|(error, summary)| {
265                let messages = summary
266                    .messages
267                    .iter()
268                    .map(Some)
269                    .chain(std::iter::repeat(None));
270                summary
271                    .locations
272                    .iter()
273                    .zip(messages)
274                    .filter_map(move |(location, message)| {
275                        // Sheet names themselves may contain '!'. The final separator is the cell.
276                        let (sheet, cell) = location.rsplit_once('!')?;
277                        Some(ErrorCell {
278                            sheet: sheet.into(),
279                            cell: cell.into(),
280                            error: error.clone(),
281                            message: message.cloned().flatten(),
282                        })
283                    })
284            })
285            .take(limit)
286            .collect();
287        self.errors_truncated = Some(errors.len() < result.summary.errors);
288        self.errors = Some(errors);
289        self.unknown_functions = Some(
290            result
291                .summary
292                .unknown_functions
293                .iter()
294                .map(|(name, cells)| UnknownFunction {
295                    name: name.clone(),
296                    cells: *cells,
297                })
298                .collect(),
299        );
300    }
301}
302
303fn parse_now(value: &str) -> Result<DateTime<FixedOffset>, String> {
304    DateTime::parse_from_rfc3339(value).map_err(|_| {
305        "expected an RFC 3339 timestamp with an offset or Z, e.g. 2026-01-31T09:00:00Z".into()
306    })
307}
308fn offset_zone(seconds: i32) -> TimeZoneSpec {
309    if seconds == 0 {
310        TimeZoneSpec::Utc
311    } else {
312        TimeZoneSpec::FixedOffsetSeconds(seconds)
313    }
314}
315fn parse_tz(value: &str) -> Result<TimeZoneSpec, String> {
316    if value.eq_ignore_ascii_case("utc") || value == "Z" {
317        return Ok(TimeZoneSpec::Utc);
318    }
319    let bytes = value.as_bytes();
320    let digits = |r: std::ops::Range<usize>| {
321        bytes[r.clone()]
322            .iter()
323            .all(u8::is_ascii_digit)
324            .then(|| value[r].parse::<i32>().ok())
325            .flatten()
326    };
327    if bytes.len() == 6
328        && matches!(bytes[0], b'+' | b'-')
329        && bytes[3] == b':'
330        && let (Some(h), Some(m)) = (digits(1..3), digits(4..6))
331        && h <= 23
332        && m <= 59
333    {
334        let seconds = (h * 60 + m) * 60;
335        return Ok(offset_zone(if bytes[0] == b'-' {
336            -seconds
337        } else {
338            seconds
339        }));
340    }
341    Err("expected UTC or an offset ±HH:MM, e.g. +02:00 or -05:00".into())
342}
343/// The UTC offset `zone` applies at `now` (for `Local`, the host's offset
344/// at that instant, as the engine's clock computes it).
345fn applied_offset(now: DateTime<Utc>, zone: &TimeZoneSpec) -> FixedOffset {
346    zone.fixed_offset()
347        .unwrap_or_else(|| *now.with_timezone(&Local).offset())
348}
349fn zone_label(zone: &TimeZoneSpec) -> String {
350    match zone {
351        TimeZoneSpec::Local => "Local".into(),
352        TimeZoneSpec::Utc => "UTC".into(),
353        TimeZoneSpec::FixedOffsetSeconds(seconds) => {
354            let sign = if *seconds < 0 { '-' } else { '+' };
355            let s = seconds.unsigned_abs();
356            let (h, m, rest) = (s / 3600, s / 60 % 60, s % 60);
357            if rest == 0 {
358                format!("{sign}{h:02}:{m:02}")
359            } else {
360                format!("{sign}{h:02}:{m:02}:{rest:02}")
361            }
362        }
363    }
364}
365
366fn emit(
367    report: &Report,
368    json: bool,
369    code: i32,
370    stdout: &mut dyn Write,
371    stderr: &mut dyn Write,
372) -> i32 {
373    let result = if json {
374        serde_json::to_writer(&mut *stdout, report)
375            .map_err(std::io::Error::other)
376            .and_then(|_| writeln!(stdout))
377    } else if code == 0 || code == 3 {
378        writeln!(stdout, "{}", report.message)
379    } else {
380        writeln!(stderr, "{}", report.message)
381    };
382    if result.is_err() { 1 } else { code }
383}
384
385// All structured strict-path refusals remain refusals. A structured category in
386// the library would allow malformed packages and limits to be refined later.
387fn classify(error: &IoError) -> (&'static str, i32) {
388    match error {
389        IoError::Engine(e) if e.kind == formualizer_common::ExcelErrorKind::Cancelled => {
390            ("interrupted", 130)
391        }
392        IoError::Unsupported { .. } => ("refused", 2),
393        _ => ("error", 1),
394    }
395}
396
397/// Runs the command and returns its exit code. `args` includes `argv[0]`.
398/// Bindings should disable default features, pass `None` or their own shared
399/// cancellation token, and capture stdout/stderr independently. No process exit
400/// or signal-handler installation occurs in this function.
401pub fn run<I, T>(
402    args: I,
403    stdout: &mut dyn Write,
404    stderr: &mut dyn Write,
405    cancel: Option<CancelToken>,
406) -> i32
407where
408    I: IntoIterator<Item = T>,
409    T: Into<OsString> + Clone,
410{
411    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
412    // Even clap failures must have machine-readable output when requested.
413    let json_requested = args
414        .iter()
415        .take_while(|arg| *arg != "--")
416        .any(|arg| arg == "--json");
417    let cli = match Cli::try_parse_from(args) {
418        Ok(cli) => cli,
419        Err(error) => {
420            use clap::error::ErrorKind;
421            if matches!(
422                error.kind(),
423                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
424            ) {
425                return if write!(stdout, "{error}").is_ok() {
426                    0
427                } else {
428                    1
429                };
430            }
431            if !json_requested {
432                return if write!(stderr, "{error}").is_ok() {
433                    64
434                } else {
435                    1
436                };
437            }
438            let message = error.to_string().replace('\n', " ").trim().to_owned();
439            return emit(
440                &Report::new("error", message),
441                json_requested,
442                64,
443                stdout,
444                stderr,
445            );
446        }
447    };
448    let Command::Recalc {
449        input,
450        output,
451        check,
452        json,
453        max_errors,
454        now,
455        tz,
456        seed,
457        external_links,
458    } = cli.command;
459    let mut options = XlsxRecalculateOptions {
460        cancel: cancel.clone(),
461        error_location_limit: max_errors,
462        external_links: external_links.policy(),
463        ..Default::default()
464    };
465    // No flags leave the default configuration (system clock, local time,
466    // built-in seed) untouched.
467    if now.is_some() || tz.is_some() {
468        let timezone = tz.unwrap_or_else(|| {
469            now.map_or(TimeZoneSpec::Local, |n| {
470                offset_zone(n.offset().local_minus_utc())
471            })
472        });
473        options.eval_config.deterministic_mode = match now {
474            Some(now) => DeterministicMode::Enabled {
475                timestamp_utc: now.with_timezone(&Utc),
476                timezone,
477            },
478            None => DeterministicMode::Disabled { timezone },
479        };
480    }
481    if let Some(seed) = seed {
482        options.eval_config.workbook_seed = seed;
483    }
484    let zone = options.eval_config.deterministic_mode.timezone().clone();
485    let zone = &zone;
486    let seed = options.eval_config.workbook_seed;
487    let result = catch_internal_panic(|| -> Result<XlsxRecalculateResult, IoError> {
488        #[cfg(test)]
489        if INJECT_PANIC.with(|inject| inject.replace(false)) {
490            panic!("injected test panic");
491        }
492        if cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
493            return Err(IoError::Engine(formualizer_common::ExcelError::new(
494                formualizer_common::ExcelErrorKind::Cancelled,
495            )));
496        }
497        let mut file = std::fs::File::open(&input)?;
498        let mut prefix = [0; 4];
499        if file.read_exact(&mut prefix).is_err() || prefix != *b"PK\x03\x04" {
500            return Err(IoError::Io(std::io::Error::new(
501                std::io::ErrorKind::InvalidData,
502                "not an xlsx (ZIP) file",
503            )));
504        }
505        if check {
506            let mut source = prefix.to_vec();
507            file.take(
508                (options.limits.max_input_bytes as u64)
509                    .saturating_add(1)
510                    .saturating_sub(4),
511            )
512            .read_to_end(&mut source)?;
513            recalculate_xlsx_bytes(&source, options)
514        } else {
515            recalculate_xlsx_file(&input, output.as_deref(), options)
516        }
517    });
518    let mut report = Report::new("error", String::new());
519    report.input = Some(input.to_string_lossy().into_owned());
520    report.output = if check {
521        None
522    } else {
523        Some(
524            output
525                .as_deref()
526                .unwrap_or(&input)
527                .to_string_lossy()
528                .into_owned(),
529        )
530    };
531    let code = match result {
532        Ok(result) => {
533            // Metadata publication only accompanies worksheet changes. Cache
534            // counts alone would miss attribute-only dynamic-array changes.
535            let changed = result.worksheet_parts_changed != 0;
536            let code = if check && changed { 3 } else { 0 };
537            report.written = !check && (output.is_some() || changed);
538            report.status = if check {
539                if changed { "stale" } else { "current" }
540            } else if report.written {
541                "written"
542            } else {
543                "unchanged"
544            };
545            report.result(&result, max_errors);
546            report.clock = Some(Clock {
547                now: result.clock_now_utc.map(|now| {
548                    now.with_timezone(&applied_offset(now, zone))
549                        .to_rfc3339_opts(SecondsFormat::AutoSi, true)
550                }),
551                timezone: zone_label(zone),
552                fixed: now.is_some(),
553            });
554            report.seed = Some(seed);
555            let locations = report
556                .errors
557                .as_ref()
558                .unwrap()
559                .iter()
560                .map(|e| match &e.message {
561                    Some(message) => format!("{}!{} {} ({message})", e.sheet, e.cell, e.error),
562                    None => format!("{}!{} {}", e.sheet, e.cell, e.error),
563                })
564                .collect::<Vec<_>>()
565                .join(", ");
566            let unknown = report
567                .unknown_functions
568                .as_ref()
569                .unwrap()
570                .iter()
571                .map(|f| {
572                    format!(
573                        "{} ({} cell{})",
574                        f.name,
575                        f.cells,
576                        if f.cells == 1 { "" } else { "s" }
577                    )
578                })
579                .collect::<Vec<_>>()
580                .join(", ");
581            report.message = format!(
582                "{}: recalculated {} formulas, {} cached values changed, {} error cells{} ({})",
583                input.display(),
584                result.formula_cells,
585                result.cache_cells_changed,
586                result.summary.errors,
587                if locations.is_empty() {
588                    String::new()
589                } else {
590                    format!(" ({locations})")
591                },
592                report.status
593            );
594            if !unknown.is_empty() {
595                report.message.push_str(&format!(
596                    "\nunknown functions (cells produce #NAME?): {unknown}"
597                ));
598            }
599            if let Some(links) = &report.external_links {
600                report.message.push_str(&format!(
601                    "\nexternal links: used the values cached in the workbook for {} link{} (not refreshed)",
602                    links.links_used,
603                    if links.links_used == 1 { "" } else { "s" }
604                ));
605            }
606            code
607        }
608        Err(error) => {
609            let (status, code) = classify(&error);
610            report.status = status;
611            report.message = format!("{}: {error}. Nothing was written.", input.display());
612            if let IoError::Unsupported { feature, .. } = &error
613                && feature == EXTERNAL_LINK_VALUES
614            {
615                report
616                    .message
617                    .push_str(" Pass --external-links cached to recalculate with them.");
618            }
619            if let IoError::Unsupported { feature, context } = error {
620                report.refusal = Some(Refusal { feature, context });
621            }
622            code
623        }
624    };
625    emit(&report, json, code, stdout, stderr)
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    fn run_with_injected_panic(json: bool) -> (i32, String, String) {
633        INJECT_PANIC.with(|inject| inject.set(true));
634        let mut args = vec!["formualizer", "recalc", "in.xlsx"];
635        if json {
636            args.push("--json");
637        }
638        let (mut out, mut err) = (Vec::new(), Vec::new());
639        let code = run(args, &mut out, &mut err, None);
640        (
641            code,
642            String::from_utf8(out).unwrap(),
643            String::from_utf8(err).unwrap(),
644        )
645    }
646
647    /// A panic on a worker thread, re-raised on the caller with its payload
648    /// the way rayon does: the message keeps the payload, does not borrow a
649    /// location from another thread, and the worker's own record is its own.
650    #[test]
651    fn a_worker_panic_reraised_on_the_caller_reports_its_payload() {
652        // An unrelated panic on another thread first: it must not leak in.
653        let _ = std::thread::spawn(|| {
654            let _ = catch_internal_panic::<()>(|| panic!("unrelated"));
655        })
656        .join();
657        let result = catch_internal_panic::<()>(|| {
658            let payload = std::thread::spawn(|| panic!("worker failed"))
659                .join()
660                .unwrap_err();
661            std::panic::resume_unwind(payload)
662        });
663        let message = result.unwrap_err().to_string();
664        assert!(message.contains("worker failed"), "{message}");
665        assert!(
666            message.contains("a location on another thread"),
667            "{message}"
668        );
669        assert!(!message.contains("unrelated"), "{message}");
670
671        // On the calling thread itself the location is recorded.
672        let message = catch_internal_panic::<()>(|| panic!("here"))
673            .unwrap_err()
674            .to_string();
675        assert!(message.contains(file!()), "{message}");
676    }
677
678    #[test]
679    fn a_panic_is_the_internal_error_result_with_its_location() {
680        let (code, out, _) = run_with_injected_panic(true);
681        assert_eq!(code, 1);
682        let report: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
683        assert_eq!(report["schema"], "formualizer.recalc/1");
684        assert_eq!(report["status"], "error");
685        assert_eq!(report["written"], false);
686        let message = report["message"].as_str().unwrap();
687        assert!(message.contains("internal error: panicked at"), "{message}");
688        assert!(message.contains(file!()), "{message}");
689        assert!(message.contains("injected test panic"), "{message}");
690
691        let (code, out, err) = run_with_injected_panic(false);
692        assert_eq!(code, 1);
693        assert!(out.is_empty());
694        assert!(err.contains("internal error: panicked at"), "{err}");
695    }
696    #[test]
697    fn structured_error_classification() {
698        use formualizer_common::{ExcelError, ExcelErrorKind};
699        for (error, status, code) in [
700            (
701                IoError::Unsupported {
702                    feature: "table metadata".into(),
703                    context: "Sheet1".into(),
704                },
705                "refused",
706                2,
707            ),
708            (
709                IoError::Unsupported {
710                    feature: "ambiguous/missing ZIP footer".into(),
711                    context: "XLSX package".into(),
712                },
713                "refused",
714                2,
715            ),
716            (
717                IoError::Unsupported {
718                    feature: "input size limit".into(),
719                    context: "XLSX package".into(),
720                },
721                "refused",
722                2,
723            ),
724            (
725                IoError::Engine(ExcelError::new(ExcelErrorKind::Cancelled)),
726                "interrupted",
727                130,
728            ),
729            (
730                IoError::Engine(ExcelError::new(ExcelErrorKind::Value)),
731                "error",
732                1,
733            ),
734            (IoError::Io(std::io::Error::other("I/O")), "error", 1),
735            (
736                IoError::Backend {
737                    backend: "zip".into(),
738                    message: "invalid".into(),
739                },
740                "error",
741                1,
742            ),
743        ] {
744            assert_eq!(classify(&error), (status, code));
745        }
746    }
747}