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    CellValue, FormulaChange, FormulaText, ValueChange, ValueDifferenceKind,
5};
6use crate::options::{
7    DateComparePolicy, FormulaCompareMode, NumberComparePolicy, NumericTypePolicy,
8    TypeMismatchPolicy, ValueCompareOptions,
9};
10
11/// Public re-export for testing; not part of the stable API surface.
12#[doc(hidden)]
13pub use self::compare_values as compare_values_pub;
14
15// ---------------------------------------------------------------------------
16// Value comparison
17// ---------------------------------------------------------------------------
18
19/// Compare two `CellValue`s under the supplied options.
20///
21/// Returns `Some(ValueChange)` when the values differ; `None` when equal.
22pub fn compare_values(
23    old: &CellValue,
24    new: &CellValue,
25    opts: &ValueCompareOptions,
26) -> Option<ValueChange> {
27    use CellValue::*;
28
29    let reason = match (old, new) {
30        // Same variant comparisons
31        (Empty, Empty) => return None,
32        (Text(a), Text(b)) if a == b => return None,
33        (Text(a), Text(b)) => {
34            debug_assert!(a != b);
35            ValueDifferenceKind::ContentChanged
36        }
37        (Bool(a), Bool(b)) if a == b => return None,
38        (Bool(_), Bool(_)) => ValueDifferenceKind::ContentChanged,
39        (Integer(a), Integer(b)) if a == b => return None,
40        (Integer(_), Integer(_)) => ValueDifferenceKind::ContentChanged,
41        (Number(a), Number(b)) => match compare_floats(*a, *b, &opts.number) {
42            Some(r) => r,
43            None => return None,
44        },
45        (Error(a), Error(b)) if a == b => return None,
46        (Error(_), Error(_)) => ValueDifferenceKind::ErrorKindChanged,
47        (DateTime(a), DateTime(b)) => {
48            if a.serial == b.serial && a.is_1904 == b.is_1904 && a.kind == b.kind {
49                return None;
50            }
51            match opts.date {
52                DateComparePolicy::ExactRepresentation => ValueDifferenceKind::DateTimeChanged,
53                DateComparePolicy::NormalizeEquivalentDateTimes => {
54                    // Attempt serial normalization across 1900/1904 systems.
55                    if normalized_serial_eq(a.serial, a.is_1904, b.serial, b.is_1904) {
56                        return None;
57                    }
58                    ValueDifferenceKind::DateTimeChanged
59                }
60            }
61        }
62        (Duration(a), Duration(b)) => {
63            if a.serial == b.serial {
64                return None;
65            }
66            ValueDifferenceKind::ContentChanged
67        }
68        (Unsupported { display: a, .. }, Unsupported { display: b, .. }) if a == b => {
69            return None
70        }
71        (Unsupported { .. }, Unsupported { .. }) => ValueDifferenceKind::ContentChanged,
72
73        // Cross-type: Integer vs Number
74        (Integer(i), Number(f)) | (Number(f), Integer(i)) => {
75            match opts.numeric_type {
76                NumericTypePolicy::PreserveType => ValueDifferenceKind::TypeChanged,
77                NumericTypePolicy::CompareMathematicalValue => {
78                    if *i as f64 == *f {
79                        return None;
80                    }
81                    ValueDifferenceKind::ContentChanged
82                }
83            }
84        }
85
86        // Cross-type: everything else
87        _ => match opts.type_mismatch {
88            TypeMismatchPolicy::Different => ValueDifferenceKind::TypeChanged,
89            TypeMismatchPolicy::CompareDisplayString => {
90                let a_str = old.display_string();
91                let b_str = new.display_string();
92                if a_str == b_str {
93                    return None;
94                }
95                ValueDifferenceKind::DisplayStringChanged
96            }
97        },
98    };
99
100    Some(ValueChange { old: old.clone(), new: new.clone(), reason })
101}
102
103fn compare_floats(
104    a: f64,
105    b: f64,
106    policy: &NumberComparePolicy,
107) -> Option<ValueDifferenceKind> {
108    let equal = match policy {
109        NumberComparePolicy::Exact => a == b || (a.is_nan() && b.is_nan()),
110        NumberComparePolicy::AbsoluteTolerance(tol) => (a - b).abs() <= *tol,
111        NumberComparePolicy::RelativeTolerance(tol) => {
112            let denom = a.abs().max(b.abs());
113            denom == 0.0 || (a - b).abs() / denom <= *tol
114        }
115        NumberComparePolicy::AbsoluteOrRelative { abs, rel } => {
116            let abs_ok = (a - b).abs() <= *abs;
117            let denom = a.abs().max(b.abs());
118            let rel_ok = denom == 0.0 || (a - b).abs() / denom <= *rel;
119            abs_ok || rel_ok
120        }
121    };
122    if equal {
123        None
124    } else {
125        Some(ValueDifferenceKind::NumericOutsideTolerance)
126    }
127}
128
129/// Normalise serials across 1900 / 1904 date systems.
130/// The offset between the two systems is 1462 days.
131fn normalized_serial_eq(a_serial: f64, a_1904: bool, b_serial: f64, b_1904: bool) -> bool {
132    const OFFSET: f64 = 1462.0;
133    let a_norm = if a_1904 { a_serial + OFFSET } else { a_serial };
134    let b_norm = if b_1904 { b_serial + OFFSET } else { b_serial };
135    a_norm == b_norm
136}
137
138// ---------------------------------------------------------------------------
139// Formula comparison (RFC-018)
140// ---------------------------------------------------------------------------
141
142/// Compare formula strings under the configured mode.
143///
144/// Returns `Some(FormulaChange)` when the formulas differ; `None` when equal or
145/// when the mode is `Ignore`.
146pub fn compare_formulas(
147    old_formula: Option<&str>,
148    new_formula: Option<&str>,
149    mode: FormulaCompareMode,
150) -> Option<FormulaChange> {
151    if mode == FormulaCompareMode::Ignore {
152        return None;
153    }
154
155    let old_text = old_formula.map(|r| FormulaText {
156        raw: r.to_owned(),
157        normalized: None, // NormalizedText mode guard is in options validation
158    });
159    let new_text = new_formula.map(|r| FormulaText {
160        raw: r.to_owned(),
161        normalized: None,
162    });
163
164    // Equal?
165    let old_raw = old_formula.unwrap_or("");
166    let new_raw = new_formula.unwrap_or("");
167    if old_raw == new_raw {
168        return None;
169    }
170
171    Some(FormulaChange { old: old_text, new: new_text })
172}
173
174// ---------------------------------------------------------------------------
175// Tests
176// ---------------------------------------------------------------------------
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::model::CellValue;
182    use crate::options::ValueCompareOptions;
183
184    fn opts() -> ValueCompareOptions {
185        ValueCompareOptions::default()
186    }
187
188    #[test]
189    fn equal_texts_produce_no_change() {
190        let r = compare_values(&CellValue::Text("x".into()), &CellValue::Text("x".into()), &opts());
191        assert!(r.is_none());
192    }
193
194    #[test]
195    fn different_texts_produce_content_changed() {
196        let r = compare_values(
197            &CellValue::Text("a".into()),
198            &CellValue::Text("b".into()),
199            &opts(),
200        )
201        .unwrap();
202        assert_eq!(r.reason, ValueDifferenceKind::ContentChanged);
203    }
204
205    #[test]
206    fn integer_vs_number_is_type_changed_by_default() {
207        let r = compare_values(&CellValue::Integer(1), &CellValue::Number(1.0), &opts()).unwrap();
208        assert_eq!(r.reason, ValueDifferenceKind::TypeChanged);
209    }
210
211    #[test]
212    fn integer_vs_number_equal_when_math_policy() {
213        let mut o = opts();
214        o.numeric_type = NumericTypePolicy::CompareMathematicalValue;
215        let r = compare_values(&CellValue::Integer(1), &CellValue::Number(1.0), &o);
216        assert!(r.is_none());
217    }
218
219    #[test]
220    fn text_vs_integer_is_type_changed() {
221        let r = compare_values(
222            &CellValue::Text("100".into()),
223            &CellValue::Integer(100),
224            &opts(),
225        )
226        .unwrap();
227        assert_eq!(r.reason, ValueDifferenceKind::TypeChanged);
228    }
229
230    #[test]
231    fn equal_booleans_produce_no_change() {
232        let r = compare_values(&CellValue::Bool(true), &CellValue::Bool(true), &opts());
233        assert!(r.is_none());
234    }
235
236    #[test]
237    fn empty_vs_empty_produces_no_change() {
238        let r = compare_values(&CellValue::Empty, &CellValue::Empty, &opts());
239        assert!(r.is_none());
240    }
241
242    #[test]
243    fn formula_ignore_returns_none() {
244        let r = compare_formulas(Some("=A1"), Some("=B1"), FormulaCompareMode::Ignore);
245        assert!(r.is_none());
246    }
247
248    #[test]
249    fn equal_formulas_return_none() {
250        let r = compare_formulas(Some("=A1+B1"), Some("=A1+B1"), FormulaCompareMode::RawText);
251        assert!(r.is_none());
252    }
253
254    #[test]
255    fn different_formulas_return_change() {
256        let r = compare_formulas(Some("=A1+B1"), Some("=A1+B1+C1"), FormulaCompareMode::RawText)
257            .unwrap();
258        assert_eq!(r.old.as_ref().unwrap().raw, "=A1+B1");
259        assert_eq!(r.new.as_ref().unwrap().raw, "=A1+B1+C1");
260    }
261
262    #[test]
263    fn formula_added() {
264        let r = compare_formulas(None, Some("=SUM(A1:A10)"), FormulaCompareMode::RawText).unwrap();
265        assert!(r.old.is_none());
266        assert!(r.new.is_some());
267    }
268}