Skip to main content

sheets_diff/
compare.rs

1//! Cell-level value and formula comparison (RFC-010, RFC-018, RFC-019).
2
3use crate::model::{
4    CellDateTime, CellValue, FormulaChange, FormulaText, ValueChange, ValueDifferenceKind,
5};
6use crate::options::{
7    DateComparePolicy, FormulaCompareMode, NumberComparePolicy, NumericTypePolicy,
8    TypeMismatchPolicy, ValueCompareOptions,
9};
10
11// ---------------------------------------------------------------------------
12// Value comparison
13// ---------------------------------------------------------------------------
14
15/// Compare two `CellValue`s under the supplied options.
16///
17/// Returns `Some(ValueChange)` when the values differ; `None` when equal.
18pub fn compare_values(
19    old: &CellValue,
20    new: &CellValue,
21    opts: &ValueCompareOptions,
22) -> Option<ValueChange> {
23    use CellValue::*;
24
25    let reason = match (old, new) {
26        // Same variant comparisons
27        (Empty, Empty) => return None,
28        (Text(a), Text(b)) if a == b => return None,
29        (Text(a), Text(b)) => {
30            debug_assert!(a != b);
31            ValueDifferenceKind::ContentChanged
32        }
33        (Bool(a), Bool(b)) if a == b => return None,
34        (Bool(_), Bool(_)) => ValueDifferenceKind::ContentChanged,
35        (Integer(a), Integer(b)) if a == b => return None,
36        (Integer(_), Integer(_)) => ValueDifferenceKind::ContentChanged,
37        (Number(a), Number(b)) => compare_floats(*a, *b, &opts.number)?,
38        (Error(a), Error(b)) if a == b => return None,
39        (Error(_), Error(_)) => ValueDifferenceKind::ErrorKindChanged,
40        (DateTime(a), DateTime(b)) => {
41            if datetime_equal(a, b) {
42                return None;
43            }
44            match opts.date {
45                DateComparePolicy::ExactRepresentation => ValueDifferenceKind::DateTimeChanged,
46                DateComparePolicy::NormalizeEquivalentDateTimes => {
47                    // Attempt serial normalization across 1900/1904 systems.
48                    // Only meaningful when both sides carry a genuine serial
49                    // (D-01) — an ISO-only value has no epoch to normalise.
50                    if a.has_serial
51                        && b.has_serial
52                        && normalized_serial_eq(a.serial, a.is_1904, b.serial, b.is_1904)
53                    {
54                        return None;
55                    }
56                    ValueDifferenceKind::DateTimeChanged
57                }
58            }
59        }
60        (Duration(a), Duration(b)) => {
61            let equal = match (&a.iso, &b.iso) {
62                // Both carry an ISO string: it is the authoritative
63                // representation for a duration (RFC-019 / D-01 — `serial`
64                // is currently always a `0.0` placeholder here; comparing
65                // `iso` is what actually distinguishes two durations).
66                (Some(ai), Some(bi)) => ai == bi,
67                (None, None) => a.serial == b.serial,
68                // One side has an ISO string and the other doesn't: never
69                // silently equal (D-01) — there is no reliable common
70                // representation to compare through.
71                _ => false,
72            };
73            if equal {
74                return None;
75            }
76            ValueDifferenceKind::ContentChanged
77        }
78        (Unsupported { display: a, .. }, Unsupported { display: b, .. }) if a == b => return None,
79        (Unsupported { .. }, Unsupported { .. }) => ValueDifferenceKind::ContentChanged,
80
81        // Cross-type: Integer vs Number
82        (Integer(i), Number(f)) | (Number(f), Integer(i)) => match opts.numeric_type {
83            NumericTypePolicy::PreserveType => ValueDifferenceKind::TypeChanged,
84            NumericTypePolicy::CompareMathematicalValue => {
85                if *i as f64 == *f {
86                    return None;
87                }
88                ValueDifferenceKind::ContentChanged
89            }
90        },
91
92        // Cross-type: everything else
93        _ => match opts.type_mismatch {
94            TypeMismatchPolicy::Different => ValueDifferenceKind::TypeChanged,
95            TypeMismatchPolicy::CompareDisplayString => {
96                let a_str = old.display_string();
97                let b_str = new.display_string();
98                if a_str == b_str {
99                    return None;
100                }
101                ValueDifferenceKind::DisplayStringChanged
102            }
103        },
104    };
105
106    Some(ValueChange {
107        old: old.clone(),
108        new: new.clone(),
109        reason,
110    })
111}
112
113/// Equality for the default (`ExactRepresentation`) date/time comparison.
114///
115/// D-01: a value from `Data::DateTimeIso` has no genuine Excel serial — its
116/// `serial` field is a `0.0` placeholder (`has_serial: false`) and `iso` is
117/// the only meaningful representation. A value from `Data::DateTime` always
118/// has a genuine serial (`has_serial: true`); when the `chrono` feature is
119/// enabled it may *also* carry a synthesized `iso` string, but that string
120/// is redundant with the serial, not authoritative — comparing it instead
121/// of the serial would risk losing precision (the synthesized string has
122/// only second resolution) and would make the comparison result depend on
123/// whether `chrono` is enabled, which must not happen.
124///
125/// So: two genuine serials compare via serial/`is_1904`/`kind`, unchanged
126/// from before. Two ISO-only values compare via `iso`. A serial-based value
127/// against an ISO-only value has no shared representation to compare
128/// through and is never silently equal.
129fn datetime_equal(a: &CellDateTime, b: &CellDateTime) -> bool {
130    match (a.has_serial, b.has_serial) {
131        (true, true) => a.serial == b.serial && a.is_1904 == b.is_1904 && a.kind == b.kind,
132        (false, false) => a.iso == b.iso,
133        _ => false,
134    }
135}
136
137fn compare_floats(a: f64, b: f64, policy: &NumberComparePolicy) -> Option<ValueDifferenceKind> {
138    let equal = match policy {
139        NumberComparePolicy::Exact => a == b || (a.is_nan() && b.is_nan()),
140        NumberComparePolicy::AbsoluteTolerance(tol) => (a - b).abs() <= *tol,
141        NumberComparePolicy::RelativeTolerance(tol) => {
142            let denom = a.abs().max(b.abs());
143            denom == 0.0 || (a - b).abs() / denom <= *tol
144        }
145        NumberComparePolicy::AbsoluteOrRelative { abs, rel } => {
146            let abs_ok = (a - b).abs() <= *abs;
147            let denom = a.abs().max(b.abs());
148            let rel_ok = denom == 0.0 || (a - b).abs() / denom <= *rel;
149            abs_ok || rel_ok
150        }
151    };
152    if equal {
153        None
154    } else {
155        Some(ValueDifferenceKind::NumericOutsideTolerance)
156    }
157}
158
159/// Normalise serials across 1900 / 1904 date systems.
160/// The offset between the two systems is 1462 days.
161fn normalized_serial_eq(a_serial: f64, a_1904: bool, b_serial: f64, b_1904: bool) -> bool {
162    const OFFSET: f64 = 1462.0;
163    let a_norm = if a_1904 { a_serial + OFFSET } else { a_serial };
164    let b_norm = if b_1904 { b_serial + OFFSET } else { b_serial };
165    a_norm == b_norm
166}
167
168// ---------------------------------------------------------------------------
169// Formula comparison (RFC-018)
170// ---------------------------------------------------------------------------
171
172/// Compare formula strings under the configured mode.
173///
174/// Returns `Some(FormulaChange)` when the formulas differ; `None` when equal or
175/// when the mode is `Ignore`.
176pub fn compare_formulas(
177    old_formula: Option<&str>,
178    new_formula: Option<&str>,
179    mode: FormulaCompareMode,
180) -> Option<FormulaChange> {
181    if mode == FormulaCompareMode::Ignore {
182        return None;
183    }
184
185    let old_text = old_formula.map(|r| FormulaText {
186        raw: r.to_owned(),
187        normalized: None, // NormalizedText mode guard is in options validation
188    });
189    let new_text = new_formula.map(|r| FormulaText {
190        raw: r.to_owned(),
191        normalized: None,
192    });
193
194    // Equal?
195    let old_raw = old_formula.unwrap_or("");
196    let new_raw = new_formula.unwrap_or("");
197    if old_raw == new_raw {
198        return None;
199    }
200
201    Some(FormulaChange {
202        old: old_text,
203        new: new_text,
204    })
205}
206
207// ---------------------------------------------------------------------------
208// Tests
209// ---------------------------------------------------------------------------
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::model::{CellDuration, CellValue, DateTimeKind};
215    use crate::options::{DateComparePolicy, ValueCompareOptions};
216
217    fn opts() -> ValueCompareOptions {
218        ValueCompareOptions::default()
219    }
220
221    fn iso_only_dt(iso: &str) -> CellDateTime {
222        CellDateTime {
223            serial: 0.0,
224            is_1904: false,
225            kind: DateTimeKind::DateTime,
226            iso: Some(iso.to_string()),
227            has_serial: false,
228        }
229    }
230
231    fn serial_dt(serial: f64, is_1904: bool) -> CellDateTime {
232        CellDateTime {
233            serial,
234            is_1904,
235            kind: DateTimeKind::DateTime,
236            iso: None,
237            has_serial: true,
238        }
239    }
240
241    // D-01: ISO date/time and duration values must not always compare equal ---
242
243    #[test]
244    fn iso_only_datetimes_with_same_iso_are_equal() {
245        let a = iso_only_dt("2024-01-01T00:00:00");
246        let b = iso_only_dt("2024-01-01T00:00:00");
247        assert!(
248            compare_values(&CellValue::DateTime(a), &CellValue::DateTime(b), &opts()).is_none()
249        );
250    }
251
252    #[test]
253    fn iso_only_datetimes_with_different_iso_are_reported_changed() {
254        // The exact pair from the RFC-035 Handoff 05 audit: before the fix,
255        // both normalise to serial 0.0 / is_1904 false / kind DateTime, so
256        // this compared equal no matter how different the two dates are.
257        let a = iso_only_dt("2024-01-01T00:00:00");
258        let b = iso_only_dt("2099-12-31T23:59:59");
259        let r = compare_values(&CellValue::DateTime(a), &CellValue::DateTime(b), &opts()).unwrap();
260        assert_eq!(r.reason, ValueDifferenceKind::DateTimeChanged);
261    }
262
263    #[test]
264    fn iso_only_durations_with_different_iso_are_reported_changed() {
265        // The second pair from the same audit finding.
266        let a = CellValue::Duration(CellDuration {
267            serial: 0.0,
268            iso: Some("PT1H".to_string()),
269        });
270        let b = CellValue::Duration(CellDuration {
271            serial: 0.0,
272            iso: Some("PT99H".to_string()),
273        });
274        let r = compare_values(&a, &b, &opts()).unwrap();
275        assert_eq!(r.reason, ValueDifferenceKind::ContentChanged);
276    }
277
278    #[test]
279    fn iso_only_durations_with_same_iso_are_equal() {
280        let a = CellValue::Duration(CellDuration {
281            serial: 0.0,
282            iso: Some("PT1H30M".to_string()),
283        });
284        let b = CellValue::Duration(CellDuration {
285            serial: 0.0,
286            iso: Some("PT1H30M".to_string()),
287        });
288        assert!(compare_values(&a, &b, &opts()).is_none());
289    }
290
291    #[test]
292    fn mixed_serial_and_iso_datetime_never_silently_equal() {
293        // A genuine serial-based value whose serial happens to be 0.0 (a
294        // legitimate date, 1899-12-30 in the 1900 system) against an
295        // ISO-only value whose serial is *also* 0.0 but as a placeholder.
296        // Before `has_serial`, these were indistinguishable and compared
297        // equal under the old (serial, is_1904, kind) check.
298        let serial_based = serial_dt(0.0, false);
299        let iso_only = iso_only_dt("2024-01-01T00:00:00");
300        let mut o = opts();
301
302        o.date = DateComparePolicy::ExactRepresentation;
303        let r = compare_values(
304            &CellValue::DateTime(serial_based.clone()),
305            &CellValue::DateTime(iso_only.clone()),
306            &o,
307        );
308        assert!(
309            r.is_some(),
310            "mixed representation must not be silently equal"
311        );
312
313        // Must not become equal under the normalisation policy either.
314        o.date = DateComparePolicy::NormalizeEquivalentDateTimes;
315        let r = compare_values(
316            &CellValue::DateTime(serial_based),
317            &CellValue::DateTime(iso_only),
318            &o,
319        );
320        assert!(
321            r.is_some(),
322            "mixed representation must not be silently equal under NormalizeEquivalentDateTimes either"
323        );
324    }
325
326    #[test]
327    fn normalize_equivalent_datetimes_reconciles_1900_and_1904_systems() {
328        // Same real-world date, represented once under the 1900 system and
329        // once under the 1904 system: serials differ by exactly the 1462-day
330        // offset. `ExactRepresentation` must see them as different;
331        // `NormalizeEquivalentDateTimes` must see them as the same instant.
332        let system_1900 = serial_dt(45000.0, false);
333        let system_1904 = serial_dt(45000.0 - 1462.0, true);
334
335        let mut o = opts();
336        o.date = DateComparePolicy::ExactRepresentation;
337        let r = compare_values(
338            &CellValue::DateTime(system_1900.clone()),
339            &CellValue::DateTime(system_1904.clone()),
340            &o,
341        );
342        assert!(
343            r.is_some(),
344            "ExactRepresentation must not conflate the two epochs"
345        );
346
347        o.date = DateComparePolicy::NormalizeEquivalentDateTimes;
348        let r = compare_values(
349            &CellValue::DateTime(system_1900),
350            &CellValue::DateTime(system_1904),
351            &o,
352        );
353        assert!(
354            r.is_none(),
355            "NormalizeEquivalentDateTimes must recognise the same instant across epochs"
356        );
357    }
358
359    #[test]
360    fn equal_texts_produce_no_change() {
361        let r = compare_values(
362            &CellValue::Text("x".into()),
363            &CellValue::Text("x".into()),
364            &opts(),
365        );
366        assert!(r.is_none());
367    }
368
369    #[test]
370    fn different_texts_produce_content_changed() {
371        let r = compare_values(
372            &CellValue::Text("a".into()),
373            &CellValue::Text("b".into()),
374            &opts(),
375        )
376        .unwrap();
377        assert_eq!(r.reason, ValueDifferenceKind::ContentChanged);
378    }
379
380    #[test]
381    fn integer_vs_number_is_type_changed_by_default() {
382        let r = compare_values(&CellValue::Integer(1), &CellValue::Number(1.0), &opts()).unwrap();
383        assert_eq!(r.reason, ValueDifferenceKind::TypeChanged);
384    }
385
386    #[test]
387    fn integer_vs_number_equal_when_math_policy() {
388        let mut o = opts();
389        o.numeric_type = NumericTypePolicy::CompareMathematicalValue;
390        let r = compare_values(&CellValue::Integer(1), &CellValue::Number(1.0), &o);
391        assert!(r.is_none());
392    }
393
394    #[test]
395    fn text_vs_integer_is_type_changed() {
396        let r = compare_values(
397            &CellValue::Text("100".into()),
398            &CellValue::Integer(100),
399            &opts(),
400        )
401        .unwrap();
402        assert_eq!(r.reason, ValueDifferenceKind::TypeChanged);
403    }
404
405    #[test]
406    fn equal_booleans_produce_no_change() {
407        let r = compare_values(&CellValue::Bool(true), &CellValue::Bool(true), &opts());
408        assert!(r.is_none());
409    }
410
411    #[test]
412    fn empty_vs_empty_produces_no_change() {
413        let r = compare_values(&CellValue::Empty, &CellValue::Empty, &opts());
414        assert!(r.is_none());
415    }
416
417    #[test]
418    fn formula_ignore_returns_none() {
419        let r = compare_formulas(Some("=A1"), Some("=B1"), FormulaCompareMode::Ignore);
420        assert!(r.is_none());
421    }
422
423    #[test]
424    fn equal_formulas_return_none() {
425        let r = compare_formulas(Some("=A1+B1"), Some("=A1+B1"), FormulaCompareMode::RawText);
426        assert!(r.is_none());
427    }
428
429    #[test]
430    fn different_formulas_return_change() {
431        let r = compare_formulas(
432            Some("=A1+B1"),
433            Some("=A1+B1+C1"),
434            FormulaCompareMode::RawText,
435        )
436        .unwrap();
437        assert_eq!(r.old.as_ref().unwrap().raw, "=A1+B1");
438        assert_eq!(r.new.as_ref().unwrap().raw, "=A1+B1+C1");
439    }
440
441    #[test]
442    fn formula_added() {
443        let r = compare_formulas(None, Some("=SUM(A1:A10)"), FormulaCompareMode::RawText).unwrap();
444        assert!(r.old.is_none());
445        assert!(r.new.is_some());
446    }
447}