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 in v2.0.
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 in v2.0; 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}