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}