sheets_diff/model.rs
1//! Public result data model.
2//!
3//! All types here are normatively defined in RFC-033. This module owns
4//! construction and the summary / change-kind derivation logic; the field
5//! shapes are fixed by the canonical lexicon.
6
7use std::fmt;
8
9#[cfg(feature = "serde")]
10use serde::Serialize;
11
12use crate::address::{CellAddress, ComparedRange};
13
14// ---------------------------------------------------------------------------
15// Side
16// ---------------------------------------------------------------------------
17
18/// Which workbook of the pair a piece of data refers to.
19#[derive(Clone, Copy, PartialEq, Eq, Debug)]
20#[cfg_attr(feature = "serde", derive(Serialize))]
21#[non_exhaustive]
22pub enum Side {
23 Old,
24 New,
25}
26
27impl fmt::Display for Side {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 match self {
30 Side::Old => f.write_str("old"),
31 Side::New => f.write_str("new"),
32 }
33 }
34}
35
36// ---------------------------------------------------------------------------
37// Source description
38// ---------------------------------------------------------------------------
39
40/// What kind of input source a workbook came from.
41#[derive(Clone, PartialEq, Eq, Debug)]
42#[cfg_attr(feature = "serde", derive(Serialize))]
43#[non_exhaustive]
44pub enum SourceKind {
45 Path,
46 Bytes,
47 Reader,
48 Unknown,
49}
50
51/// Caller-visible description of a workbook input source.
52///
53/// `display_name` is never an absolute path unless the caller explicitly
54/// provided it as such.
55#[derive(Clone, PartialEq, Debug)]
56#[cfg_attr(feature = "serde", derive(Serialize))]
57#[non_exhaustive]
58pub struct SourceDescription {
59 pub kind: SourceKind,
60 pub display_name: Option<String>,
61}
62
63// ---------------------------------------------------------------------------
64// Per-side workbook metadata
65// ---------------------------------------------------------------------------
66
67#[derive(Clone, PartialEq, Debug)]
68#[cfg_attr(feature = "serde", derive(Serialize))]
69#[non_exhaustive]
70pub struct WorkbookSideInfo {
71 pub source: SourceDescription,
72 pub workbook_name: Option<String>,
73 pub sheet_count: usize,
74}
75
76// ---------------------------------------------------------------------------
77// Sheet identity
78// ---------------------------------------------------------------------------
79
80/// A reference to a specific sheet in one workbook.
81///
82/// `index` is **0-based** workbook order (as returned by calamine).
83#[derive(Clone, PartialEq, Eq, Debug)]
84#[cfg_attr(feature = "serde", derive(Serialize))]
85#[non_exhaustive]
86pub struct SheetRef {
87 pub name: String,
88 pub index: usize,
89}
90
91// ---------------------------------------------------------------------------
92// Sheet change classification (RFC-009 / RFC-033 §6)
93// ---------------------------------------------------------------------------
94
95/// How confident the sheet-matching algorithm is about a non-exact pairing.
96#[derive(Clone, Copy, PartialEq, Eq, Debug)]
97#[cfg_attr(feature = "serde", derive(Serialize))]
98#[non_exhaustive]
99pub enum MatchConfidence {
100 Exact,
101 High,
102 Medium,
103 Low,
104}
105
106/// The reason a non-exact sheet pair was formed.
107#[non_exhaustive]
108#[derive(Clone, PartialEq, Eq, Debug)]
109#[cfg_attr(feature = "serde", derive(Serialize))]
110pub enum SheetMatchReason {
111 ExactName,
112 IndexAndContent,
113 ContentSimilarity,
114}
115
116/// How a sheet pair was classified.
117///
118/// Names and indices live in `SheetDiff.old_sheet` / `SheetDiff.new_sheet`;
119/// they are **not** duplicated inside the variant payloads.
120#[non_exhaustive]
121#[derive(Clone, PartialEq, Eq, Debug)]
122#[cfg_attr(feature = "serde", derive(Serialize))]
123pub enum SheetChange {
124 /// Name-matched, index unchanged, no cell differences.
125 Unchanged,
126 /// Name-matched (or rename-matched), has cell differences.
127 Modified,
128 /// New sheet with no counterpart in the old workbook.
129 Added,
130 /// Old sheet with no counterpart in the new workbook.
131 Removed,
132 /// Name-matched, but the tab index moved between the two workbooks.
133 Moved,
134 /// Name changed; heuristically matched.
135 Renamed { confidence: MatchConfidence, reason: SheetMatchReason },
136 /// Both renamed and moved.
137 RenamedAndMoved { confidence: MatchConfidence, reason: SheetMatchReason },
138}
139
140// ---------------------------------------------------------------------------
141// CellValue and components (RFC-007 / RFC-033 §2–§3)
142// ---------------------------------------------------------------------------
143
144/// Spreadsheet-serial date/time value captured from calamine.
145///
146/// `serial` is the Excel date serial (days since 1900-01-00 or 1904-01-01).
147/// `is_1904` distinguishes the two date systems.
148/// `iso` is populated when calamine provides an ISO string directly or when the
149/// `chrono` feature can synthesize one.
150#[derive(Clone, PartialEq, Debug)]
151#[cfg_attr(feature = "serde", derive(Serialize))]
152#[non_exhaustive]
153pub struct CellDateTime {
154 pub serial: f64,
155 pub is_1904: bool,
156 pub kind: DateTimeKind,
157 pub iso: Option<String>,
158}
159
160/// Whether an Excel date serial represents a date, time, or datetime.
161#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162#[cfg_attr(feature = "serde", derive(Serialize))]
163#[non_exhaustive]
164pub enum DateTimeKind {
165 DateTime,
166 Date,
167 Time,
168}
169
170/// Spreadsheet-serial duration value (ISO 8601 duration string when available).
171#[derive(Clone, PartialEq, Debug)]
172#[cfg_attr(feature = "serde", derive(Serialize))]
173#[non_exhaustive]
174pub struct CellDuration {
175 pub serial: f64,
176 pub iso: Option<String>,
177}
178
179/// Typed spreadsheet cell error.
180///
181/// Maps 1-to-1 with calamine's `CellErrorType`; `Other` handles forward-compat.
182#[non_exhaustive]
183#[derive(Clone, PartialEq, Eq, Debug)]
184#[cfg_attr(feature = "serde", derive(Serialize))]
185pub enum CellError {
186 Div0,
187 NA,
188 Name,
189 Null,
190 Num,
191 Ref,
192 Value,
193 GettingData,
194 Other(String),
195}
196
197impl fmt::Display for CellError {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 match self {
200 CellError::Div0 => f.write_str("#DIV/0!"),
201 CellError::NA => f.write_str("#N/A"),
202 CellError::Name => f.write_str("#NAME?"),
203 CellError::Null => f.write_str("#NULL!"),
204 CellError::Num => f.write_str("#NUM!"),
205 CellError::Ref => f.write_str("#REF!"),
206 CellError::Value => f.write_str("#VALUE!"),
207 CellError::GettingData => f.write_str("#GETTING_DATA"),
208 CellError::Other(s) => write!(f, "#{s}"),
209 }
210 }
211}
212
213/// Typed representation of a spreadsheet cell value (RFC-033 §2).
214///
215/// `Integer` and `Number` are kept distinct (reflecting calamine's `Data::Int`
216/// / `Data::Float`). Default comparison treats `Integer(1)` vs `Number(1.0)`
217/// as a `TypeChanged` difference; cross-type numeric equality is opt-in
218/// (RFC-019).
219#[non_exhaustive]
220#[derive(Clone, PartialEq, Debug)]
221#[cfg_attr(feature = "serde", derive(Serialize))]
222pub enum CellValue {
223 Empty,
224 Text(String),
225 Integer(i64),
226 Number(f64),
227 Bool(bool),
228 DateTime(CellDateTime),
229 Duration(CellDuration),
230 Error(CellError),
231 Unsupported { display: String, reason: String },
232}
233
234impl CellValue {
235 /// A human-readable display string. For use in reports only; never used
236 /// as an equality key.
237 pub fn display_string(&self) -> String {
238 match self {
239 CellValue::Empty => String::new(),
240 CellValue::Text(s) => s.clone(),
241 CellValue::Integer(i) => i.to_string(),
242 CellValue::Number(f) => f.to_string(),
243 CellValue::Bool(b) => b.to_string(),
244 CellValue::DateTime(dt) => {
245 dt.iso.clone().unwrap_or_else(|| dt.serial.to_string())
246 }
247 CellValue::Duration(d) => {
248 d.iso.clone().unwrap_or_else(|| d.serial.to_string())
249 }
250 CellValue::Error(e) => e.to_string(),
251 CellValue::Unsupported { display, .. } => display.clone(),
252 }
253 }
254
255 /// True if the value is `Empty`.
256 pub fn is_empty(&self) -> bool {
257 matches!(self, CellValue::Empty)
258 }
259
260 /// Alias for `display_string` — preferred name per RFC-020.
261 #[inline]
262 pub fn display_default(&self) -> String {
263 self.display_string()
264 }
265}
266
267// ---------------------------------------------------------------------------
268// Display metadata (RFC-020)
269// ---------------------------------------------------------------------------
270
271/// Where a display string originated.
272#[derive(Clone, Copy, PartialEq, Eq, Debug)]
273#[cfg_attr(feature = "serde", derive(Serialize))]
274#[non_exhaustive]
275pub enum DisplaySource {
276 /// Provided directly by the workbook reader.
277 ReaderProvided,
278 /// Synthesised by `sheets-diff` from the typed value.
279 SheetsDiffDefault,
280 /// Substituted by the calling application.
281 ApplicationProvided,
282}
283
284/// A number-format identifier and/or code string captured from the workbook.
285///
286/// In calamine 0.35 neither field is available from cell data; both are
287/// `None` in v2.2. The struct is reserved so RFC-022 can populate it
288/// without an API break.
289#[derive(Clone, PartialEq, Eq, Debug, Default)]
290#[cfg_attr(feature = "serde", derive(Serialize))]
291#[non_exhaustive]
292pub struct CellNumberFormat {
293 /// Excel built-in format ID (e.g. `4` for `#,##0.00`).
294 pub id: Option<u32>,
295 /// Raw format code string (e.g. `"#,##0.00"`).
296 pub code: Option<String>,
297}
298
299/// Human-friendly display metadata attached to a cell value (RFC-020).
300///
301/// `text` is the primary display string. `format` and `source` are optional
302/// metadata; consumers may use them for localisation or formatting hints.
303#[derive(Clone, PartialEq, Eq, Debug)]
304#[cfg_attr(feature = "serde", derive(Serialize))]
305#[non_exhaustive]
306pub struct CellDisplay {
307 /// The display string — deterministic and locale-neutral by default.
308 pub text: String,
309 /// Number-format metadata when available (always `None` in calamine 0.35).
310 pub format: Option<CellNumberFormat>,
311 pub source: DisplaySource,
312}
313
314impl CellDisplay {
315 /// Construct a `CellDisplay` from its components.
316 pub fn new(text: String, format: Option<CellNumberFormat>, source: DisplaySource) -> Self {
317 Self { text, format, source }
318 }
319
320 /// Build a default display from a `CellValue`.
321 pub fn from_value(value: &CellValue) -> Self {
322 Self {
323 text: value.display_default(),
324 format: None,
325 source: DisplaySource::SheetsDiffDefault,
326 }
327 }
328}
329
330/// A full snapshot of one cell: typed value + optional formula + optional display
331/// metadata (RFC-020).
332///
333/// `display` is populated by default using `CellDisplay::from_value`; it can be
334/// overridden by the calling application without touching the typed value.
335#[derive(Clone, PartialEq, Debug)]
336#[cfg_attr(feature = "serde", derive(Serialize))]
337#[non_exhaustive]
338pub struct CellSnapshot {
339 pub value: CellValue,
340 pub formula: Option<crate::model::FormulaText>,
341 pub display: Option<CellDisplay>,
342}
343
344impl CellSnapshot {
345 /// Construct a `CellSnapshot` from its components.
346 pub fn new(value: CellValue, formula: Option<FormulaText>, display: Option<CellDisplay>) -> Self {
347 Self { value, formula, display }
348 }
349
350 /// Return the best available display string: `display.text` when present,
351 /// otherwise `value.display_default()`.
352 pub fn preferred_display(&self) -> String {
353 self.display
354 .as_ref()
355 .map(|d| d.text.clone())
356 .unwrap_or_else(|| self.value.display_default())
357 }
358}
359
360// ---------------------------------------------------------------------------
361// Cell change model (RFC-010 / RFC-033 §5)
362// ---------------------------------------------------------------------------
363
364/// Why two `CellValue`s were considered different.
365#[non_exhaustive]
366#[derive(Clone, PartialEq, Eq, Debug)]
367#[cfg_attr(feature = "serde", derive(Serialize))]
368pub enum ValueDifferenceKind {
369 /// The Rust enum variant changed (e.g. `Integer` → `Number`).
370 TypeChanged,
371 /// Same type, different content.
372 ContentChanged,
373 /// Same float type, outside the configured tolerance.
374 NumericOutsideTolerance,
375 /// Date/time serial or kind changed.
376 DateTimeChanged,
377 /// `CellError` variant changed.
378 ErrorKindChanged,
379 /// Compared as display strings (opt-in policy); strings differed.
380 DisplayStringChanged,
381}
382
383/// A value-layer change at one cell address.
384#[derive(Clone, PartialEq, Debug)]
385#[cfg_attr(feature = "serde", derive(Serialize))]
386#[non_exhaustive]
387pub struct ValueChange {
388 pub old: CellValue,
389 pub new: CellValue,
390 pub reason: ValueDifferenceKind,
391}
392
393/// A formula's text, with an optional normalised form.
394#[derive(Clone, PartialEq, Eq, Debug)]
395#[cfg_attr(feature = "serde", derive(Serialize))]
396#[non_exhaustive]
397pub struct FormulaText {
398 pub raw: String,
399 /// `None` unless the `NormalizedText` formula-compare mode is enabled and
400 /// a normaliser is available (RFC-018).
401 pub normalized: Option<String>,
402}
403
404/// A formula-layer change at one cell address.
405///
406/// `None` in `old` or `new` means the formula was added or removed.
407#[derive(Clone, PartialEq, Eq, Debug)]
408#[cfg_attr(feature = "serde", derive(Serialize))]
409#[non_exhaustive]
410pub struct FormulaChange {
411 pub old: Option<FormulaText>,
412 pub new: Option<FormulaText>,
413}
414
415/// Reserved for RFC-022 (style/format diffs). Always `None` — calamine 0.35
416/// does not expose a cell-style API. Set via `FormatCompareMode` (currently
417/// only `Ignore` is accepted).
418#[derive(Clone, PartialEq, Eq, Debug)]
419#[cfg_attr(feature = "serde", derive(Serialize))]
420#[non_exhaustive]
421pub struct FormatChange {
422 // Fields added in v2.x once RFC-022 is implemented.
423}
424
425/// Derived classification of a `CellDiff` entry.
426#[derive(Clone, Copy, PartialEq, Eq, Debug)]
427#[cfg_attr(feature = "serde", derive(Serialize))]
428#[non_exhaustive]
429pub enum CellChangeKind {
430 Added,
431 Removed,
432 Modified,
433}
434
435/// A merged per-cell diff entry (RFC-033 §5).
436///
437/// **One `CellDiff` per logical address.** This is the intended consumer model:
438/// a value change and a formula change at the same address are *facets of one
439/// change*, carried in the independent `value` and `formula` sub-fields, not
440/// two separate entries. The `output::view::CellChangeRow` projection follows
441/// the same rule (one row per address, with `formula_changed` / `old_formula` /
442/// `new_formula` describing the formula facet). Consumers migrating from a
443/// per-facet model should collapse to one row per address rather than preserve
444/// the split.
445///
446/// `change_kind()` is derived from the sub-fields, not stored.
447#[non_exhaustive]
448#[derive(Clone, PartialEq, Debug)]
449#[cfg_attr(feature = "serde", derive(Serialize))]
450pub struct CellDiff {
451 pub address: CellAddress,
452 pub value: Option<ValueChange>,
453 pub formula: Option<FormulaChange>,
454 /// Reserved until RFC-022.
455 pub format: Option<FormatChange>,
456 pub diagnostics: Vec<Diagnostic>,
457}
458
459impl CellDiff {
460 /// Derive Added / Removed / Modified from the sub-change fields.
461 ///
462 /// - **Added**: every present sub-change has an empty/absent `old` side.
463 /// - **Removed**: every present sub-change has an empty/absent `new` side.
464 /// - **Modified**: otherwise.
465 ///
466 /// This derivation is **stable API**: the rule above will not change within
467 /// a major version, so downstream code may depend on it rather than
468 /// re-deriving presence classification from the sub-fields.
469 pub fn change_kind(&self) -> CellChangeKind {
470 let has_old = self.value.as_ref().map(|v| !v.old.is_empty()).unwrap_or(false)
471 || self.formula.as_ref().map(|f| f.old.is_some()).unwrap_or(false);
472 let has_new = self.value.as_ref().map(|v| !v.new.is_empty()).unwrap_or(false)
473 || self.formula.as_ref().map(|f| f.new.is_some()).unwrap_or(false);
474 match (has_old, has_new) {
475 (false, true) => CellChangeKind::Added,
476 (true, false) => CellChangeKind::Removed,
477 _ => CellChangeKind::Modified,
478 }
479 }
480}
481
482// ---------------------------------------------------------------------------
483// Diagnostics (RFC-005 / RFC-033 §8)
484// ---------------------------------------------------------------------------
485
486/// Severity of a diagnostic entry.
487#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
488#[cfg_attr(feature = "serde", derive(Serialize))]
489#[non_exhaustive]
490pub enum Severity {
491 Info,
492 Warning,
493 Error,
494}
495
496/// Which processing stage emitted a diagnostic.
497#[derive(Clone, Copy, PartialEq, Eq, Debug)]
498#[cfg_attr(feature = "serde", derive(Serialize))]
499#[non_exhaustive]
500pub enum DiffStage {
501 Open,
502 Metadata,
503 Match,
504 Read,
505 Normalize,
506 Compare,
507 Aggregate,
508}
509
510/// Location context attached to a diagnostic.
511#[derive(Clone, PartialEq, Debug)]
512#[cfg_attr(feature = "serde", derive(Serialize))]
513#[non_exhaustive]
514pub struct DiagnosticLocation {
515 pub stage: DiffStage,
516 /// 0-based sheet order (workbook index), if applicable.
517 pub sheet_order: Option<usize>,
518 pub sheet_name: Option<String>,
519 pub address: Option<CellAddress>,
520}
521
522/// Structured diagnostic kind.
523///
524/// `code()` returns a stable string identifier for serde / localisation;
525/// it is never renamed within a major version.
526#[non_exhaustive]
527#[derive(Clone, PartialEq, Eq, Debug)]
528#[cfg_attr(feature = "serde", derive(Serialize))]
529pub enum DiagnosticKind {
530 FormulaUnavailable,
531 FormulaCachedValueUnverified,
532 AmbiguousSheetMatch { candidates: Vec<SheetRef> },
533 UnsupportedCellValue { detail: String },
534 UnsupportedWorkbookFeature { feature: String },
535 UnsupportedWorkbookMetadata { category: String },
536 DefinedNameScopeUnknown,
537 DateTimeNotNormalized,
538 LimitTruncatedCells { limit: String, observed: u64 },
539}
540
541impl DiagnosticKind {
542 /// Stable code string for this diagnostic kind.
543 ///
544 /// **These strings are the stable programmatic surface for diagnostics.**
545 /// Match on `code()` rather than on the `#[non_exhaustive]` enum variants:
546 /// new variants may be added in a minor release (which would break an
547 /// exhaustive `match` on the enum), but an existing code string is never
548 /// renamed within a major version. Codes also appear verbatim in serialised
549 /// JSON.
550 ///
551 /// The complete set of codes in this major version:
552 ///
553 /// | Code | Meaning |
554 /// |---|---|
555 /// | `formula_unavailable` | A cell's formula text could not be read |
556 /// | `formula_cached_value_unverified` | A formula's cached value could not be verified |
557 /// | `ambiguous_sheet_match` | Sheet rename detection found more than one candidate |
558 /// | `unsupported_cell_value` | A cell value could not be normalised to a `CellValue` |
559 /// | `unsupported_workbook_feature` | A non-cell object/sheet type is present but not compared |
560 /// | `unsupported_workbook_metadata` | A defined-name / visibility / metadata change was detected |
561 /// | `defined_name_scope_unknown` | Defined-name scope is unavailable from the reader |
562 /// | `datetime_not_normalized` | A date/time value could not be normalised to ISO form |
563 /// | `limit_truncated_cells` | A configured cell limit truncated the comparison |
564 ///
565 /// New codes added in later minor versions will extend this table; existing
566 /// rows are stable.
567 pub fn code(&self) -> &'static str {
568 match self {
569 DiagnosticKind::FormulaUnavailable => "formula_unavailable",
570 DiagnosticKind::FormulaCachedValueUnverified => "formula_cached_value_unverified",
571 DiagnosticKind::AmbiguousSheetMatch { .. } => "ambiguous_sheet_match",
572 DiagnosticKind::UnsupportedCellValue { .. } => "unsupported_cell_value",
573 DiagnosticKind::UnsupportedWorkbookFeature { .. } => "unsupported_workbook_feature",
574 DiagnosticKind::UnsupportedWorkbookMetadata { .. } => "unsupported_workbook_metadata",
575 DiagnosticKind::DefinedNameScopeUnknown => "defined_name_scope_unknown",
576 DiagnosticKind::DateTimeNotNormalized => "datetime_not_normalized",
577 DiagnosticKind::LimitTruncatedCells { .. } => "limit_truncated_cells",
578 }
579 }
580}
581
582/// A single structured diagnostic entry.
583#[derive(Clone, PartialEq, Debug)]
584#[cfg_attr(feature = "serde", derive(Serialize))]
585#[non_exhaustive]
586pub struct Diagnostic {
587 pub severity: Severity,
588 pub kind: DiagnosticKind,
589 pub location: DiagnosticLocation,
590 /// Human-readable message — for display only, not for programmatic matching.
591 pub message: String,
592}
593
594// ---------------------------------------------------------------------------
595// Summary types
596// ---------------------------------------------------------------------------
597
598/// Per-sheet summary counts.
599#[derive(Clone, Default, PartialEq, Debug)]
600#[cfg_attr(feature = "serde", derive(Serialize))]
601#[non_exhaustive]
602pub struct SheetSummary {
603 pub cells_changed: usize,
604 pub values_changed: usize,
605 pub formulas_changed: usize,
606}
607
608/// Diagnostic counts rolled up at any level.
609#[derive(Clone, Default, PartialEq, Debug)]
610#[cfg_attr(feature = "serde", derive(Serialize))]
611#[non_exhaustive]
612pub struct DiagnosticSummary {
613 pub errors: usize,
614 pub warnings: usize,
615 pub info: usize,
616}
617
618/// Top-level workbook diff summary.
619#[derive(Clone, Default, PartialEq, Debug)]
620#[cfg_attr(feature = "serde", derive(Serialize))]
621#[non_exhaustive]
622pub struct DiffSummary {
623 pub sheets_added: usize,
624 pub sheets_removed: usize,
625 pub sheets_renamed: usize,
626 pub sheets_moved: usize,
627 pub sheets_changed: usize,
628 pub cells_changed: usize,
629 pub values_changed: usize,
630 pub formulas_changed: usize,
631 pub diagnostics: DiagnosticSummary,
632}
633
634/// Internal processing metrics (RFC-024, RFC-027).
635///
636/// Useful for benchmarking, performance analysis, and debugging.
637/// Always populated; fields are cumulative across the whole comparison.
638#[derive(Clone, Default, PartialEq, Debug)]
639#[cfg_attr(feature = "serde", derive(Serialize))]
640#[non_exhaustive]
641pub struct DiffMetrics {
642 pub sheets_read: u32,
643 pub cells_read: u64,
644 pub cells_compared: u64,
645 pub diffs_emitted: u64,
646 pub diagnostics_emitted: u64,
647}
648
649// ---------------------------------------------------------------------------
650// SheetDiff
651// ---------------------------------------------------------------------------
652
653/// Summary of row-alignment decisions for a sheet pair (RFC-011).
654///
655/// `None` on `SheetDiff.alignment_summary` when mode is `Positional`.
656#[non_exhaustive]
657#[derive(Clone, PartialEq, Debug)]
658#[cfg_attr(feature = "serde", derive(Serialize))]
659pub struct AlignmentSummary {
660 pub inserted_rows: usize,
661 pub removed_rows: usize,
662 pub matched_rows: usize,
663 pub confidence: MatchConfidence,
664}
665
666/// The diff result for one logical sheet pair.
667#[non_exhaustive]
668#[derive(Clone, PartialEq, Debug)]
669#[cfg_attr(feature = "serde", derive(Serialize))]
670pub struct SheetDiff {
671 /// The sheet on the old side (`None` for Added sheets).
672 pub old_sheet: Option<SheetRef>,
673 /// The sheet on the new side (`None` for Removed sheets).
674 pub new_sheet: Option<SheetRef>,
675 pub change: SheetChange,
676 /// Cell diffs sorted by `(row, col)`.
677 pub cell_diffs: Vec<CellDiff>,
678 pub compared_range: ComparedRange,
679 /// Reserved until RFC-011.
680 pub alignment_summary: Option<AlignmentSummary>,
681 pub diagnostics: Vec<Diagnostic>,
682 pub summary: SheetSummary,
683}
684
685// ---------------------------------------------------------------------------
686// Workbook-level change placeholders (RFC-021/023, reserved in v2.0)
687// ---------------------------------------------------------------------------
688
689/// Reserved for RFC-021 (workbook metadata diffs). Always empty in v2.0.
690#[non_exhaustive]
691#[derive(Clone, PartialEq, Debug)]
692#[cfg_attr(feature = "serde", derive(Serialize))]
693pub struct WorkbookChange {
694 // Populated by RFC-021 implementation.
695}
696
697/// Reserved for RFC-023 (non-cell object diffs). Always empty in v2.0.
698#[non_exhaustive]
699#[derive(Clone, PartialEq, Debug)]
700#[cfg_attr(feature = "serde", derive(Serialize))]
701pub struct WorkbookObjectChange {
702 // Populated by RFC-023 implementation.
703}
704
705// ---------------------------------------------------------------------------
706// Top-level result (RFC-033 §12)
707// ---------------------------------------------------------------------------
708
709/// The complete diff result for a workbook pair.
710///
711/// `workbook_changes` and `object_changes` are always empty — RFC-021/023
712/// surface their findings through `diagnostics` in v2.2, and structured
713/// variants await a future release. The struct is `#[non_exhaustive]` so
714/// they can be populated additively without a breaking change.
715///
716/// # Extracting a lightweight summary
717///
718/// `summary` ([`DiffSummary`]), `metrics` ([`DiffMetrics`]), and each sheet's
719/// `change` ([`SheetChange`]) are all cheap, small, owned values. Memory-conscious
720/// consumers that only need counts and the sheet-change list can clone those out
721/// and drop the whole `WorkbookDiff` — including the potentially large
722/// `sheets[..].cell_diffs` vectors — at their adapter boundary:
723///
724/// ```no_run
725/// # use sheets_diff::compare_paths;
726/// let diff = compare_paths("a.xlsx", "b.xlsx")?;
727/// let summary = diff.summary.clone(); // cheap
728/// let metrics = diff.metrics.clone(); // cheap
729/// let sheet_changes: Vec<_> =
730/// diff.sheets.iter().map(|s| s.change.clone()).collect();
731/// drop(diff); // releases all cell_diffs
732/// # Ok::<(), sheets_diff::SheetsDiffError>(())
733/// ```
734#[non_exhaustive]
735#[derive(Clone, PartialEq, Debug)]
736#[cfg_attr(feature = "serde", derive(Serialize))]
737pub struct WorkbookDiff {
738 pub old: WorkbookSideInfo,
739 pub new: WorkbookSideInfo,
740 /// Sheet diffs in old-workbook sheet order (then new-workbook order for
741 /// added sheets).
742 pub sheets: Vec<SheetDiff>,
743 /// Always empty in v2.2; reserved for future structured workbook-level changes.
744 pub workbook_changes: Vec<WorkbookChange>,
745 /// Always empty in v2.2; reserved for future structured object-level changes.
746 pub object_changes: Vec<WorkbookObjectChange>,
747 pub diagnostics: Vec<Diagnostic>,
748 pub summary: DiffSummary,
749 /// Processing metrics for benchmarking and performance analysis (RFC-024/027).
750 pub metrics: DiffMetrics,
751}
752
753// ---------------------------------------------------------------------------
754// Summary derivation helpers
755// ---------------------------------------------------------------------------
756
757impl WorkbookDiff {
758 pub(crate) fn derive_summary(sheets: &[SheetDiff], diagnostics: &[Diagnostic]) -> DiffSummary {
759 let mut s = DiffSummary::default();
760 for sd in sheets {
761 match sd.change {
762 SheetChange::Added => s.sheets_added += 1,
763 SheetChange::Removed => s.sheets_removed += 1,
764 SheetChange::Renamed { .. } => {
765 s.sheets_renamed += 1;
766 if !sd.cell_diffs.is_empty() {
767 s.sheets_changed += 1;
768 }
769 }
770 SheetChange::RenamedAndMoved { .. } => {
771 s.sheets_renamed += 1;
772 s.sheets_moved += 1;
773 if !sd.cell_diffs.is_empty() {
774 s.sheets_changed += 1;
775 }
776 }
777 SheetChange::Moved => {
778 s.sheets_moved += 1;
779 if !sd.cell_diffs.is_empty() {
780 s.sheets_changed += 1;
781 }
782 }
783 SheetChange::Modified => s.sheets_changed += 1,
784 SheetChange::Unchanged => {}
785 }
786 s.cells_changed += sd.summary.cells_changed;
787 s.values_changed += sd.summary.values_changed;
788 s.formulas_changed += sd.summary.formulas_changed;
789 }
790 for d in diagnostics {
791 match d.severity {
792 Severity::Error => s.diagnostics.errors += 1,
793 Severity::Warning => s.diagnostics.warnings += 1,
794 Severity::Info => s.diagnostics.info += 1,
795 }
796 }
797 s
798 }
799}