1use chrono::{DateTime, FixedOffset, Local, SecondsFormat, Utc};
4use clap::{Parser, Subcommand};
5use formualizer_eval::{engine::DeterministicMode, timezone::TimeZoneSpec};
6pub use formualizer_workbook::CancelToken;
7use formualizer_workbook::{
8 IoError, XlsxRecalculateOptions, XlsxRecalculateResult, recalculate_xlsx_bytes,
9 recalculate_xlsx_file,
10};
11use serde::Serialize;
12use std::{
13 ffi::OsString,
14 io::{Read, Write},
15 path::PathBuf,
16};
17
18const SCHEMA: &str = "formualizer.recalc/1";
19const DEFAULT_MAX_ERRORS: usize = 20;
20const RECALC_AFTER_HELP: &str = "\
21Writes INPUT in place unless -o or --check is given.
22Run recalc as the last step that writes the workbook: later edits leave its
23caches stale.
24
25RAND/RANDBETWEEN are reproducible run to run. Without --now, TODAY/NOW use
26the host clock (local time unless --tz).
27
28Exit codes:
29 0 written, unchanged or current
30 1 error; nothing written
31 2 refused (unsupported input); nothing written
32 3 --check: caches are stale
33 64 usage error or invalid option value
34 130 interrupted; nothing written";
35
36#[derive(Parser)]
37#[command(
38 name = "formualizer",
39 version,
40 about = "Recalculate XLSX formula caches without reconstructing the workbook"
41)]
42struct Cli {
43 #[command(subcommand)]
44 command: Command,
45}
46#[derive(Subcommand)]
47enum Command {
48 #[command(after_help = RECALC_AFTER_HELP)]
50 Recalc {
51 input: PathBuf,
53 #[arg(short, long, value_name = "PATH")]
55 output: Option<PathBuf>,
56 #[arg(long)]
58 check: bool,
59 #[arg(long)]
61 json: bool,
62 #[arg(long, value_name = "N", default_value_t = DEFAULT_MAX_ERRORS)]
64 max_errors: usize,
65 #[arg(long, value_name = "TIMESTAMP", value_parser = parse_now)]
67 now: Option<DateTime<FixedOffset>>,
68 #[arg(long, value_name = "ZONE", value_parser = parse_tz, allow_hyphen_values = true)]
70 tz: Option<TimeZoneSpec>,
71 #[arg(long, value_name = "U64")]
73 seed: Option<u64>,
74 },
75}
76#[derive(Serialize)]
77struct ErrorCell {
78 sheet: String,
79 cell: String,
80 error: String,
81 message: Option<String>,
83}
84#[derive(Serialize)]
86struct UnknownFunction {
87 name: String,
88 cells: usize,
89}
90#[derive(Serialize)]
91struct Refusal {
92 feature: String,
93 context: String,
94}
95#[derive(Serialize)]
97struct Clock {
98 now: Option<String>,
102 timezone: String,
104 fixed: bool,
106}
107#[derive(Serialize)]
108struct Report {
109 schema: &'static str,
110 status: &'static str,
111 input: Option<String>,
112 output: Option<String>,
113 written: bool,
114 formula_cells: Option<usize>,
115 cache_cells_changed: Option<usize>,
116 worksheet_parts_changed: Option<usize>,
117 error_cells: Option<usize>,
118 errors: Option<Vec<ErrorCell>>,
119 errors_truncated: Option<bool>,
120 unknown_functions: Option<Vec<UnknownFunction>>,
121 refusal: Option<Refusal>,
122 clock: Option<Clock>,
123 seed: Option<u64>,
124 message: String,
125}
126impl Report {
127 fn new(status: &'static str, message: String) -> Self {
128 Self {
129 schema: SCHEMA,
130 status,
131 input: None,
132 output: None,
133 written: false,
134 formula_cells: None,
135 cache_cells_changed: None,
136 worksheet_parts_changed: None,
137 error_cells: None,
138 errors: None,
139 errors_truncated: None,
140 unknown_functions: None,
141 refusal: None,
142 clock: None,
143 seed: None,
144 message,
145 }
146 }
147 fn result(&mut self, result: &XlsxRecalculateResult, limit: usize) {
148 self.formula_cells = Some(result.formula_cells);
149 self.cache_cells_changed = Some(result.cache_cells_changed);
150 self.worksheet_parts_changed = Some(result.worksheet_parts_changed);
151 self.error_cells = Some(result.summary.errors);
152 let errors: Vec<_> = result
153 .summary
154 .error_summary
155 .iter()
156 .flat_map(|(error, summary)| {
157 let messages = summary
158 .messages
159 .iter()
160 .map(Some)
161 .chain(std::iter::repeat(None));
162 summary
163 .locations
164 .iter()
165 .zip(messages)
166 .filter_map(move |(location, message)| {
167 let (sheet, cell) = location.rsplit_once('!')?;
169 Some(ErrorCell {
170 sheet: sheet.into(),
171 cell: cell.into(),
172 error: error.clone(),
173 message: message.cloned().flatten(),
174 })
175 })
176 })
177 .take(limit)
178 .collect();
179 self.errors_truncated = Some(errors.len() < result.summary.errors);
180 self.errors = Some(errors);
181 self.unknown_functions = Some(
182 result
183 .summary
184 .unknown_functions
185 .iter()
186 .map(|(name, cells)| UnknownFunction {
187 name: name.clone(),
188 cells: *cells,
189 })
190 .collect(),
191 );
192 }
193}
194
195fn parse_now(value: &str) -> Result<DateTime<FixedOffset>, String> {
196 DateTime::parse_from_rfc3339(value).map_err(|_| {
197 "expected an RFC 3339 timestamp with an offset or Z, e.g. 2026-01-31T09:00:00Z".into()
198 })
199}
200fn offset_zone(seconds: i32) -> TimeZoneSpec {
201 if seconds == 0 {
202 TimeZoneSpec::Utc
203 } else {
204 TimeZoneSpec::FixedOffsetSeconds(seconds)
205 }
206}
207fn parse_tz(value: &str) -> Result<TimeZoneSpec, String> {
208 if value.eq_ignore_ascii_case("utc") || value == "Z" {
209 return Ok(TimeZoneSpec::Utc);
210 }
211 let bytes = value.as_bytes();
212 let digits = |r: std::ops::Range<usize>| {
213 bytes[r.clone()]
214 .iter()
215 .all(u8::is_ascii_digit)
216 .then(|| value[r].parse::<i32>().ok())
217 .flatten()
218 };
219 if bytes.len() == 6
220 && matches!(bytes[0], b'+' | b'-')
221 && bytes[3] == b':'
222 && let (Some(h), Some(m)) = (digits(1..3), digits(4..6))
223 && h <= 23
224 && m <= 59
225 {
226 let seconds = (h * 60 + m) * 60;
227 return Ok(offset_zone(if bytes[0] == b'-' {
228 -seconds
229 } else {
230 seconds
231 }));
232 }
233 Err("expected UTC or an offset ±HH:MM, e.g. +02:00 or -05:00".into())
234}
235fn applied_offset(now: DateTime<Utc>, zone: &TimeZoneSpec) -> FixedOffset {
238 zone.fixed_offset()
239 .unwrap_or_else(|| *now.with_timezone(&Local).offset())
240}
241fn zone_label(zone: &TimeZoneSpec) -> String {
242 match zone {
243 TimeZoneSpec::Local => "Local".into(),
244 TimeZoneSpec::Utc => "UTC".into(),
245 TimeZoneSpec::FixedOffsetSeconds(seconds) => {
246 let sign = if *seconds < 0 { '-' } else { '+' };
247 let s = seconds.unsigned_abs();
248 let (h, m, rest) = (s / 3600, s / 60 % 60, s % 60);
249 if rest == 0 {
250 format!("{sign}{h:02}:{m:02}")
251 } else {
252 format!("{sign}{h:02}:{m:02}:{rest:02}")
253 }
254 }
255 }
256}
257
258fn emit(
259 report: &Report,
260 json: bool,
261 code: i32,
262 stdout: &mut dyn Write,
263 stderr: &mut dyn Write,
264) -> i32 {
265 let result = if json {
266 serde_json::to_writer(&mut *stdout, report)
267 .map_err(std::io::Error::other)
268 .and_then(|_| writeln!(stdout))
269 } else if code == 0 || code == 3 {
270 writeln!(stdout, "{}", report.message)
271 } else {
272 writeln!(stderr, "{}", report.message)
273 };
274 if result.is_err() { 1 } else { code }
275}
276
277fn classify(error: &IoError) -> (&'static str, i32) {
280 match error {
281 IoError::Engine(e) if e.kind == formualizer_common::ExcelErrorKind::Cancelled => {
282 ("interrupted", 130)
283 }
284 IoError::Unsupported { .. } => ("refused", 2),
285 _ => ("error", 1),
286 }
287}
288
289pub fn run<I, T>(
294 args: I,
295 stdout: &mut dyn Write,
296 stderr: &mut dyn Write,
297 cancel: Option<CancelToken>,
298) -> i32
299where
300 I: IntoIterator<Item = T>,
301 T: Into<OsString> + Clone,
302{
303 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
304 let json_requested = args
306 .iter()
307 .take_while(|arg| *arg != "--")
308 .any(|arg| arg == "--json");
309 let cli = match Cli::try_parse_from(args) {
310 Ok(cli) => cli,
311 Err(error) => {
312 use clap::error::ErrorKind;
313 if matches!(
314 error.kind(),
315 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
316 ) {
317 return if write!(stdout, "{error}").is_ok() {
318 0
319 } else {
320 1
321 };
322 }
323 if !json_requested {
324 return if write!(stderr, "{error}").is_ok() {
325 64
326 } else {
327 1
328 };
329 }
330 let message = error.to_string().replace('\n', " ").trim().to_owned();
331 return emit(
332 &Report::new("error", message),
333 json_requested,
334 64,
335 stdout,
336 stderr,
337 );
338 }
339 };
340 let Command::Recalc {
341 input,
342 output,
343 check,
344 json,
345 max_errors,
346 now,
347 tz,
348 seed,
349 } = cli.command;
350 let mut options = XlsxRecalculateOptions {
351 cancel: cancel.clone(),
352 error_location_limit: max_errors,
353 ..Default::default()
354 };
355 if now.is_some() || tz.is_some() {
358 let timezone = tz.unwrap_or_else(|| {
359 now.map_or(TimeZoneSpec::Local, |n| {
360 offset_zone(n.offset().local_minus_utc())
361 })
362 });
363 options.eval_config.deterministic_mode = match now {
364 Some(now) => DeterministicMode::Enabled {
365 timestamp_utc: now.with_timezone(&Utc),
366 timezone,
367 },
368 None => DeterministicMode::Disabled { timezone },
369 };
370 }
371 if let Some(seed) = seed {
372 options.eval_config.workbook_seed = seed;
373 }
374 let zone = options.eval_config.deterministic_mode.timezone().clone();
375 let zone = &zone;
376 let seed = options.eval_config.workbook_seed;
377 let result = (|| -> Result<XlsxRecalculateResult, IoError> {
378 if cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
379 return Err(IoError::Engine(formualizer_common::ExcelError::new(
380 formualizer_common::ExcelErrorKind::Cancelled,
381 )));
382 }
383 let mut file = std::fs::File::open(&input)?;
384 let mut prefix = [0; 4];
385 if file.read_exact(&mut prefix).is_err() || prefix != *b"PK\x03\x04" {
386 return Err(IoError::Io(std::io::Error::new(
387 std::io::ErrorKind::InvalidData,
388 "not an xlsx (ZIP) file",
389 )));
390 }
391 if check {
392 let mut source = prefix.to_vec();
393 file.take(
394 (options.limits.max_input_bytes as u64)
395 .saturating_add(1)
396 .saturating_sub(4),
397 )
398 .read_to_end(&mut source)?;
399 recalculate_xlsx_bytes(&source, options)
400 } else {
401 recalculate_xlsx_file(&input, output.as_deref(), options)
402 }
403 })();
404 let mut report = Report::new("error", String::new());
405 report.input = Some(input.to_string_lossy().into_owned());
406 report.output = if check {
407 None
408 } else {
409 Some(
410 output
411 .as_deref()
412 .unwrap_or(&input)
413 .to_string_lossy()
414 .into_owned(),
415 )
416 };
417 let code = match result {
418 Ok(result) => {
419 let changed = result.worksheet_parts_changed != 0;
422 let code = if check && changed { 3 } else { 0 };
423 report.written = !check && (output.is_some() || changed);
424 report.status = if check {
425 if changed { "stale" } else { "current" }
426 } else if report.written {
427 "written"
428 } else {
429 "unchanged"
430 };
431 report.result(&result, max_errors);
432 report.clock = Some(Clock {
433 now: result.clock_now_utc.map(|now| {
434 now.with_timezone(&applied_offset(now, zone))
435 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
436 }),
437 timezone: zone_label(zone),
438 fixed: now.is_some(),
439 });
440 report.seed = Some(seed);
441 let locations = report
442 .errors
443 .as_ref()
444 .unwrap()
445 .iter()
446 .map(|e| match &e.message {
447 Some(message) => format!("{}!{} {} ({message})", e.sheet, e.cell, e.error),
448 None => format!("{}!{} {}", e.sheet, e.cell, e.error),
449 })
450 .collect::<Vec<_>>()
451 .join(", ");
452 let unknown = report
453 .unknown_functions
454 .as_ref()
455 .unwrap()
456 .iter()
457 .map(|f| {
458 format!(
459 "{} ({} cell{})",
460 f.name,
461 f.cells,
462 if f.cells == 1 { "" } else { "s" }
463 )
464 })
465 .collect::<Vec<_>>()
466 .join(", ");
467 report.message = format!(
468 "{}: recalculated {} formulas, {} cached values changed, {} error cells{} ({})",
469 input.display(),
470 result.formula_cells,
471 result.cache_cells_changed,
472 result.summary.errors,
473 if locations.is_empty() {
474 String::new()
475 } else {
476 format!(" ({locations})")
477 },
478 report.status
479 );
480 if !unknown.is_empty() {
481 report.message.push_str(&format!(
482 "\nunknown functions (cells produce #NAME?): {unknown}"
483 ));
484 }
485 code
486 }
487 Err(error) => {
488 let (status, code) = classify(&error);
489 report.status = status;
490 report.message = format!("{}: {error}. Nothing was written.", input.display());
491 if let IoError::Unsupported { feature, context } = error {
492 report.refusal = Some(Refusal { feature, context });
493 }
494 code
495 }
496 };
497 emit(&report, json, code, stdout, stderr)
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 #[test]
504 fn structured_error_classification() {
505 use formualizer_common::{ExcelError, ExcelErrorKind};
506 for (error, status, code) in [
507 (
508 IoError::Unsupported {
509 feature: "table metadata".into(),
510 context: "Sheet1".into(),
511 },
512 "refused",
513 2,
514 ),
515 (
516 IoError::Unsupported {
517 feature: "ambiguous/missing ZIP footer".into(),
518 context: "XLSX package".into(),
519 },
520 "refused",
521 2,
522 ),
523 (
524 IoError::Unsupported {
525 feature: "input size limit".into(),
526 context: "XLSX package".into(),
527 },
528 "refused",
529 2,
530 ),
531 (
532 IoError::Engine(ExcelError::new(ExcelErrorKind::Cancelled)),
533 "interrupted",
534 130,
535 ),
536 (
537 IoError::Engine(ExcelError::new(ExcelErrorKind::Value)),
538 "error",
539 1,
540 ),
541 (IoError::Io(std::io::Error::other("I/O")), "error", 1),
542 (
543 IoError::Backend {
544 backend: "zip".into(),
545 message: "invalid".into(),
546 },
547 "error",
548 1,
549 ),
550 ] {
551 assert_eq!(classify(&error), (status, code));
552 }
553 }
554}