use std::collections::BTreeMap;
use std::io::{Read, Seek};
use calamine::Reader;
use crate::address::{CellAddress, ComparedRange};
use crate::compare::{compare_formulas, compare_values};
use crate::error::{LimitKind, SheetsDiffError};
use crate::matcher::{MatchedPair, match_sheets};
use crate::model::{
CellDiff, Diagnostic, DiagnosticKind, DiagnosticLocation, DiffStage, Severity,
SheetChange, SheetDiff, SheetRef, SheetSummary, Side,
WorkbookDiff, WorkbookSideInfo,
};
use crate::normalize::normalize_cell_value;
use crate::open::{OpenedWorkbook, open_bytes, open_path, open_reader};
use crate::options::{DiffEvent, DiffOptions};
struct NormalizedCell {
value: crate::model::CellValue,
formula: Option<String>,
}
type CellMap = BTreeMap<(u32, u32), NormalizedCell>;
pub fn run_compare_paths(
old: impl AsRef<std::path::Path>,
new: impl AsRef<std::path::Path>,
opts: DiffOptions,
) -> Result<WorkbookDiff, SheetsDiffError> {
opts.validate()?;
let old_wb = open_path(old, Side::Old)?;
let new_wb = open_path(new, Side::New)?;
run_pipeline(old_wb, new_wb, opts)
}
pub fn run_compare_bytes(
old: impl AsRef<[u8]>,
new: impl AsRef<[u8]>,
opts: DiffOptions,
) -> Result<WorkbookDiff, SheetsDiffError> {
opts.validate()?;
let old_wb = open_bytes(old, Side::Old, None)?;
let new_wb = open_bytes(new, Side::New, None)?;
run_pipeline(old_wb, new_wb, opts)
}
pub fn run_compare_readers<R1, R2>(
old: R1,
new: R2,
opts: DiffOptions,
) -> Result<WorkbookDiff, SheetsDiffError>
where
R1: Read + Seek,
R2: Read + Seek,
{
opts.validate()?;
let old_wb = open_reader(old, Side::Old, None)?;
let new_wb = open_reader(new, Side::New, None)?;
run_pipeline(old_wb, new_wb, opts)
}
fn run_pipeline(
mut old_wb: OpenedWorkbook,
mut new_wb: OpenedWorkbook,
mut opts: DiffOptions,
) -> Result<WorkbookDiff, SheetsDiffError> {
let mut workbook_diagnostics: Vec<Diagnostic> = Vec::new();
emit(&mut opts, DiffEvent::Started);
emit(&mut opts, DiffEvent::OpeningWorkbook { side: Side::Old });
emit(&mut opts, DiffEvent::WorkbookOpened {
side: Side::Old,
sheet_count: old_wb.sheets.len(),
});
emit(&mut opts, DiffEvent::OpeningWorkbook { side: Side::New });
emit(&mut opts, DiffEvent::WorkbookOpened {
side: Side::New,
sheet_count: new_wb.sheets.len(),
});
if let Some(max) = opts.limits.max_sheets {
let old_count = old_wb.sheets.len() as u64;
let new_count = new_wb.sheets.len() as u64;
let observed = old_count.max(new_count);
if observed > max as u64 {
return Err(SheetsDiffError::LimitExceeded {
limit: LimitKind::Sheets,
observed,
});
}
}
let old_info = WorkbookSideInfo {
source: old_wb.source.clone(),
workbook_name: None,
sheet_count: old_wb.sheets.len(),
};
let new_info = WorkbookSideInfo {
source: new_wb.source.clone(),
workbook_name: None,
sheet_count: new_wb.sheets.len(),
};
emit(&mut opts, DiffEvent::MatchingSheets);
let matched = match_sheets(
&old_wb.sheets.clone(),
&new_wb.sheets.clone(),
opts.matching.sheet_matching,
&mut workbook_diagnostics,
);
let total_sheets = matched.len();
let mut sheet_diffs: Vec<SheetDiff> = Vec::with_capacity(total_sheets);
let mut total_diffs: u64 = 0;
let mut total_cells_read: u64 = 0;
for (idx, pair) in matched.into_iter().enumerate() {
check_cancel(&opts)?;
let sheet_name = pair
.new_sheet
.as_ref()
.or(pair.old_sheet.as_ref())
.map(|s| s.name.clone())
.unwrap_or_default();
emit(&mut opts, DiffEvent::SheetStarted {
index: idx,
total: total_sheets,
name: sheet_name,
});
let sheet_diff = process_sheet_pair(
&pair,
&mut old_wb,
&mut new_wb,
&opts,
&mut total_diffs,
&mut total_cells_read,
)?;
let changed = sheet_diff.cell_diffs.len();
emit(&mut opts, DiffEvent::SheetFinished { index: idx, changed_cells: changed });
sheet_diffs.push(sheet_diff);
}
sheet_diffs.sort_by_key(|sd| {
sd.old_sheet
.as_ref()
.map(|s| (0usize, s.index))
.unwrap_or_else(|| (1, sd.new_sheet.as_ref().map(|s| s.index).unwrap_or(0)))
});
let summary = WorkbookDiff::derive_summary(&sheet_diffs, &workbook_diagnostics);
emit(&mut opts, DiffEvent::Finished);
Ok(WorkbookDiff {
old: old_info,
new: new_info,
sheets: sheet_diffs,
workbook_changes: Vec::new(),
object_changes: Vec::new(),
diagnostics: workbook_diagnostics,
summary,
})
}
fn process_sheet_pair(
pair: &MatchedPair,
old_wb: &mut OpenedWorkbook,
new_wb: &mut OpenedWorkbook,
opts: &DiffOptions,
total_diffs: &mut u64,
total_cells_read: &mut u64,
) -> Result<SheetDiff, SheetsDiffError> {
let mut sheet_diag: Vec<Diagnostic> = Vec::new();
let old_cells = match &pair.old_sheet {
Some(s) => read_sheet_cells(
old_wb, s, Side::Old, opts, total_cells_read, &mut sheet_diag,
)?,
None => (CellMap::new(), None, None),
};
let new_cells = match &pair.new_sheet {
Some(s) => read_sheet_cells(
new_wb, s, Side::New, opts, total_cells_read, &mut sheet_diag,
)?,
None => (CellMap::new(), None, None),
};
let (old_map, old_start, old_end) = old_cells;
let (new_map, new_start, new_end) = new_cells;
let compared_range = ComparedRange::union(old_start, old_end, new_start, new_end);
let mut coords: std::collections::BTreeSet<(u32, u32)> = std::collections::BTreeSet::new();
coords.extend(old_map.keys().copied());
coords.extend(new_map.keys().copied());
let mut cell_diffs: Vec<CellDiff> = Vec::new();
let mut summary = SheetSummary::default();
let empty_cell = NormalizedCell {
value: crate::model::CellValue::Empty,
formula: None,
};
for (row, col) in &coords {
let (row, col) = (*row, *col);
if let Some(max) = opts.limits.max_cells_compared {
let compared_so_far = cell_diffs.len() as u64 + 1;
if compared_so_far > max {
return Err(SheetsDiffError::LimitExceeded {
limit: LimitKind::CellsCompared,
observed: compared_so_far,
});
}
}
let old_cell = old_map.get(&(row, col)).unwrap_or(&empty_cell);
let new_cell = new_map.get(&(row, col)).unwrap_or(&empty_cell);
let value_change = compare_values(
&old_cell.value,
&new_cell.value,
&opts.comparison.value,
);
let formula_change =
compare_formulas(
old_cell.formula.as_deref(),
new_cell.formula.as_deref(),
opts.comparison.formula,
);
if value_change.is_none() && formula_change.is_none() {
continue;
}
if let Some(max) = opts.limits.max_diffs_returned {
if *total_diffs >= max {
return Err(SheetsDiffError::LimitExceeded {
limit: LimitKind::DiffsReturned,
observed: *total_diffs + 1,
});
}
}
if value_change.is_some() {
summary.values_changed += 1;
}
if formula_change.is_some() {
summary.formulas_changed += 1;
}
summary.cells_changed += 1;
*total_diffs += 1;
let address = CellAddress::new_unchecked(row, col);
cell_diffs.push(CellDiff {
address,
value: value_change,
formula: formula_change,
format: None,
diagnostics: Vec::new(),
});
}
let change = match &pair.change {
SheetChange::Unchanged if !cell_diffs.is_empty() => SheetChange::Modified,
other => other.clone(),
};
Ok(SheetDiff {
old_sheet: pair.old_sheet.clone(),
new_sheet: pair.new_sheet.clone(),
change,
cell_diffs,
compared_range,
alignment_summary: None,
diagnostics: sheet_diag,
summary,
})
}
fn read_sheet_cells(
wb: &mut OpenedWorkbook,
sheet: &SheetRef,
side: Side,
opts: &DiffOptions,
total_cells_read: &mut u64,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<(CellMap, Option<(u32, u32)>, Option<(u32, u32)>), SheetsDiffError> {
let range = wb.reader.worksheet_range(&sheet.name).map_err(|e| {
SheetsDiffError::read_sheet(side, sheet.clone(), e)
})?;
let formula_range = wb.reader.worksheet_formula(&sheet.name).ok();
let has_formulas = formula_range.is_some();
let mut cells: CellMap = BTreeMap::new();
let mut range_start: Option<(u32, u32)> = None;
let mut range_end: Option<(u32, u32)> = None;
let origin = range.start().unwrap_or((0, 0));
for (row_idx, row) in range.rows().enumerate() {
for (col_idx, cell) in row.iter().enumerate() {
*total_cells_read += 1;
if let Some(max) = opts.limits.max_cells_read {
if *total_cells_read > max {
return Err(SheetsDiffError::LimitExceeded {
limit: LimitKind::CellsRead,
observed: *total_cells_read,
});
}
}
let value = normalize_cell_value(cell);
if matches!(value, crate::model::CellValue::Empty) {
continue;
}
let row1 = origin.0 + row_idx as u32 + 1;
let col1 = origin.1 + col_idx as u32 + 1;
let formula = formula_range
.as_ref()
.and_then(|fr| fr.get((row_idx, col_idx)))
.filter(|s| !s.is_empty())
.cloned();
if has_formulas
&& formula.is_none()
&& opts.comparison.include_formula_cached_values
&& matches!(
value,
crate::model::CellValue::Integer(_) | crate::model::CellValue::Number(_)
)
{
diagnostics.push(Diagnostic {
severity: Severity::Info,
kind: DiagnosticKind::FormulaUnavailable,
location: DiagnosticLocation {
stage: DiffStage::Read,
sheet_order: Some(sheet.index),
sheet_name: Some(sheet.name.clone()),
address: Some(CellAddress::new_unchecked(row1, col1)),
},
message: format!(
"formula text unavailable for numeric cell at {}{}",
crate::address::col_to_label(col1),
row1
),
});
}
update_bounds(&mut range_start, &mut range_end, row1, col1);
cells.insert((row1, col1), NormalizedCell { value, formula });
}
}
Ok((cells, range_start, range_end))
}
fn update_bounds(
start: &mut Option<(u32, u32)>,
end: &mut Option<(u32, u32)>,
row: u32,
col: u32,
) {
*start = Some(match *start {
None => (row, col),
Some((r, c)) => (r.min(row), c.min(col)),
});
*end = Some(match *end {
None => (row, col),
Some((r, c)) => (r.max(row), c.max(col)),
});
}
fn emit(opts: &mut DiffOptions, event: DiffEvent) {
if let Some(sink) = opts.execution.progress.as_mut() {
sink.on_event(event);
}
}
fn check_cancel(opts: &DiffOptions) -> Result<(), SheetsDiffError> {
if let Some(ref cancel) = opts.execution.cancellation {
if cancel.is_cancelled() {
return Err(SheetsDiffError::Cancelled);
}
}
Ok(())
}