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
252// ---------------------------------------------------------------------------
253// Cell change model (RFC-010 / RFC-033 §5)
254// ---------------------------------------------------------------------------
255
256/// Why two `CellValue`s were considered different.
257#[non_exhaustive]
258#[derive(Clone, PartialEq, Eq, Debug)]
259#[cfg_attr(feature = "serde", derive(Serialize))]
260pub enum ValueDifferenceKind {
261    /// The Rust enum variant changed (e.g. `Integer` → `Number`).
262    TypeChanged,
263    /// Same type, different content.
264    ContentChanged,
265    /// Same float type, outside the configured tolerance.
266    NumericOutsideTolerance,
267    /// Date/time serial or kind changed.
268    DateTimeChanged,
269    /// `CellError` variant changed.
270    ErrorKindChanged,
271    /// Compared as display strings (opt-in policy); strings differed.
272    DisplayStringChanged,
273}
274
275/// A value-layer change at one cell address.
276#[derive(Clone, PartialEq, Debug)]
277#[cfg_attr(feature = "serde", derive(Serialize))]
278pub struct ValueChange {
279    pub old: CellValue,
280    pub new: CellValue,
281    pub reason: ValueDifferenceKind,
282}
283
284/// A formula's text, with an optional normalised form.
285#[derive(Clone, PartialEq, Eq, Debug)]
286#[cfg_attr(feature = "serde", derive(Serialize))]
287pub struct FormulaText {
288    pub raw: String,
289    /// `None` unless the `NormalizedText` formula-compare mode is enabled and
290    /// a normaliser is available (RFC-018).
291    pub normalized: Option<String>,
292}
293
294/// A formula-layer change at one cell address.
295///
296/// `None` in `old` or `new` means the formula was added or removed.
297#[derive(Clone, PartialEq, Eq, Debug)]
298#[cfg_attr(feature = "serde", derive(Serialize))]
299pub struct FormulaChange {
300    pub old: Option<FormulaText>,
301    pub new: Option<FormulaText>,
302}
303
304/// Reserved for RFC-022 (style/format diffs).  Always `None` in v2.0.
305#[derive(Clone, PartialEq, Eq, Debug)]
306#[cfg_attr(feature = "serde", derive(Serialize))]
307pub struct FormatChange {
308    // Fields added in v2.x once RFC-022 is implemented.
309}
310
311/// Derived classification of a `CellDiff` entry.
312#[derive(Clone, Copy, PartialEq, Eq, Debug)]
313#[cfg_attr(feature = "serde", derive(Serialize))]
314pub enum CellChangeKind {
315    Added,
316    Removed,
317    Modified,
318}
319
320/// A merged per-cell diff entry (RFC-033 §5).
321///
322/// One `CellDiff` per logical address.  Value and formula changes are
323/// independent sub-fields.  `change_kind()` is derived, not stored.
324#[non_exhaustive]
325#[derive(Clone, PartialEq, Debug)]
326#[cfg_attr(feature = "serde", derive(Serialize))]
327pub struct CellDiff {
328    pub address: CellAddress,
329    pub value: Option<ValueChange>,
330    pub formula: Option<FormulaChange>,
331    /// Reserved until RFC-022.
332    pub format: Option<FormatChange>,
333    pub diagnostics: Vec<Diagnostic>,
334}
335
336impl CellDiff {
337    /// Derive Added / Removed / Modified from the sub-change fields.
338    ///
339    /// - **Added**: every present sub-change has `old == None`.
340    /// - **Removed**: every present sub-change has `new == None`.
341    /// - **Modified**: otherwise.
342    pub fn change_kind(&self) -> CellChangeKind {
343        let has_old = self.value.as_ref().map(|v| !v.old.is_empty()).unwrap_or(false)
344            || self.formula.as_ref().map(|f| f.old.is_some()).unwrap_or(false);
345        let has_new = self.value.as_ref().map(|v| !v.new.is_empty()).unwrap_or(false)
346            || self.formula.as_ref().map(|f| f.new.is_some()).unwrap_or(false);
347        match (has_old, has_new) {
348            (false, true) => CellChangeKind::Added,
349            (true, false) => CellChangeKind::Removed,
350            _ => CellChangeKind::Modified,
351        }
352    }
353}
354
355// ---------------------------------------------------------------------------
356// Diagnostics (RFC-005 / RFC-033 §8)
357// ---------------------------------------------------------------------------
358
359/// Severity of a diagnostic entry.
360#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
361#[cfg_attr(feature = "serde", derive(Serialize))]
362pub enum Severity {
363    Info,
364    Warning,
365    Error,
366}
367
368/// Which processing stage emitted a diagnostic.
369#[derive(Clone, Copy, PartialEq, Eq, Debug)]
370#[cfg_attr(feature = "serde", derive(Serialize))]
371pub enum DiffStage {
372    Open,
373    Metadata,
374    Match,
375    Read,
376    Normalize,
377    Compare,
378    Aggregate,
379}
380
381/// Location context attached to a diagnostic.
382#[derive(Clone, PartialEq, Debug)]
383#[cfg_attr(feature = "serde", derive(Serialize))]
384pub struct DiagnosticLocation {
385    pub stage: DiffStage,
386    /// 0-based sheet order (workbook index), if applicable.
387    pub sheet_order: Option<usize>,
388    pub sheet_name: Option<String>,
389    pub address: Option<CellAddress>,
390}
391
392/// Structured diagnostic kind.
393///
394/// `code()` returns a stable string identifier for serde / localisation;
395/// it is never renamed within a major version.
396#[non_exhaustive]
397#[derive(Clone, PartialEq, Eq, Debug)]
398#[cfg_attr(feature = "serde", derive(Serialize))]
399pub enum DiagnosticKind {
400    FormulaUnavailable,
401    FormulaCachedValueUnverified,
402    AmbiguousSheetMatch { candidates: Vec<SheetRef> },
403    UnsupportedCellValue { detail: String },
404    UnsupportedWorkbookFeature { feature: String },
405    UnsupportedWorkbookMetadata { category: String },
406    DefinedNameScopeUnknown,
407    DateTimeNotNormalized,
408    LimitTruncatedCells { limit: String, observed: u64 },
409}
410
411impl DiagnosticKind {
412    /// Stable code string, safe to match in downstream code and serialised
413    /// JSON.
414    pub fn code(&self) -> &'static str {
415        match self {
416            DiagnosticKind::FormulaUnavailable => "formula_unavailable",
417            DiagnosticKind::FormulaCachedValueUnverified => "formula_cached_value_unverified",
418            DiagnosticKind::AmbiguousSheetMatch { .. } => "ambiguous_sheet_match",
419            DiagnosticKind::UnsupportedCellValue { .. } => "unsupported_cell_value",
420            DiagnosticKind::UnsupportedWorkbookFeature { .. } => "unsupported_workbook_feature",
421            DiagnosticKind::UnsupportedWorkbookMetadata { .. } => "unsupported_workbook_metadata",
422            DiagnosticKind::DefinedNameScopeUnknown => "defined_name_scope_unknown",
423            DiagnosticKind::DateTimeNotNormalized => "datetime_not_normalized",
424            DiagnosticKind::LimitTruncatedCells { .. } => "limit_truncated_cells",
425        }
426    }
427}
428
429/// A single structured diagnostic entry.
430#[derive(Clone, PartialEq, Debug)]
431#[cfg_attr(feature = "serde", derive(Serialize))]
432pub struct Diagnostic {
433    pub severity: Severity,
434    pub kind: DiagnosticKind,
435    pub location: DiagnosticLocation,
436    /// Human-readable message — for display only, not for programmatic matching.
437    pub message: String,
438}
439
440// ---------------------------------------------------------------------------
441// Summary types
442// ---------------------------------------------------------------------------
443
444/// Per-sheet summary counts.
445#[derive(Clone, Default, PartialEq, Debug)]
446#[cfg_attr(feature = "serde", derive(Serialize))]
447pub struct SheetSummary {
448    pub cells_changed: usize,
449    pub values_changed: usize,
450    pub formulas_changed: usize,
451}
452
453/// Diagnostic counts rolled up at any level.
454#[derive(Clone, Default, PartialEq, Debug)]
455#[cfg_attr(feature = "serde", derive(Serialize))]
456pub struct DiagnosticSummary {
457    pub errors: usize,
458    pub warnings: usize,
459    pub info: usize,
460}
461
462/// Top-level workbook diff summary.
463#[derive(Clone, Default, PartialEq, Debug)]
464#[cfg_attr(feature = "serde", derive(Serialize))]
465pub struct DiffSummary {
466    pub sheets_added: usize,
467    pub sheets_removed: usize,
468    pub sheets_renamed: usize,
469    pub sheets_moved: usize,
470    pub sheets_changed: usize,
471    pub cells_changed: usize,
472    pub values_changed: usize,
473    pub formulas_changed: usize,
474    pub diagnostics: DiagnosticSummary,
475}
476
477// ---------------------------------------------------------------------------
478// SheetDiff
479// ---------------------------------------------------------------------------
480
481/// Reserved field for RFC-011 (row/column alignment summaries).
482#[non_exhaustive]
483#[derive(Clone, PartialEq, Debug)]
484#[cfg_attr(feature = "serde", derive(Serialize))]
485pub struct AlignmentSummary {
486    // Fields added when RFC-011 is implemented.
487}
488
489/// The diff result for one logical sheet pair.
490#[non_exhaustive]
491#[derive(Clone, PartialEq, Debug)]
492#[cfg_attr(feature = "serde", derive(Serialize))]
493pub struct SheetDiff {
494    /// The sheet on the old side (`None` for Added sheets).
495    pub old_sheet: Option<SheetRef>,
496    /// The sheet on the new side (`None` for Removed sheets).
497    pub new_sheet: Option<SheetRef>,
498    pub change: SheetChange,
499    /// Cell diffs sorted by `(row, col)`.
500    pub cell_diffs: Vec<CellDiff>,
501    pub compared_range: ComparedRange,
502    /// Reserved until RFC-011.
503    pub alignment_summary: Option<AlignmentSummary>,
504    pub diagnostics: Vec<Diagnostic>,
505    pub summary: SheetSummary,
506}
507
508// ---------------------------------------------------------------------------
509// Workbook-level change placeholders (RFC-021/023, reserved in v2.0)
510// ---------------------------------------------------------------------------
511
512/// Reserved for RFC-021 (workbook metadata diffs).  Always empty in v2.0.
513#[non_exhaustive]
514#[derive(Clone, PartialEq, Debug)]
515#[cfg_attr(feature = "serde", derive(Serialize))]
516pub struct WorkbookChange {
517    // Populated by RFC-021 implementation.
518}
519
520/// Reserved for RFC-023 (non-cell object diffs).  Always empty in v2.0.
521#[non_exhaustive]
522#[derive(Clone, PartialEq, Debug)]
523#[cfg_attr(feature = "serde", derive(Serialize))]
524pub struct WorkbookObjectChange {
525    // Populated by RFC-023 implementation.
526}
527
528// ---------------------------------------------------------------------------
529// Top-level result (RFC-033 §12)
530// ---------------------------------------------------------------------------
531
532/// The complete diff result for a workbook pair.
533///
534/// `workbook_changes` and `object_changes` are reserved for RFC-021/023 (v2.1+)
535/// and are always empty in v2.0.  Because the struct is `#[non_exhaustive]` and
536/// read-only for application code, those fields can be populated additively.
537#[non_exhaustive]
538#[derive(Clone, PartialEq, Debug)]
539#[cfg_attr(feature = "serde", derive(Serialize))]
540pub struct WorkbookDiff {
541    pub old: WorkbookSideInfo,
542    pub new: WorkbookSideInfo,
543    /// Sheet diffs in old-workbook sheet order (then new-workbook order for
544    /// added sheets).
545    pub sheets: Vec<SheetDiff>,
546    /// Reserved; empty until RFC-021.
547    pub workbook_changes: Vec<WorkbookChange>,
548    /// Reserved; empty until RFC-023.
549    pub object_changes: Vec<WorkbookObjectChange>,
550    pub diagnostics: Vec<Diagnostic>,
551    pub summary: DiffSummary,
552}
553
554// ---------------------------------------------------------------------------
555// Summary derivation helpers
556// ---------------------------------------------------------------------------
557
558impl WorkbookDiff {
559    pub(crate) fn derive_summary(sheets: &[SheetDiff], diagnostics: &[Diagnostic]) -> DiffSummary {
560        let mut s = DiffSummary::default();
561        for sd in sheets {
562            match sd.change {
563                SheetChange::Added => s.sheets_added += 1,
564                SheetChange::Removed => s.sheets_removed += 1,
565                SheetChange::Renamed { .. } => {
566                    s.sheets_renamed += 1;
567                    if !sd.cell_diffs.is_empty() {
568                        s.sheets_changed += 1;
569                    }
570                }
571                SheetChange::RenamedAndMoved { .. } => {
572                    s.sheets_renamed += 1;
573                    s.sheets_moved += 1;
574                    if !sd.cell_diffs.is_empty() {
575                        s.sheets_changed += 1;
576                    }
577                }
578                SheetChange::Moved => {
579                    s.sheets_moved += 1;
580                    if !sd.cell_diffs.is_empty() {
581                        s.sheets_changed += 1;
582                    }
583                }
584                SheetChange::Modified => s.sheets_changed += 1,
585                SheetChange::Unchanged => {}
586            }
587            s.cells_changed += sd.summary.cells_changed;
588            s.values_changed += sd.summary.values_changed;
589            s.formulas_changed += sd.summary.formulas_changed;
590        }
591        for d in diagnostics {
592            match d.severity {
593                Severity::Error => s.diagnostics.errors += 1,
594                Severity::Warning => s.diagnostics.warnings += 1,
595                Severity::Info => s.diagnostics.info += 1,
596            }
597        }
598        s
599    }
600}