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