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