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};
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    /// Recalculate formula caches using the strict source-preserving path.
49    #[command(after_help = RECALC_AFTER_HELP)]
50    Recalc {
51        /// Workbook (.xlsx) to recalculate
52        input: PathBuf,
53        /// Write the result to PATH instead of replacing INPUT
54        #[arg(short, long, value_name = "PATH")]
55        output: Option<PathBuf>,
56        /// Compute without writing; exit 3 if any cache is stale
57        #[arg(long)]
58        check: bool,
59        /// Print one formualizer.recalc/1 JSON object on stdout
60        #[arg(long)]
61        json: bool,
62        /// List at most N error-cell locations
63        #[arg(long, value_name = "N", default_value_t = DEFAULT_MAX_ERRORS)]
64        max_errors: usize,
65        /// Fixed TODAY/NOW instant: RFC 3339 with an offset or Z
66        #[arg(long, value_name = "TIMESTAMP", value_parser = parse_now)]
67        now: Option<DateTime<FixedOffset>>,
68        /// TODAY/NOW timezone: UTC or ±HH:MM [default: --now offset, else local]
69        #[arg(long, value_name = "ZONE", value_parser = parse_tz, allow_hyphen_values = true)]
70        tz: Option<TimeZoneSpec>,
71        /// RAND/RANDBETWEEN seed [default: built-in seed]
72        #[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    /// The engine's reason, e.g. `Unknown function: SPDVOL`.
82    message: Option<String>,
83}
84/// A function the engine does not implement and the error cells naming it.
85#[derive(Serialize)]
86struct UnknownFunction {
87    name: String,
88    cells: usize,
89}
90#[derive(Serialize)]
91struct Refusal {
92    feature: String,
93    context: String,
94}
95/// The clock a computed run used: enough, with `seed`, to replay it.
96#[derive(Serialize)]
97struct Clock {
98    /// RFC 3339 instant TODAY/NOW observed, written in the UTC offset that
99    /// was applied to it (so `--now <now>` alone replays it); null when the
100    /// build has no system clock and `--now` was not given.
101    now: Option<String>,
102    /// `Local`, `UTC` or `±HH:MM`.
103    timezone: String,
104    /// True when the instant came from `--now`.
105    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                        // Sheet names themselves may contain '!'. The final separator is the cell.
168                        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}
235/// The UTC offset `zone` applies at `now` (for `Local`, the host's offset
236/// at that instant, as the engine's clock computes it).
237fn 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
277// All structured strict-path refusals remain refusals. A structured category in
278// the library would allow malformed packages and limits to be refined later.
279fn 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
289/// Runs the command and returns its exit code. `args` includes `argv[0]`.
290/// Bindings should disable default features, pass `None` or their own shared
291/// cancellation token, and capture stdout/stderr independently. No process exit
292/// or signal-handler installation occurs in this function.
293pub 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    // Even clap failures must have machine-readable output when requested.
305    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    // No flags leave the default configuration (system clock, local time,
356    // built-in seed) untouched.
357    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            // Metadata publication only accompanies worksheet changes. Cache
420            // counts alone would miss attribute-only dynamic-array changes.
421            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}