Skip to main content

sheets_diff/
options.rs

1//! Comparison options, builder, and related policy enums (RFC-006, RFC-033 §11).
2
3use crate::error::SheetsDiffError;
4
5// ---------------------------------------------------------------------------
6// Formula comparison (RFC-018)
7// ---------------------------------------------------------------------------
8
9/// How formula text is compared when both sides have a formula.
10#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
11pub enum FormulaCompareMode {
12    /// Compare raw formula strings exactly.  Default.
13    #[default]
14    RawText,
15    /// Compare normalised formula strings.  Requires a normaliser feature;
16    /// returns `InvalidOptions` if selected without one.
17    NormalizedText,
18    /// Compare both raw and normalised; emits both in `FormulaText`.
19    RawAndNormalized,
20    /// Do not compare formulas at all.
21    Ignore,
22}
23
24// ---------------------------------------------------------------------------
25// Numeric / value comparison (RFC-019)
26// ---------------------------------------------------------------------------
27
28/// How two floating-point numbers are compared.
29#[derive(Clone, Copy, PartialEq, Debug, Default)]
30pub enum NumberComparePolicy {
31    /// Bit-faithful parsed equality.  Default.
32    #[default]
33    Exact,
34    AbsoluteTolerance(f64),
35    RelativeTolerance(f64),
36    AbsoluteOrRelative {
37        abs: f64,
38        rel: f64,
39    },
40}
41
42/// Whether `Integer` vs `Number` is treated as a type change.
43#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
44pub enum NumericTypePolicy {
45    /// `Integer(1)` and `Number(1.0)` are **different** (TypeChanged).  Default.
46    #[default]
47    PreserveType,
48    /// Compare by mathematical value; `Integer(1)` and `Number(1.0)` are equal.
49    CompareMathematicalValue,
50}
51
52/// How date/time values are compared.
53#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
54pub enum DateComparePolicy {
55    /// Compare the raw serial and `is_1904` flag.  Default.
56    #[default]
57    ExactRepresentation,
58    /// Attempt to normalise equivalent date-times before comparing.
59    NormalizeEquivalentDateTimes,
60}
61
62/// How a typed value is compared against a value of a different type.
63#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
64pub enum TypeMismatchPolicy {
65    /// Different types are always `TypeChanged`.  Default.
66    #[default]
67    Different,
68    /// Compare their display strings instead (for human-friendly reports only).
69    CompareDisplayString,
70}
71
72/// All value-comparison policy fields grouped together.
73#[derive(Clone, Debug, Default)]
74pub struct ValueCompareOptions {
75    pub number: NumberComparePolicy,
76    pub numeric_type: NumericTypePolicy,
77    pub date: DateComparePolicy,
78    pub type_mismatch: TypeMismatchPolicy,
79}
80
81// ---------------------------------------------------------------------------
82// Format / style comparison (RFC-022)
83// ---------------------------------------------------------------------------
84
85/// Controls whether cell formatting (number format, font, fill, …) is compared.
86///
87/// Default is `Ignore` — calamine 0.36 does not expose a cell-style API, so
88/// `AllAvailable` emits an `UnsupportedWorkbookFeature` diagnostic at runtime.
89#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
90pub enum FormatCompareMode {
91    /// Ignore all formatting differences (default).
92    #[default]
93    Ignore,
94    /// Compare number-format strings only (future, requires style reader).
95    NumberFormatOnly,
96    /// Compare all available style fields (future, best-effort).
97    AllAvailable,
98}
99
100// ---------------------------------------------------------------------------
101// Comparison options
102// ---------------------------------------------------------------------------
103
104/// All comparison-behaviour options.
105#[derive(Clone, Debug)]
106pub struct ComparisonOptions {
107    pub value: ValueCompareOptions,
108    pub formula: FormulaCompareMode,
109    /// Whether the formula's cached value is compared as a value change.
110    pub include_formula_cached_values: bool,
111    /// Cell formatting comparison mode (RFC-022). Default: `Ignore`.
112    pub format: FormatCompareMode,
113}
114
115impl Default for ComparisonOptions {
116    fn default() -> Self {
117        Self {
118            value: ValueCompareOptions::default(),
119            formula: FormulaCompareMode::default(),
120            include_formula_cached_values: true,
121            format: FormatCompareMode::default(),
122        }
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Sheet matching (RFC-009)
128// ---------------------------------------------------------------------------
129
130/// How sheets are paired between the two workbooks.
131#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
132pub enum SheetMatchingMode {
133    /// Pair only sheets with the same name; others are Added/Removed.
134    ExactNameOnly,
135    /// Exact name first; then detect a rename when exactly one unmatched old and
136    /// one unmatched new sheet remain and confidence is sufficient.  Default.
137    #[default]
138    ExactNameThenConservativeRename,
139    /// Exact name first; then try pairing by sheet index.
140    ExactNameThenIndex,
141}
142
143/// Row/column alignment mode (RFC-011).
144#[allow(dead_code)]
145#[derive(Clone, Debug, Default)]
146pub enum AlignmentMode {
147    /// Positional (row N on old vs row N on new).  Default.
148    #[default]
149    Positional,
150    /// Match rows by the values in the specified key columns (1-based).
151    /// Reduces cascades after row insertion/deletion.
152    RowKey { columns: Vec<u32> },
153    /// Match rows by a hash of selected cell values (content similarity).
154    /// `sample_columns` limits which columns contribute to the signature;
155    /// `None` means all columns.
156    RowSignature { sample_columns: Option<Vec<u32>> },
157    /// Match rows using the first row as a column-header identity.
158    #[allow(dead_code)]
159    HeaderColumn,
160}
161
162/// Options controlling sheet matching and cell alignment.
163#[derive(Clone, Debug, Default)]
164pub struct MatchingOptions {
165    pub sheet_matching: SheetMatchingMode,
166    pub alignment: AlignmentMode,
167}
168
169// ---------------------------------------------------------------------------
170// Limits (RFC-012 / RFC-033 §10 / RFC-035 §5.1-5.4)
171// ---------------------------------------------------------------------------
172
173/// Default bound on the row-alignment `m × n` table (RFC-035 §5.1, §9).
174///
175/// Chosen from a direct measurement of `Vec<Vec<u32>>` allocation cost at
176/// several square sizes (see Handoff 04's review request for the full
177/// table): 5,000×5,000 (this bound) measured ~95 MB / ~15 ms; the
178/// previous *unbounded* worst case — two sheets each at the old row-count
179/// guard's 50,000-row ceiling — measured ~9.5 GB / ~3.3 s just to
180/// zero-allocate the table, before any comparison work. Two sheets each up
181/// to ~5,000 rows (or any combination whose product stays under this
182/// bound) get full alignment; larger degrades to positional with a
183/// diagnostic (RFC-035 §5.2) rather than risking the unbounded case.
184pub const DEFAULT_MAX_ALIGNMENT_PRODUCT: u64 = 25_000_000;
185
186/// Default bound on input size, checked before any read begins (RFC-035
187/// §5.4): 500 MiB. Chosen to be generous enough that no ordinary `.xlsx`
188/// workbook — this crate does not compare macros, embedded media, or other
189/// content that would make a legitimate file huge — should ever reach it,
190/// while still being finite.
191pub const DEFAULT_MAX_INPUT_BYTES: u64 = 500 * 1024 * 1024;
192
193/// Resource bounds that protect against pathological workbooks.
194///
195/// `None` means no limit on that dimension. Per RFC-035 §5.1, the four
196/// *linear* fields (`max_sheets`, `max_cells_read`, `max_cells_compared`,
197/// `max_diffs_returned`) default to `None` — their cost scales predictably
198/// with input size the caller chose to open, so bounding them by default
199/// would surprise working callers for no safety gain they could not have
200/// anticipated. `max_alignment_product` and `max_input_bytes` default to
201/// `Some` instead: their unbounded cost is *superlinear* or is incurred
202/// before any comparison logic can observe it, which is exactly the failure
203/// class RFC-035 exists to close. See [`Limits::hardened()`] for a preset
204/// that bounds every dimension, for callers who do not trust their input.
205#[derive(Clone, Debug)]
206pub struct Limits {
207    pub max_sheets: Option<u32>,
208    pub max_cells_read: Option<u64>,
209    pub max_cells_compared: Option<u64>,
210    pub max_diffs_returned: Option<u64>,
211    /// Bounds the `m × n` row-alignment table. Exceeding it degrades this
212    /// sheet to positional comparison and emits an
213    /// [`AlignmentBoundExceeded`](crate::DiagnosticKind::AlignmentBoundExceeded)
214    /// diagnostic — it never errors and never aborts (RFC-035 §5.2). `Some`
215    /// by default; see [`DEFAULT_MAX_ALIGNMENT_PRODUCT`].
216    pub max_alignment_product: Option<u64>,
217    /// Bounds the input size, checked *before* the file is read (or the
218    /// reader is drained). Exceeding it returns
219    /// [`SheetsDiffError::LimitExceeded`] with
220    /// [`LimitKind::InputBytes`](crate::LimitKind::InputBytes) — this one
221    /// does error, unlike the alignment bound, because there is no
222    /// "positional fallback" for an oversized file. `Some` by default; see
223    /// [`DEFAULT_MAX_INPUT_BYTES`].
224    pub max_input_bytes: Option<u64>,
225}
226
227impl Default for Limits {
228    fn default() -> Self {
229        Self {
230            max_sheets: None,
231            max_cells_read: None,
232            max_cells_compared: None,
233            max_diffs_returned: None,
234            max_alignment_product: Some(DEFAULT_MAX_ALIGNMENT_PRODUCT),
235            max_input_bytes: Some(DEFAULT_MAX_INPUT_BYTES),
236        }
237    }
238}
239
240impl Limits {
241    /// A conservative bound on every dimension, for comparing a workbook
242    /// from a source you do not trust (RFC-035 §5.3).
243    ///
244    /// `Limits::default()` deliberately does **not** provide this — its
245    /// four linear fields stay unbounded so ordinary large-but-legitimate
246    /// workbooks are never surprised. `hardened()` trades that off: a
247    /// caller who opts into it accepts that a very large but legitimate
248    /// workbook may hit a limit, in exchange for a guarantee that no
249    /// workbook — hostile or merely huge — can demand unbounded time or
250    /// memory. Values are chosen to comfortably accommodate an ordinary
251    /// office workbook while capping the worst case; they are not
252    /// individually re-measured beyond the alignment bound already
253    /// justified above; if a specific dimension proves too tight in
254    /// practice, that is a finding to report, not a default to silently
255    /// loosen.
256    pub fn hardened() -> Self {
257        Self {
258            max_sheets: Some(256),
259            max_cells_read: Some(5_000_000),
260            max_cells_compared: Some(5_000_000),
261            max_diffs_returned: Some(1_000_000),
262            max_alignment_product: Some(DEFAULT_MAX_ALIGNMENT_PRODUCT),
263            max_input_bytes: Some(50 * 1024 * 1024),
264        }
265    }
266}
267
268// ---------------------------------------------------------------------------
269// Progress and cancellation (RFC-012)
270// ---------------------------------------------------------------------------
271
272/// An event emitted during a comparison for progress reporting.
273#[derive(Clone, Debug)]
274pub enum DiffEvent {
275    Started,
276    OpeningWorkbook {
277        side: crate::model::Side,
278    },
279    WorkbookOpened {
280        side: crate::model::Side,
281        sheet_count: usize,
282    },
283    MatchingSheets,
284    SheetStarted {
285        index: usize,
286        total: usize,
287        name: String,
288    },
289    SheetFinished {
290        index: usize,
291        changed_cells: usize,
292    },
293    Finished,
294}
295
296/// Trait for receiving progress events.
297///
298/// A blanket impl covers any `FnMut(DiffEvent) + Send` closure, so callers can
299/// pass a bare closure at call sites without boilerplate (RFC-012).
300pub trait ProgressSink: Send {
301    fn on_event(&mut self, event: DiffEvent);
302}
303
304impl<F: FnMut(DiffEvent) + Send> ProgressSink for F {
305    fn on_event(&mut self, event: DiffEvent) {
306        self(event);
307    }
308}
309
310/// Trait for cancellation predicates.
311///
312/// A blanket impl covers any `Fn() -> bool + Send + Sync`, so the common case
313/// is a closure. The single most common adapter is an `Arc<AtomicBool>` shared
314/// with a GUI "Cancel" button:
315///
316/// ```
317/// use std::sync::Arc;
318/// use std::sync::atomic::{AtomicBool, Ordering};
319/// use sheets_diff::DiffOptions;
320///
321/// let cancel_flag = Arc::new(AtomicBool::new(false));
322/// let flag = cancel_flag.clone();
323/// let opts = DiffOptions::builder()
324///     .cancellation(move || flag.load(Ordering::Relaxed))
325///     .build()
326///     .unwrap();
327/// // Setting `cancel_flag` to true from another thread causes the next
328/// // cancellation check to abort the diff with `SheetsDiffError::Cancelled`.
329/// ```
330///
331/// # Cancellation latency
332///
333/// `is_cancelled()` is polled once before each sheet pair, **and** at an
334/// interval inside a sheet's own processing — every 50,000 cells, in both
335/// the read phase and the compare phase. On the largest single sheet this
336/// crate's own benchmark ladder covers (300,000 cells), that bounds
337/// worst-case latency to roughly 100 ms; see `docs/src/maintainers/performance.md`
338/// for the measured overhead of this polling, with and without a
339/// `Cancellation` configured.
340///
341/// **This changed in M7 Handoff 03.** Before it, `is_cancelled()` was polled
342/// **only** once before each sheet pair — on a workbook with many sheets,
343/// cancellation was observed promptly at the next sheet boundary, but on a
344/// single sheet (the ordinary shape of a spreadsheet) there was no next
345/// checkpoint, so a comparison ran to completion and returned `Ok` no matter
346/// when cancellation was requested. That gap is closed: a single-sheet
347/// workbook large enough to cross a polling interval is now cancellable
348/// mid-sheet, in both phases. Setting a `max_cells_read` / `max_cells_compared`
349/// bound remains useful for a hard resource ceiling, but is no longer the
350/// only way to get sub-sheet cancellation latency.
351pub trait Cancellation: Send + Sync {
352    fn is_cancelled(&self) -> bool;
353}
354
355impl<F: Fn() -> bool + Send + Sync> Cancellation for F {
356    fn is_cancelled(&self) -> bool {
357        self()
358    }
359}
360
361/// Execution-mode configuration.
362///
363/// Reserved, currently has no effect: `Sequential` is the only variant and
364/// the only path the pipeline runs. A parallel mode was removed (RFC-025,
365/// roadmap decision D2) because its implementation parallelised the wrong
366/// phase; the type is kept so a future, differently-designed re-introduction
367/// does not need a public API break. See RFC-025 for the full rationale and
368/// the re-introduction gate.
369#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
370pub enum ExecutionMode {
371    /// Single-threaded, deterministic.  Default.
372    #[default]
373    Sequential,
374}
375
376/// Execution, progress, and cancellation options.
377#[derive(Default)]
378pub struct ExecutionOptions {
379    pub progress: Option<Box<dyn ProgressSink>>,
380    pub cancellation: Option<Box<dyn Cancellation>>,
381    /// Reserved, currently has no effect — see [`ExecutionMode`] (RFC-025).
382    pub mode: ExecutionMode,
383}
384
385// ---------------------------------------------------------------------------
386// Diagnostic options
387// ---------------------------------------------------------------------------
388
389#[derive(Clone, Debug, Default)]
390pub struct DiagnosticOptions {
391    /// Minimum severity to collect.  Defaults to `Info` (collect everything).
392    pub min_severity: Option<crate::model::Severity>,
393}
394
395// ---------------------------------------------------------------------------
396// Output options
397// ---------------------------------------------------------------------------
398
399/// Output and presentation options.
400#[derive(Clone, Debug)]
401pub struct OutputOptions {
402    /// How non-cell workbook objects are handled (RFC-023).
403    pub objects: crate::objects::ObjectCompareMode,
404}
405
406impl Default for OutputOptions {
407    fn default() -> Self {
408        Self {
409            objects: crate::objects::ObjectCompareMode::WarnIfPresent,
410        }
411    }
412}
413
414// ---------------------------------------------------------------------------
415// DiffOptions — grouped tree (RFC-033 §11)
416// ---------------------------------------------------------------------------
417
418/// The top-level configuration entry point for a v2 comparison.
419///
420/// Construct via `DiffOptions::default()` or `DiffOptions::builder()`.
421#[derive(Default)]
422pub struct DiffOptions {
423    pub comparison: ComparisonOptions,
424    pub matching: MatchingOptions,
425    pub limits: Limits,
426    pub execution: ExecutionOptions,
427    pub diagnostics: DiagnosticOptions,
428    pub output: OutputOptions,
429}
430
431impl DiffOptions {
432    pub fn builder() -> DiffOptionsBuilder {
433        DiffOptionsBuilder::new()
434    }
435
436    /// Validate option combinations before I/O begins.
437    pub(crate) fn validate(&self) -> Result<(), SheetsDiffError> {
438        // NormalizedText requires a normaliser; none exists.
439        if self.comparison.formula == FormulaCompareMode::NormalizedText
440            || self.comparison.formula == FormulaCompareMode::RawAndNormalized
441        {
442            return Err(SheetsDiffError::InvalidOptions {
443                detail: "FormulaCompareMode::NormalizedText / RawAndNormalized is not \
444                         available; no formula normaliser is implemented yet"
445                    .into(),
446            });
447        }
448        // Style comparison requires a calamine style reader not yet available.
449        if self.comparison.format != FormatCompareMode::Ignore {
450            return Err(SheetsDiffError::InvalidOptions {
451                detail: "FormatCompareMode other than Ignore is not available in v2; \
452                         calamine 0.36 does not expose a cell-style API"
453                    .into(),
454            });
455        }
456        Ok(())
457    }
458}
459
460// ---------------------------------------------------------------------------
461// Builder
462// ---------------------------------------------------------------------------
463
464/// Fluent builder for `DiffOptions`.
465///
466/// Call `.build()` to validate the combination and obtain a `DiffOptions`.
467#[derive(Default)]
468pub struct DiffOptionsBuilder {
469    opts: DiffOptions,
470}
471
472impl DiffOptionsBuilder {
473    pub fn new() -> Self {
474        Self {
475            opts: DiffOptions::default(),
476        }
477    }
478
479    // Comparison
480
481    pub fn formula_compare(mut self, mode: FormulaCompareMode) -> Self {
482        self.opts.comparison.formula = mode;
483        self
484    }
485
486    pub fn format_compare(mut self, mode: FormatCompareMode) -> Self {
487        self.opts.comparison.format = mode;
488        self
489    }
490
491    /// Set the object comparison mode (RFC-023).
492    pub fn object_mode(mut self, mode: crate::objects::ObjectCompareMode) -> Self {
493        self.opts.output.objects = mode;
494        self
495    }
496
497    /// Set the execution mode.
498    ///
499    /// Reserved, currently has no effect — see [`ExecutionMode`] (RFC-025).
500    pub fn execution_mode(mut self, mode: ExecutionMode) -> Self {
501        self.opts.execution.mode = mode;
502        self
503    }
504
505    pub fn include_formula_cached_values(mut self, yes: bool) -> Self {
506        self.opts.comparison.include_formula_cached_values = yes;
507        self
508    }
509
510    pub fn number_compare(mut self, policy: NumberComparePolicy) -> Self {
511        self.opts.comparison.value.number = policy;
512        self
513    }
514
515    pub fn numeric_type_policy(mut self, policy: NumericTypePolicy) -> Self {
516        self.opts.comparison.value.numeric_type = policy;
517        self
518    }
519
520    pub fn type_mismatch_policy(mut self, policy: TypeMismatchPolicy) -> Self {
521        self.opts.comparison.value.type_mismatch = policy;
522        self
523    }
524
525    pub fn number_compare_policy(mut self, policy: NumberComparePolicy) -> Self {
526        self.opts.comparison.value.number = policy;
527        self
528    }
529
530    // Matching
531
532    pub fn sheet_matching(mut self, mode: SheetMatchingMode) -> Self {
533        self.opts.matching.sheet_matching = mode;
534        self
535    }
536
537    // Limits
538
539    pub fn max_sheets(mut self, n: u32) -> Self {
540        self.opts.limits.max_sheets = Some(n);
541        self
542    }
543
544    pub fn max_cells_compared(mut self, n: u64) -> Self {
545        self.opts.limits.max_cells_compared = Some(n);
546        self
547    }
548
549    pub fn max_diffs_returned(mut self, n: u64) -> Self {
550        self.opts.limits.max_diffs_returned = Some(n);
551        self
552    }
553
554    /// Bounds the `m × n` alignment table; `Some` by default
555    /// ([`DEFAULT_MAX_ALIGNMENT_PRODUCT`]). Pass `None` to disable the
556    /// bound entirely (RFC-035 §5.1 — this is opt-out, not opt-in).
557    pub fn max_alignment_product(mut self, limit: Option<u64>) -> Self {
558        self.opts.limits.max_alignment_product = limit;
559        self
560    }
561
562    /// Bounds input size, checked before any read begins; `Some` by
563    /// default ([`DEFAULT_MAX_INPUT_BYTES`]). Pass `None` to disable the
564    /// bound entirely.
565    pub fn max_input_bytes(mut self, limit: Option<u64>) -> Self {
566        self.opts.limits.max_input_bytes = limit;
567        self
568    }
569
570    /// Replace all limits at once, e.g. with [`Limits::hardened()`].
571    pub fn limits(mut self, limits: Limits) -> Self {
572        self.opts.limits = limits;
573        self
574    }
575
576    // Execution
577
578    pub fn progress<S: ProgressSink + 'static>(mut self, sink: S) -> Self {
579        self.opts.execution.progress = Some(Box::new(sink));
580        self
581    }
582
583    pub fn cancellation<C: Cancellation + 'static>(mut self, token: C) -> Self {
584        self.opts.execution.cancellation = Some(Box::new(token));
585        self
586    }
587
588    /// Build with a fully specified `MatchingOptions` (convenience for alignment tests).
589    pub fn build_with_matching(
590        mut self,
591        matching: MatchingOptions,
592    ) -> Result<DiffOptions, SheetsDiffError> {
593        self.opts.matching = matching;
594        self.opts.validate()?;
595        Ok(self.opts)
596    }
597
598    /// Validate and return the built options.
599    pub fn build(self) -> Result<DiffOptions, SheetsDiffError> {
600        self.opts.validate()?;
601        Ok(self.opts)
602    }
603}