Skip to main content

sheets_diff/
model.rs

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