Skip to main content

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}