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