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}