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 /// Sheet pairs processed — one per matched, added, or removed sheet.
727 pub sheets_read: u32,
728 /// Every cell physically visited while reading both workbooks,
729 /// **including empty cells inside the used range** — this is not the
730 /// number of cells with content. On the `sparse_range` corpus fixture
731 /// (two populated cells far apart in a large used range) this reads
732 /// **5200** against `cells_compared`'s **2**: almost the entire count
733 /// is empty cells the used range spans but never populates. Always
734 /// `>= cells_compared`.
735 pub cells_read: u64,
736 /// Every coordinate compared between the two sides: the union of both
737 /// sides' populated cells for each sheet pair, remapped by alignment
738 /// when alignment is not `Positional`. Counted once per coordinate
739 /// regardless of whether it produced a diff — always `>= diffs_emitted`.
740 pub cells_compared: u64,
741 /// Cell-level differences returned in the result — the count behind
742 /// every `CellDiff` across all sheets. Always `<= cells_compared`.
743 pub diffs_emitted: u64,
744 /// `Diagnostic` entries attached to the result, workbook-level and
745 /// per-sheet combined.
746 pub diagnostics_emitted: u64,
747}
748
749// ---------------------------------------------------------------------------
750// SheetDiff
751// ---------------------------------------------------------------------------
752
753/// Summary of row-alignment decisions for a sheet pair (RFC-011).
754///
755/// `None` on `SheetDiff.alignment_summary` when mode is `Positional`.
756#[non_exhaustive]
757#[derive(Clone, PartialEq, Debug)]
758#[cfg_attr(feature = "serde", derive(Serialize))]
759pub struct AlignmentSummary {
760 pub inserted_rows: usize,
761 pub removed_rows: usize,
762 pub matched_rows: usize,
763 pub confidence: MatchConfidence,
764}
765
766/// The diff result for one logical sheet pair.
767#[non_exhaustive]
768#[derive(Clone, PartialEq, Debug)]
769#[cfg_attr(feature = "serde", derive(Serialize))]
770pub struct SheetDiff {
771 /// The sheet on the old side (`None` for Added sheets).
772 pub old_sheet: Option<SheetRef>,
773 /// The sheet on the new side (`None` for Removed sheets).
774 pub new_sheet: Option<SheetRef>,
775 pub change: SheetChange,
776 /// Cell diffs sorted by `(row, col)`.
777 pub cell_diffs: Vec<CellDiff>,
778 pub compared_range: ComparedRange,
779 /// Reserved until RFC-011.
780 pub alignment_summary: Option<AlignmentSummary>,
781 pub diagnostics: Vec<Diagnostic>,
782 pub summary: SheetSummary,
783}
784
785// ---------------------------------------------------------------------------
786// Workbook-level change placeholders (RFC-021/023, reserved)
787// ---------------------------------------------------------------------------
788
789/// Reserved for RFC-021 (workbook metadata diffs). Always empty.
790#[non_exhaustive]
791#[derive(Clone, PartialEq, Debug)]
792#[cfg_attr(feature = "serde", derive(Serialize))]
793pub struct WorkbookChange {
794 // Populated by RFC-021 implementation.
795}
796
797/// Reserved for RFC-023 (non-cell object diffs). Always empty.
798#[non_exhaustive]
799#[derive(Clone, PartialEq, Debug)]
800#[cfg_attr(feature = "serde", derive(Serialize))]
801pub struct WorkbookObjectChange {
802 // Populated by RFC-023 implementation.
803}
804
805// ---------------------------------------------------------------------------
806// Top-level result (RFC-033 §12)
807// ---------------------------------------------------------------------------
808
809/// The complete diff result for a workbook pair.
810///
811/// `workbook_changes` and `object_changes` are always empty — RFC-021/023
812/// surface their findings through `diagnostics`, and structured variants
813/// await a future release. The struct is `#[non_exhaustive]` so
814/// they can be populated additively without a breaking change.
815///
816/// # Extracting a lightweight summary
817///
818/// `summary` ([`DiffSummary`]), `metrics` ([`DiffMetrics`]), and each sheet's
819/// `change` ([`SheetChange`]) are all cheap, small, owned values. Memory-conscious
820/// consumers that only need counts and the sheet-change list can clone those out
821/// and drop the whole `WorkbookDiff` — including the potentially large
822/// `sheets[..].cell_diffs` vectors — at their adapter boundary:
823///
824/// ```no_run
825/// # use sheets_diff::compare_paths;
826/// let diff = compare_paths("a.xlsx", "b.xlsx")?;
827/// let summary = diff.summary.clone(); // cheap
828/// let metrics = diff.metrics.clone(); // cheap
829/// let sheet_changes: Vec<_> =
830/// diff.sheets.iter().map(|s| s.change.clone()).collect();
831/// drop(diff); // releases all cell_diffs
832/// # Ok::<(), sheets_diff::SheetsDiffError>(())
833/// ```
834#[non_exhaustive]
835#[derive(Clone, PartialEq, Debug)]
836#[cfg_attr(feature = "serde", derive(Serialize))]
837pub struct WorkbookDiff {
838 pub old: WorkbookSideInfo,
839 pub new: WorkbookSideInfo,
840 /// Sheet diffs in old-workbook sheet order (then new-workbook order for
841 /// added sheets).
842 pub sheets: Vec<SheetDiff>,
843 /// Always empty; reserved for future structured workbook-level changes.
844 pub workbook_changes: Vec<WorkbookChange>,
845 /// Always empty; reserved for future structured object-level changes.
846 pub object_changes: Vec<WorkbookObjectChange>,
847 pub diagnostics: Vec<Diagnostic>,
848 pub summary: DiffSummary,
849 /// Processing metrics for benchmarking and performance analysis (RFC-024/027).
850 pub metrics: DiffMetrics,
851}
852
853// ---------------------------------------------------------------------------
854// Summary derivation helpers
855// ---------------------------------------------------------------------------
856
857impl WorkbookDiff {
858 pub(crate) fn derive_summary(sheets: &[SheetDiff], diagnostics: &[Diagnostic]) -> DiffSummary {
859 let mut s = DiffSummary::default();
860 for sd in sheets {
861 match sd.change {
862 SheetChange::Added => s.sheets_added += 1,
863 SheetChange::Removed => s.sheets_removed += 1,
864 SheetChange::Renamed { .. } => {
865 s.sheets_renamed += 1;
866 if !sd.cell_diffs.is_empty() {
867 s.sheets_changed += 1;
868 }
869 }
870 SheetChange::RenamedAndMoved { .. } => {
871 s.sheets_renamed += 1;
872 s.sheets_moved += 1;
873 if !sd.cell_diffs.is_empty() {
874 s.sheets_changed += 1;
875 }
876 }
877 SheetChange::Moved => {
878 s.sheets_moved += 1;
879 if !sd.cell_diffs.is_empty() {
880 s.sheets_changed += 1;
881 }
882 }
883 SheetChange::Modified => s.sheets_changed += 1,
884 SheetChange::Unchanged => {}
885 }
886 s.cells_changed += sd.summary.cells_changed;
887 s.values_changed += sd.summary.values_changed;
888 s.formulas_changed += sd.summary.formulas_changed;
889 }
890 for d in diagnostics {
891 match d.severity {
892 Severity::Error => s.diagnostics.errors += 1,
893 Severity::Warning => s.diagnostics.warnings += 1,
894 Severity::Info => s.diagnostics.info += 1,
895 }
896 }
897 s
898 }
899}