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}