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 { abs: f64, rel: f64 },
37}
38
39/// Whether `Integer` vs `Number` is treated as a type change.
40#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
41pub enum NumericTypePolicy {
42 /// `Integer(1)` and `Number(1.0)` are **different** (TypeChanged). Default.
43 #[default]
44 PreserveType,
45 /// Compare by mathematical value; `Integer(1)` and `Number(1.0)` are equal.
46 CompareMathematicalValue,
47}
48
49/// How date/time values are compared.
50#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
51pub enum DateComparePolicy {
52 /// Compare the raw serial and `is_1904` flag. Default.
53 #[default]
54 ExactRepresentation,
55 /// Attempt to normalise equivalent date-times before comparing.
56 NormalizeEquivalentDateTimes,
57}
58
59/// How a typed value is compared against a value of a different type.
60#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
61pub enum TypeMismatchPolicy {
62 /// Different types are always `TypeChanged`. Default.
63 #[default]
64 Different,
65 /// Compare their display strings instead (for human-friendly reports only).
66 CompareDisplayString,
67}
68
69/// All value-comparison policy fields grouped together.
70#[derive(Clone, Debug, Default)]
71pub struct ValueCompareOptions {
72 pub number: NumberComparePolicy,
73 pub numeric_type: NumericTypePolicy,
74 pub date: DateComparePolicy,
75 pub type_mismatch: TypeMismatchPolicy,
76}
77
78// ---------------------------------------------------------------------------
79// Format / style comparison (RFC-022)
80// ---------------------------------------------------------------------------
81
82/// Controls whether cell formatting (number format, font, fill, …) is compared.
83///
84/// Default is `Ignore` — calamine 0.35 does not expose a cell-style API, so
85/// `AllAvailable` emits an `UnsupportedWorkbookFeature` diagnostic at runtime.
86#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
87pub enum FormatCompareMode {
88 /// Ignore all formatting differences (default).
89 #[default]
90 Ignore,
91 /// Compare number-format strings only (future, requires style reader).
92 NumberFormatOnly,
93 /// Compare all available style fields (future, best-effort).
94 AllAvailable,
95}
96
97// ---------------------------------------------------------------------------
98// Comparison options
99// ---------------------------------------------------------------------------
100
101/// All comparison-behaviour options.
102#[derive(Clone, Debug)]
103pub struct ComparisonOptions {
104 pub value: ValueCompareOptions,
105 pub formula: FormulaCompareMode,
106 /// Whether the formula's cached value is compared as a value change.
107 pub include_formula_cached_values: bool,
108 /// Cell formatting comparison mode (RFC-022). Default: `Ignore`.
109 pub format: FormatCompareMode,
110}
111
112impl Default for ComparisonOptions {
113 fn default() -> Self {
114 Self {
115 value: ValueCompareOptions::default(),
116 formula: FormulaCompareMode::default(),
117 include_formula_cached_values: true,
118 format: FormatCompareMode::default(),
119 }
120 }
121}
122
123// ---------------------------------------------------------------------------
124// Sheet matching (RFC-009)
125// ---------------------------------------------------------------------------
126
127/// How sheets are paired between the two workbooks.
128#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
129pub enum SheetMatchingMode {
130 /// Pair only sheets with the same name; others are Added/Removed.
131 ExactNameOnly,
132 /// Exact name first; then detect a rename when exactly one unmatched old and
133 /// one unmatched new sheet remain and confidence is sufficient. Default.
134 #[default]
135 ExactNameThenConservativeRename,
136 /// Exact name first; then try pairing by sheet index.
137 ExactNameThenIndex,
138}
139
140/// Row/column alignment mode (RFC-011).
141#[allow(dead_code)]
142#[derive(Clone, Debug, Default)]
143pub enum AlignmentMode {
144 /// Positional (row N on old vs row N on new). Default.
145 #[default]
146 Positional,
147 /// Match rows by the values in the specified key columns (1-based).
148 /// Reduces cascades after row insertion/deletion.
149 RowKey { columns: Vec<u32> },
150 /// Match rows by a hash of selected cell values (content similarity).
151 /// `sample_columns` limits which columns contribute to the signature;
152 /// `None` means all columns.
153 RowSignature { sample_columns: Option<Vec<u32>> },
154 /// Match rows using the first row as a column-header identity.
155 #[allow(dead_code)]
156 HeaderColumn,
157}
158
159/// Options controlling sheet matching and cell alignment.
160#[derive(Clone, Debug, Default)]
161pub struct MatchingOptions {
162 pub sheet_matching: SheetMatchingMode,
163 pub alignment: AlignmentMode,
164}
165
166// ---------------------------------------------------------------------------
167// Limits (RFC-012 / RFC-033 §10)
168// ---------------------------------------------------------------------------
169
170/// Resource bounds that protect against pathological workbooks.
171///
172/// `None` means no limit on that dimension.
173#[derive(Clone, Debug, Default)]
174pub struct Limits {
175 pub max_sheets: Option<u32>,
176 pub max_cells_read: Option<u64>,
177 pub max_cells_compared: Option<u64>,
178 pub max_diffs_returned: Option<u64>,
179}
180
181// ---------------------------------------------------------------------------
182// Progress and cancellation (RFC-012)
183// ---------------------------------------------------------------------------
184
185/// An event emitted during a comparison for progress reporting.
186#[derive(Clone, Debug)]
187pub enum DiffEvent {
188 Started,
189 OpeningWorkbook { side: crate::model::Side },
190 WorkbookOpened { side: crate::model::Side, sheet_count: usize },
191 MatchingSheets,
192 SheetStarted { index: usize, total: usize, name: String },
193 SheetFinished { index: usize, changed_cells: usize },
194 Finished,
195}
196
197/// Trait for receiving progress events.
198///
199/// A blanket impl covers any `FnMut(DiffEvent) + Send` closure, so callers can
200/// pass a bare closure at call sites without boilerplate (RFC-012).
201pub trait ProgressSink: Send {
202 fn on_event(&mut self, event: DiffEvent);
203}
204
205impl<F: FnMut(DiffEvent) + Send> ProgressSink for F {
206 fn on_event(&mut self, event: DiffEvent) {
207 self(event);
208 }
209}
210
211/// Trait for cancellation predicates.
212///
213/// A blanket impl covers any `Fn() -> bool + Send + Sync`, so the common case
214/// is a closure. The single most common adapter is an `Arc<AtomicBool>` shared
215/// with a GUI "Cancel" button:
216///
217/// ```
218/// use std::sync::Arc;
219/// use std::sync::atomic::{AtomicBool, Ordering};
220/// use sheets_diff::DiffOptions;
221///
222/// let cancel_flag = Arc::new(AtomicBool::new(false));
223/// let flag = cancel_flag.clone();
224/// let opts = DiffOptions::builder()
225/// .cancellation(move || flag.load(Ordering::Relaxed))
226/// .build()
227/// .unwrap();
228/// // Setting `cancel_flag` to true from another thread causes the next
229/// // cancellation check to abort the diff with `SheetsDiffError::Cancelled`.
230/// ```
231///
232/// # Cancellation latency
233///
234/// `is_cancelled()` is polled **once before each sheet pair** is processed.
235/// On a workbook with many sheets, cancellation is observed promptly. On a
236/// single very large sheet, cancellation is **not** observed mid-sheet in the
237/// current implementation — it fires before the next sheet begins. If you need
238/// sub-sheet cancellation latency for huge single-sheet workbooks, also set a
239/// `max_cells_read` / `max_cells_compared` bound so the diff returns within a
240/// predictable amount of work.
241pub trait Cancellation: Send + Sync {
242 fn is_cancelled(&self) -> bool;
243}
244
245impl<F: Fn() -> bool + Send + Sync> Cancellation for F {
246 fn is_cancelled(&self) -> bool {
247 self()
248 }
249}
250
251/// Execution-mode configuration.
252#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
253pub enum ExecutionMode {
254 /// Single-threaded, deterministic. Default.
255 #[default]
256 Sequential,
257 // Parallel added by RFC-025.
258}
259
260/// Execution, progress, and cancellation options.
261pub struct ExecutionOptions {
262 pub progress: Option<Box<dyn ProgressSink>>,
263 pub cancellation: Option<Box<dyn Cancellation>>,
264 pub mode: ExecutionMode,
265}
266
267impl Default for ExecutionOptions {
268 fn default() -> Self {
269 Self {
270 progress: None,
271 cancellation: None,
272 mode: ExecutionMode::default(),
273 }
274 }
275}
276
277// ---------------------------------------------------------------------------
278// Diagnostic options
279// ---------------------------------------------------------------------------
280
281#[derive(Clone, Debug, Default)]
282pub struct DiagnosticOptions {
283 /// Minimum severity to collect. Defaults to `Info` (collect everything).
284 pub min_severity: Option<crate::model::Severity>,
285}
286
287// ---------------------------------------------------------------------------
288// Output options
289// ---------------------------------------------------------------------------
290
291/// Output and presentation options.
292#[derive(Clone, Debug)]
293pub struct OutputOptions {
294 /// How non-cell workbook objects are handled (RFC-023).
295 pub objects: crate::objects::ObjectCompareMode,
296}
297
298impl Default for OutputOptions {
299 fn default() -> Self {
300 Self { objects: crate::objects::ObjectCompareMode::WarnIfPresent }
301 }
302}
303
304// ---------------------------------------------------------------------------
305// DiffOptions — grouped tree (RFC-033 §11)
306// ---------------------------------------------------------------------------
307
308/// The top-level configuration entry point for a v2 comparison.
309///
310/// Construct via `DiffOptions::default()` or `DiffOptions::builder()`.
311pub struct DiffOptions {
312 pub comparison: ComparisonOptions,
313 pub matching: MatchingOptions,
314 pub limits: Limits,
315 pub execution: ExecutionOptions,
316 pub diagnostics: DiagnosticOptions,
317 pub output: OutputOptions,
318}
319
320impl Default for DiffOptions {
321 fn default() -> Self {
322 Self {
323 comparison: ComparisonOptions::default(),
324 matching: MatchingOptions::default(),
325 limits: Limits::default(),
326 execution: ExecutionOptions::default(),
327 diagnostics: DiagnosticOptions::default(),
328 output: OutputOptions::default(),
329 }
330 }
331}
332
333impl DiffOptions {
334 pub fn builder() -> DiffOptionsBuilder {
335 DiffOptionsBuilder::new()
336 }
337
338 /// Validate option combinations before I/O begins.
339 pub(crate) fn validate(&self) -> Result<(), SheetsDiffError> {
340 // NormalizedText requires a normaliser; none exists in v2.0.
341 if self.comparison.formula == FormulaCompareMode::NormalizedText
342 || self.comparison.formula == FormulaCompareMode::RawAndNormalized
343 {
344 return Err(SheetsDiffError::InvalidOptions {
345 detail: "FormulaCompareMode::NormalizedText / RawAndNormalized is not \
346 available in v2.0; no formula normaliser is implemented yet"
347 .into(),
348 });
349 }
350 // Style comparison requires a calamine style reader not yet available.
351 if self.comparison.format != FormatCompareMode::Ignore {
352 return Err(SheetsDiffError::InvalidOptions {
353 detail: "FormatCompareMode other than Ignore is not available in v2; \
354 calamine 0.35 does not expose a cell-style API"
355 .into(),
356 });
357 }
358 Ok(())
359 }
360}
361
362// ---------------------------------------------------------------------------
363// Builder
364// ---------------------------------------------------------------------------
365
366/// Fluent builder for `DiffOptions`.
367///
368/// Call `.build()` to validate the combination and obtain a `DiffOptions`.
369#[derive(Default)]
370pub struct DiffOptionsBuilder {
371 opts: DiffOptions,
372}
373
374impl DiffOptionsBuilder {
375 pub fn new() -> Self {
376 Self { opts: DiffOptions::default() }
377 }
378
379 // Comparison
380
381 pub fn formula_compare(mut self, mode: FormulaCompareMode) -> Self {
382 self.opts.comparison.formula = mode;
383 self
384 }
385
386 pub fn format_compare(mut self, mode: FormatCompareMode) -> Self {
387 self.opts.comparison.format = mode;
388 self
389 }
390
391 /// Set the object comparison mode (RFC-023).
392 pub fn object_mode(mut self, mode: crate::objects::ObjectCompareMode) -> Self {
393 self.opts.output.objects = mode;
394 self
395 }
396
397 /// Set the execution mode (RFC-025).
398 pub fn execution_mode(mut self, mode: ExecutionMode) -> Self {
399 self.opts.execution.mode = mode;
400 self
401 }
402
403 pub fn include_formula_cached_values(mut self, yes: bool) -> Self {
404 self.opts.comparison.include_formula_cached_values = yes;
405 self
406 }
407
408 pub fn number_compare(mut self, policy: NumberComparePolicy) -> Self {
409 self.opts.comparison.value.number = policy;
410 self
411 }
412
413 pub fn numeric_type_policy(mut self, policy: NumericTypePolicy) -> Self {
414 self.opts.comparison.value.numeric_type = policy;
415 self
416 }
417
418 pub fn type_mismatch_policy(mut self, policy: TypeMismatchPolicy) -> Self {
419 self.opts.comparison.value.type_mismatch = policy;
420 self
421 }
422
423 pub fn number_compare_policy(mut self, policy: NumberComparePolicy) -> Self {
424 self.opts.comparison.value.number = policy;
425 self
426 }
427
428 // Matching
429
430 pub fn sheet_matching(mut self, mode: SheetMatchingMode) -> Self {
431 self.opts.matching.sheet_matching = mode;
432 self
433 }
434
435 // Limits
436
437 pub fn max_sheets(mut self, n: u32) -> Self {
438 self.opts.limits.max_sheets = Some(n);
439 self
440 }
441
442 pub fn max_cells_compared(mut self, n: u64) -> Self {
443 self.opts.limits.max_cells_compared = Some(n);
444 self
445 }
446
447 pub fn max_diffs_returned(mut self, n: u64) -> Self {
448 self.opts.limits.max_diffs_returned = Some(n);
449 self
450 }
451
452 // Execution
453
454 pub fn progress<S: ProgressSink + 'static>(mut self, sink: S) -> Self {
455 self.opts.execution.progress = Some(Box::new(sink));
456 self
457 }
458
459 pub fn cancellation<C: Cancellation + 'static>(mut self, token: C) -> Self {
460 self.opts.execution.cancellation = Some(Box::new(token));
461 self
462 }
463
464 /// Build with a fully specified `MatchingOptions` (convenience for alignment tests).
465 pub fn build_with_matching(mut self, matching: MatchingOptions) -> Result<DiffOptions, SheetsDiffError> {
466 self.opts.matching = matching;
467 self.opts.validate()?;
468 Ok(self.opts)
469 }
470
471 /// Validate and return the built options.
472 pub fn build(self) -> Result<DiffOptions, SheetsDiffError> {
473 self.opts.validate()?;
474 Ok(self.opts)
475 }
476}