Skip to main content

sheets_diff/core/
diff.rs

1use std::fmt;
2use std::io::{Read, Seek};
3use std::path::Path;
4
5use calamine::{Data, Reader, Xlsx, open_workbook};
6#[cfg(feature = "serde_derive")]
7use serde::{Deserialize, Serialize};
8
9use super::error::{SheetsDiffError, WorkbookSide};
10use super::utils::{cell_pos_to_address, diff_range, filter_same_name_sheets};
11
12// ---------------------------------------------------------------------------
13// Public types
14// ---------------------------------------------------------------------------
15
16/// Whether a cell diff concerns its displayed value or its formula.
17#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
18#[cfg_attr(feature = "serde_derive", derive(Serialize, Deserialize))]
19pub enum CellDiffKind {
20    Value,
21    Formula,
22}
23
24impl fmt::Display for CellDiffKind {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            CellDiffKind::Value => write!(f, "value"),
28            CellDiffKind::Formula => write!(f, "formula"),
29        }
30    }
31}
32
33/// Top-level diff result between two `.xlsx` workbooks.
34#[derive(Clone, Debug)]
35#[cfg_attr(feature = "serde_derive", derive(Serialize, Deserialize))]
36pub struct Diff {
37    pub old_filepath: String,
38    pub new_filepath: String,
39    pub sheet_diff: Vec<SheetDiff>,
40    pub cell_diffs: Vec<SheetCellDiff>,
41}
42
43/// Records a sheet that was added or removed between the two workbooks.
44#[derive(Clone, Debug)]
45#[cfg_attr(feature = "serde_derive", derive(Serialize, Deserialize))]
46pub struct SheetDiff {
47    pub old: Option<String>,
48    pub new: Option<String>,
49}
50
51/// All cell-level diffs for a single worksheet.
52#[derive(Clone, Debug)]
53#[cfg_attr(feature = "serde_derive", derive(Serialize, Deserialize))]
54pub struct SheetCellDiff {
55    pub sheet: String,
56    pub cells: Vec<CellDiff>,
57}
58
59/// A single changed cell.
60#[derive(Clone, Debug)]
61#[cfg_attr(feature = "serde_derive", derive(Serialize, Deserialize))]
62pub struct CellDiff {
63    /// 1-based row index.
64    pub row: usize,
65    /// 1-based column index.
66    pub col: usize,
67    /// Excel A1 address (e.g. `"XFD1048576"`).
68    pub addr: String,
69    pub kind: CellDiffKind,
70    pub old: Option<String>,
71    pub new: Option<String>,
72}
73
74// ---------------------------------------------------------------------------
75// Diff constructors
76// ---------------------------------------------------------------------------
77
78impl Diff {
79    /// Panicking convenience constructor.
80    ///
81    /// Opens both workbooks and computes their diff. Panics with a diagnostic
82    /// message if either workbook cannot be opened or read.
83    ///
84    /// Existing callers of v1.1.4's `Diff::new` can continue to use this
85    /// without any source changes.
86    ///
87    /// For production embedders and GUI applications, prefer [`Diff::try_new`]
88    /// to receive a structured [`SheetsDiffError`] instead of a panic.
89    pub fn new(old_filepath: &str, new_filepath: &str) -> Self {
90        match Self::try_new(old_filepath, new_filepath) {
91            Ok(diff) => diff,
92            Err(err) => panic!("failed to diff workbooks: {err}"),
93        }
94    }
95
96    /// Fallible path-based constructor.
97    ///
98    /// Accepts any value that can be treated as a [`Path`], including `&str`,
99    /// `String`, and `PathBuf`. Returns a structured error for missing,
100    /// corrupt, locked, or non-`.xlsx` inputs without panicking.
101    pub fn try_new(
102        old_filepath: impl AsRef<Path>,
103        new_filepath: impl AsRef<Path>,
104    ) -> Result<Self, SheetsDiffError> {
105        let old_path = old_filepath.as_ref();
106        let new_path = new_filepath.as_ref();
107
108        let mut old_workbook: Xlsx<_> =
109            open_workbook(old_path).map_err(|source| SheetsDiffError::OpenWorkbook {
110                side: WorkbookSide::Old,
111                path: old_path.to_path_buf(),
112                source,
113            })?;
114
115        let mut new_workbook: Xlsx<_> =
116            open_workbook(new_path).map_err(|source| SheetsDiffError::OpenWorkbook {
117                side: WorkbookSide::New,
118                path: new_path.to_path_buf(),
119                source,
120            })?;
121
122        let old_label = old_path.to_string_lossy().into_owned();
123        let new_label = new_path.to_string_lossy().into_owned();
124
125        Self::try_from_workbooks(old_label, new_label, &mut old_workbook, &mut new_workbook)
126    }
127
128    /// Fallible reader-based constructor with explicit display names.
129    ///
130    /// Accepts any `Read + Seek` stream (e.g. [`std::io::Cursor`]). The
131    /// `old_name` / `new_name` strings are stored in the returned
132    /// `Diff.old_filepath` / `Diff.new_filepath` fields and appear in diff
133    /// output — supply meaningful labels (filenames, Git object hashes, etc.).
134    ///
135    /// This constructor lets GUI and VCS tools avoid double I/O and lossy
136    /// path-to-string conversions.
137    pub fn try_from_named_readers<R1, R2>(
138        old_name: impl Into<String>,
139        old_reader: R1,
140        new_name: impl Into<String>,
141        new_reader: R2,
142    ) -> Result<Self, SheetsDiffError>
143    where
144        R1: Read + Seek,
145        R2: Read + Seek,
146    {
147        let mut old_workbook = Xlsx::new(old_reader).map_err(|source| {
148            SheetsDiffError::OpenReader {
149                side: WorkbookSide::Old,
150                source,
151            }
152        })?;
153
154        let mut new_workbook = Xlsx::new(new_reader).map_err(|source| {
155            SheetsDiffError::OpenReader {
156                side: WorkbookSide::New,
157                source,
158            }
159        })?;
160
161        Self::try_from_workbooks(
162            old_name.into(),
163            new_name.into(),
164            &mut old_workbook,
165            &mut new_workbook,
166        )
167    }
168
169    /// Returns a clone of this diff (kept for v1.1.4 source compatibility).
170    pub fn diff(&mut self) -> Diff {
171        self.clone()
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Internal helpers
177// ---------------------------------------------------------------------------
178
179impl Diff {
180    /// Returns an empty `Diff` with the given display labels.
181    fn empty(old_filepath: String, new_filepath: String) -> Self {
182        Diff {
183            old_filepath,
184            new_filepath,
185            sheet_diff: vec![],
186            cell_diffs: vec![],
187        }
188    }
189
190    /// Shared internal engine used by all public constructors.
191    fn try_from_workbooks<R1, R2>(
192        old_label: String,
193        new_label: String,
194        old_workbook: &mut Xlsx<R1>,
195        new_workbook: &mut Xlsx<R2>,
196    ) -> Result<Self, SheetsDiffError>
197    where
198        R1: Read + Seek,
199        R2: Read + Seek,
200    {
201        let mut diff = Self::empty(old_label, new_label);
202        diff.collect_diff_from_workbooks(old_workbook, new_workbook)?;
203        diff.normalize_cell_diffs();
204        Ok(diff)
205    }
206
207    /// Collects all sheet-level and cell-level diffs into `self`.
208    fn collect_diff_from_workbooks<R1, R2>(
209        &mut self,
210        old_workbook: &mut Xlsx<R1>,
211        new_workbook: &mut Xlsx<R2>,
212    ) -> Result<(), SheetsDiffError>
213    where
214        R1: Read + Seek,
215        R2: Read + Seek,
216    {
217        let old_sheets = old_workbook.sheet_names().to_owned();
218        let new_sheets = new_workbook.sheet_names().to_owned();
219
220        self.collect_sheet_diff(&old_sheets, &new_sheets);
221
222        let same_name_sheets = filter_same_name_sheets(&old_sheets, &new_sheets);
223        self.collect_cell_value_diff(old_workbook, new_workbook, &same_name_sheets)?;
224        self.collect_cell_formula_diff(old_workbook, new_workbook, &same_name_sheets)?;
225
226        Ok(())
227    }
228
229    /// Detects sheets that were added or removed.
230    fn collect_sheet_diff(&mut self, old_sheets: &[String], new_sheets: &[String]) {
231        if old_sheets == new_sheets {
232            return;
233        }
234
235        for sheet in old_sheets {
236            if !new_sheets.contains(sheet) {
237                self.sheet_diff.push(SheetDiff {
238                    old: Some(sheet.clone()),
239                    new: None,
240                });
241            }
242        }
243        for sheet in new_sheets {
244            if !old_sheets.contains(sheet) {
245                self.sheet_diff.push(SheetDiff {
246                    old: None,
247                    new: Some(sheet.clone()),
248                });
249            }
250        }
251    }
252
253    /// Collects changed cell values for all shared sheets.
254    fn collect_cell_value_diff<R1, R2>(
255        &mut self,
256        old_workbook: &mut Xlsx<R1>,
257        new_workbook: &mut Xlsx<R2>,
258        same_name_sheets: &[String],
259    ) -> Result<(), SheetsDiffError>
260    where
261        R1: Read + Seek,
262        R2: Read + Seek,
263    {
264        for sheet in same_name_sheets {
265            let old_range =
266                old_workbook
267                    .worksheet_range(sheet)
268                    .map_err(|source| SheetsDiffError::ReadSheetValues {
269                        side: WorkbookSide::Old,
270                        sheet: sheet.clone(),
271                        source,
272                    })?;
273
274            let new_range =
275                new_workbook
276                    .worksheet_range(sheet)
277                    .map_err(|source| SheetsDiffError::ReadSheetValues {
278                        side: WorkbookSide::New,
279                        sheet: sheet.clone(),
280                        source,
281                    })?;
282
283            let mut cell_diffs: Vec<CellDiff> = vec![];
284
285            let (start_row, start_col, end_row, end_col) = diff_range(
286                old_range.start(),
287                new_range.start(),
288                old_range.end(),
289                new_range.end(),
290            );
291
292            for row in start_row..end_row {
293                for col in start_col..end_col {
294                    let old_cell = old_range.get_value((row, col)).unwrap_or(&Data::Empty);
295                    let new_cell = new_range.get_value((row, col)).unwrap_or(&Data::Empty);
296
297                    if old_cell != new_cell {
298                        let row1 = (row + 1) as usize;
299                        let col1 = (col + 1) as usize;
300                        cell_diffs.push(CellDiff {
301                            row: row1,
302                            col: col1,
303                            addr: cell_pos_to_address(row1, col1),
304                            kind: CellDiffKind::Value,
305                            old: if old_cell != &Data::Empty {
306                                Some(old_cell.to_string())
307                            } else {
308                                None
309                            },
310                            new: if new_cell != &Data::Empty {
311                                Some(new_cell.to_string())
312                            } else {
313                                None
314                            },
315                        });
316                    }
317                }
318            }
319
320            if !cell_diffs.is_empty() {
321                self.cell_diffs.push(SheetCellDiff {
322                    sheet: sheet.clone(),
323                    cells: cell_diffs,
324                });
325            }
326        }
327
328        Ok(())
329    }
330
331    /// Collects changed cell formulas for all shared sheets.
332    fn collect_cell_formula_diff<R1, R2>(
333        &mut self,
334        old_workbook: &mut Xlsx<R1>,
335        new_workbook: &mut Xlsx<R2>,
336        same_name_sheets: &[String],
337    ) -> Result<(), SheetsDiffError>
338    where
339        R1: Read + Seek,
340        R2: Read + Seek,
341    {
342        for sheet in same_name_sheets {
343            let old_range = old_workbook
344                .worksheet_formula(sheet)
345                .map_err(|source| SheetsDiffError::ReadSheetFormulas {
346                    side: WorkbookSide::Old,
347                    sheet: sheet.clone(),
348                    source,
349                })?;
350
351            let new_range = new_workbook
352                .worksheet_formula(sheet)
353                .map_err(|source| SheetsDiffError::ReadSheetFormulas {
354                    side: WorkbookSide::New,
355                    sheet: sheet.clone(),
356                    source,
357                })?;
358
359            let mut cell_diffs: Vec<CellDiff> = vec![];
360
361            let (start_row, start_col, end_row, end_col) = diff_range(
362                old_range.start(),
363                new_range.start(),
364                old_range.end(),
365                new_range.end(),
366            );
367
368            for row in start_row..end_row {
369                for col in start_col..end_col {
370                    let empty = String::new();
371                    let old_cell = old_range.get_value((row, col)).unwrap_or(&empty);
372                    let new_cell = new_range.get_value((row, col)).unwrap_or(&empty);
373
374                    if old_cell != new_cell {
375                        let row1 = (row + 1) as usize;
376                        let col1 = (col + 1) as usize;
377                        cell_diffs.push(CellDiff {
378                            row: row1,
379                            col: col1,
380                            addr: cell_pos_to_address(row1, col1),
381                            kind: CellDiffKind::Formula,
382                            old: if old_cell.is_empty() {
383                                None
384                            } else {
385                                Some(old_cell.to_string())
386                            },
387                            new: if new_cell.is_empty() {
388                                None
389                            } else {
390                                Some(new_cell.to_string())
391                            },
392                        });
393                    }
394                }
395            }
396
397            if !cell_diffs.is_empty() {
398                self.cell_diffs.push(SheetCellDiff {
399                    sheet: sheet.clone(),
400                    cells: cell_diffs,
401                });
402            }
403        }
404
405        Ok(())
406    }
407
408    /// Merges per-sheet cell diff batches and sorts by `(sheet, row, col, kind)`.
409    ///
410    /// Sorting by numeric `(row, col)` avoids lexical ordering bugs such as
411    /// `A10` appearing before `A2`.
412    fn normalize_cell_diffs(&mut self) {
413        self.cell_diffs.sort_by(|a, b| a.sheet.cmp(&b.sheet));
414
415        let mut merged: Vec<SheetCellDiff> = vec![];
416        for entry in self.cell_diffs.drain(..) {
417            match merged.iter_mut().find(|m| m.sheet == entry.sheet) {
418                Some(existing) => existing.cells.extend(entry.cells),
419                None => merged.push(entry),
420            }
421        }
422
423        for sheet_diff in &mut merged {
424            sheet_diff.cells.sort_by(|a, b| {
425                a.row
426                    .cmp(&b.row)
427                    .then_with(|| a.col.cmp(&b.col))
428                    .then_with(|| a.kind.cmp(&b.kind))
429            });
430        }
431
432        self.cell_diffs = merged;
433    }
434}