1use 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 static LAST_PANIC: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
25}
26static PANIC_HOOK: Once = Once::new();
27
28fn 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 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 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 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 #[command(after_help = RECALC_AFTER_HELP)]
118 Recalc {
119 input: PathBuf,
121 #[arg(short, long, value_name = "PATH")]
123 output: Option<PathBuf>,
124 #[arg(long)]
126 check: bool,
127 #[arg(long)]
129 json: bool,
130 #[arg(long, value_name = "N", default_value_t = DEFAULT_MAX_ERRORS)]
132 max_errors: usize,
133 #[arg(long, value_name = "TIMESTAMP", value_parser = parse_now)]
135 now: Option<DateTime<FixedOffset>>,
136 #[arg(long, value_name = "ZONE", value_parser = parse_tz, allow_hyphen_values = true)]
138 tz: Option<TimeZoneSpec>,
139 #[arg(long, value_name = "U64")]
141 seed: Option<u64>,
142 #[arg(long, value_name = "POLICY", value_enum, default_value_t = LinkPolicyArg::Cached)]
144 external_links: LinkPolicyArg,
145 },
146}
147#[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}
161const EXTERNAL_LINK_VALUES: &str = "external link values";
163#[derive(Serialize)]
164struct ErrorCell {
165 sheet: String,
166 cell: String,
167 error: String,
168 message: Option<String>,
170}
171#[derive(Serialize)]
173struct UnknownFunction {
174 name: String,
175 cells: usize,
176}
177#[derive(Serialize)]
178struct Refusal {
179 feature: String,
180 context: String,
181}
182#[derive(Serialize)]
184struct Clock {
185 now: Option<String>,
189 timezone: String,
191 fixed: bool,
193}
194#[derive(Serialize)]
197struct ExternalLinks {
198 links_used: usize,
200 refreshed: bool,
202 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 #[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 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}
343fn 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
385fn 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
397pub 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 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 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 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 #[test]
651 fn a_worker_panic_reraised_on_the_caller_reports_its_payload() {
652 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 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}