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** is processed.
334/// On a workbook with many sheets, cancellation is observed promptly. On a
335/// single very large sheet, cancellation is **not** observed mid-sheet in the
336/// current implementation — it fires before the next sheet begins. If you need
337/// sub-sheet cancellation latency for huge single-sheet workbooks, also set a
338/// `max_cells_read` / `max_cells_compared` bound so the diff returns within a
339/// predictable amount of work.
340pub trait Cancellation: Send + Sync {
341    fn is_cancelled(&self) -> bool;
342}
343
344impl<F: Fn() -> bool + Send + Sync> Cancellation for F {
345    fn is_cancelled(&self) -> bool {
346        self()
347    }
348}
349
350/// Execution-mode configuration.
351///
352/// Reserved, currently has no effect: `Sequential` is the only variant and
353/// the only path the pipeline runs. A parallel mode was removed (RFC-025,
354/// roadmap decision D2) because its implementation parallelised the wrong
355/// phase; the type is kept so a future, differently-designed re-introduction
356/// does not need a public API break. See RFC-025 for the full rationale and
357/// the re-introduction gate.
358#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
359pub enum ExecutionMode {
360    /// Single-threaded, deterministic.  Default.
361    #[default]
362    Sequential,
363}
364
365/// Execution, progress, and cancellation options.
366#[derive(Default)]
367pub struct ExecutionOptions {
368    pub progress: Option<Box<dyn ProgressSink>>,
369    pub cancellation: Option<Box<dyn Cancellation>>,
370    /// Reserved, currently has no effect — see [`ExecutionMode`] (RFC-025).
371    pub mode: ExecutionMode,
372}
373
374// ---------------------------------------------------------------------------
375// Diagnostic options
376// ---------------------------------------------------------------------------
377
378#[derive(Clone, Debug, Default)]
379pub struct DiagnosticOptions {
380    /// Minimum severity to collect.  Defaults to `Info` (collect everything).
381    pub min_severity: Option<crate::model::Severity>,
382}
383
384// ---------------------------------------------------------------------------
385// Output options
386// ---------------------------------------------------------------------------
387
388/// Output and presentation options.
389#[derive(Clone, Debug)]
390pub struct OutputOptions {
391    /// How non-cell workbook objects are handled (RFC-023).
392    pub objects: crate::objects::ObjectCompareMode,
393}
394
395impl Default for OutputOptions {
396    fn default() -> Self {
397        Self {
398            objects: crate::objects::ObjectCompareMode::WarnIfPresent,
399        }
400    }
401}
402
403// ---------------------------------------------------------------------------
404// DiffOptions — grouped tree (RFC-033 §11)
405// ---------------------------------------------------------------------------
406
407/// The top-level configuration entry point for a v2 comparison.
408///
409/// Construct via `DiffOptions::default()` or `DiffOptions::builder()`.
410#[derive(Default)]
411pub struct DiffOptions {
412    pub comparison: ComparisonOptions,
413    pub matching: MatchingOptions,
414    pub limits: Limits,
415    pub execution: ExecutionOptions,
416    pub diagnostics: DiagnosticOptions,
417    pub output: OutputOptions,
418}
419
420impl DiffOptions {
421    pub fn builder() -> DiffOptionsBuilder {
422        DiffOptionsBuilder::new()
423    }
424
425    /// Validate option combinations before I/O begins.
426    pub(crate) fn validate(&self) -> Result<(), SheetsDiffError> {
427        // NormalizedText requires a normaliser; none exists.
428        if self.comparison.formula == FormulaCompareMode::NormalizedText
429            || self.comparison.formula == FormulaCompareMode::RawAndNormalized
430        {
431            return Err(SheetsDiffError::InvalidOptions {
432                detail: "FormulaCompareMode::NormalizedText / RawAndNormalized is not \
433                         available; no formula normaliser is implemented yet"
434                    .into(),
435            });
436        }
437        // Style comparison requires a calamine style reader not yet available.
438        if self.comparison.format != FormatCompareMode::Ignore {
439            return Err(SheetsDiffError::InvalidOptions {
440                detail: "FormatCompareMode other than Ignore is not available in v2; \
441                         calamine 0.36 does not expose a cell-style API"
442                    .into(),
443            });
444        }
445        Ok(())
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Builder
451// ---------------------------------------------------------------------------
452
453/// Fluent builder for `DiffOptions`.
454///
455/// Call `.build()` to validate the combination and obtain a `DiffOptions`.
456#[derive(Default)]
457pub struct DiffOptionsBuilder {
458    opts: DiffOptions,
459}
460
461impl DiffOptionsBuilder {
462    pub fn new() -> Self {
463        Self {
464            opts: DiffOptions::default(),
465        }
466    }
467
468    // Comparison
469
470    pub fn formula_compare(mut self, mode: FormulaCompareMode) -> Self {
471        self.opts.comparison.formula = mode;
472        self
473    }
474
475    pub fn format_compare(mut self, mode: FormatCompareMode) -> Self {
476        self.opts.comparison.format = mode;
477        self
478    }
479
480    /// Set the object comparison mode (RFC-023).
481    pub fn object_mode(mut self, mode: crate::objects::ObjectCompareMode) -> Self {
482        self.opts.output.objects = mode;
483        self
484    }
485
486    /// Set the execution mode.
487    ///
488    /// Reserved, currently has no effect — see [`ExecutionMode`] (RFC-025).
489    pub fn execution_mode(mut self, mode: ExecutionMode) -> Self {
490        self.opts.execution.mode = mode;
491        self
492    }
493
494    pub fn include_formula_cached_values(mut self, yes: bool) -> Self {
495        self.opts.comparison.include_formula_cached_values = yes;
496        self
497    }
498
499    pub fn number_compare(mut self, policy: NumberComparePolicy) -> Self {
500        self.opts.comparison.value.number = policy;
501        self
502    }
503
504    pub fn numeric_type_policy(mut self, policy: NumericTypePolicy) -> Self {
505        self.opts.comparison.value.numeric_type = policy;
506        self
507    }
508
509    pub fn type_mismatch_policy(mut self, policy: TypeMismatchPolicy) -> Self {
510        self.opts.comparison.value.type_mismatch = policy;
511        self
512    }
513
514    pub fn number_compare_policy(mut self, policy: NumberComparePolicy) -> Self {
515        self.opts.comparison.value.number = policy;
516        self
517    }
518
519    // Matching
520
521    pub fn sheet_matching(mut self, mode: SheetMatchingMode) -> Self {
522        self.opts.matching.sheet_matching = mode;
523        self
524    }
525
526    // Limits
527
528    pub fn max_sheets(mut self, n: u32) -> Self {
529        self.opts.limits.max_sheets = Some(n);
530        self
531    }
532
533    pub fn max_cells_compared(mut self, n: u64) -> Self {
534        self.opts.limits.max_cells_compared = Some(n);
535        self
536    }
537
538    pub fn max_diffs_returned(mut self, n: u64) -> Self {
539        self.opts.limits.max_diffs_returned = Some(n);
540        self
541    }
542
543    /// Bounds the `m × n` alignment table; `Some` by default
544    /// ([`DEFAULT_MAX_ALIGNMENT_PRODUCT`]). Pass `None` to disable the
545    /// bound entirely (RFC-035 §5.1 — this is opt-out, not opt-in).
546    pub fn max_alignment_product(mut self, limit: Option<u64>) -> Self {
547        self.opts.limits.max_alignment_product = limit;
548        self
549    }
550
551    /// Bounds input size, checked before any read begins; `Some` by
552    /// default ([`DEFAULT_MAX_INPUT_BYTES`]). Pass `None` to disable the
553    /// bound entirely.
554    pub fn max_input_bytes(mut self, limit: Option<u64>) -> Self {
555        self.opts.limits.max_input_bytes = limit;
556        self
557    }
558
559    /// Replace all limits at once, e.g. with [`Limits::hardened()`].
560    pub fn limits(mut self, limits: Limits) -> Self {
561        self.opts.limits = limits;
562        self
563    }
564
565    // Execution
566
567    pub fn progress<S: ProgressSink + 'static>(mut self, sink: S) -> Self {
568        self.opts.execution.progress = Some(Box::new(sink));
569        self
570    }
571
572    pub fn cancellation<C: Cancellation + 'static>(mut self, token: C) -> Self {
573        self.opts.execution.cancellation = Some(Box::new(token));
574        self
575    }
576
577    /// Build with a fully specified `MatchingOptions` (convenience for alignment tests).
578    pub fn build_with_matching(
579        mut self,
580        matching: MatchingOptions,
581    ) -> Result<DiffOptions, SheetsDiffError> {
582        self.opts.matching = matching;
583        self.opts.validate()?;
584        Ok(self.opts)
585    }
586
587    /// Validate and return the built options.
588    pub fn build(self) -> Result<DiffOptions, SheetsDiffError> {
589        self.opts.validate()?;
590        Ok(self.opts)
591    }
592}