Skip to main content

spreadsheet_kit/
verification.rs

1use crate::model::{CellValue, NamedItemKind, NamedRangeDescriptor, NamedRangeScope};
2use crate::workbook::{WorkbookContext, cell_to_value};
3use anyhow::{Result, anyhow, bail};
4use schemars::JsonSchema;
5use serde::Serialize;
6use std::collections::{BTreeMap, BTreeSet};
7
8#[derive(Debug, Clone, Serialize, JsonSchema, Default)]
9pub struct TargetClassificationCounts {
10    pub unchanged: u32,
11    pub direct_edit: u32,
12    pub recalc_result: u32,
13    pub formula_shift: u32,
14    pub new_error: u32,
15}
16
17#[derive(Debug, Clone, Serialize, JsonSchema)]
18pub struct VerifySummary {
19    pub target_count: u32,
20    pub changed_targets: u32,
21    pub new_error_count: u32,
22    pub resolved_error_count: u32,
23    pub preexisting_error_count: u32,
24    pub named_range_delta_count: u32,
25    pub target_classification_counts: TargetClassificationCounts,
26}
27
28#[derive(Debug, Clone, Serialize, JsonSchema)]
29pub struct TargetDelta {
30    pub address: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub before: Option<CellValue>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub after: Option<CellValue>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub before_formula: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub after_formula: Option<String>,
39    pub classification: String,
40    pub changed: bool,
41}
42
43#[derive(Debug, Clone, Serialize, JsonSchema)]
44pub struct ErrorDelta {
45    pub address: String,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub before_error: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub after_error: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub before_formula: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub after_formula: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, JsonSchema)]
57pub struct NamedRangeDelta {
58    pub name: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub scope_kind: Option<NamedRangeScope>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub scope_sheet_name: Option<String>,
63    pub change: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub before_refers_to: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub after_refers_to: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub before_kind: Option<NamedItemKind>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub after_kind: Option<NamedItemKind>,
72}
73
74#[derive(Debug, Clone, Serialize, JsonSchema)]
75pub struct VerifyResponse {
76    pub baseline: String,
77    pub current: String,
78    pub target_deltas: Vec<TargetDelta>,
79    pub new_errors: Vec<ErrorDelta>,
80    pub resolved_errors: Vec<ErrorDelta>,
81    pub preexisting_errors: Vec<ErrorDelta>,
82    pub named_range_deltas: Vec<NamedRangeDelta>,
83    pub summary: VerifySummary,
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct VerifyOptions {
88    pub targets: Vec<String>,
89    pub sheet_filter: Option<String>,
90    pub include_named_range_deltas: bool,
91    pub errors_only: bool,
92    pub targets_only: bool,
93}
94
95#[derive(Debug, Clone)]
96struct TargetCellSnapshot {
97    value: Option<CellValue>,
98    formula: Option<String>,
99}
100
101#[derive(Debug, Clone)]
102struct ErrorCellSnapshot {
103    error: String,
104    formula: Option<String>,
105}
106
107impl VerifyOptions {
108    pub fn validate(&self) -> Result<()> {
109        if self.errors_only && self.targets_only {
110            bail!("invalid argument: --errors-only and --targets-only cannot be combined");
111        }
112        if self.errors_only && !self.targets.is_empty() {
113            bail!(
114                "invalid argument: --errors-only cannot be combined with explicit --targets; drop --targets or use default verify mode"
115            );
116        }
117        if self.errors_only && self.include_named_range_deltas {
118            bail!(
119                "invalid argument: --errors-only cannot be combined with --named-ranges; use default verify mode to include named-range deltas"
120            );
121        }
122        if self.targets_only && self.targets.is_empty() {
123            bail!(
124                "invalid argument: --targets-only requires --targets Sheet!A1,... to define the proof scope"
125            );
126        }
127        if self.targets_only && self.include_named_range_deltas {
128            bail!(
129                "invalid argument: --targets-only cannot be combined with --named-ranges; use default verify mode to include named-range deltas"
130            );
131        }
132        Ok(())
133    }
134}
135
136pub fn compare_workbooks(
137    baseline_label: impl Into<String>,
138    current_label: impl Into<String>,
139    baseline: &WorkbookContext,
140    current: &WorkbookContext,
141    options: &VerifyOptions,
142    baseline_named_ranges: Option<&[NamedRangeDescriptor]>,
143    current_named_ranges: Option<&[NamedRangeDescriptor]>,
144) -> Result<VerifyResponse> {
145    options.validate()?;
146
147    let target_deltas = if options.errors_only {
148        Vec::new()
149    } else {
150        collect_target_deltas(baseline, current, options.targets.clone())?
151    };
152
153    let (new_errors, resolved_errors, preexisting_errors) = if options.targets_only {
154        (Vec::new(), Vec::new(), Vec::new())
155    } else {
156        let baseline_errors = collect_error_cells(baseline, options.sheet_filter.as_deref())?;
157        let current_errors = collect_error_cells(current, options.sheet_filter.as_deref())?;
158        compare_error_maps(&baseline_errors, &current_errors)
159    };
160
161    let named_range_deltas = if options.include_named_range_deltas {
162        let baseline_named_ranges = baseline_named_ranges.ok_or_else(|| {
163            anyhow!("internal error: baseline named ranges were not loaded for verification")
164        })?;
165        let current_named_ranges = current_named_ranges.ok_or_else(|| {
166            anyhow!("internal error: current named ranges were not loaded for verification")
167        })?;
168        compare_named_ranges(baseline_named_ranges, current_named_ranges)
169    } else {
170        Vec::new()
171    };
172
173    let target_classification_counts = count_target_classifications(&target_deltas);
174    Ok(VerifyResponse {
175        baseline: baseline_label.into(),
176        current: current_label.into(),
177        summary: VerifySummary {
178            target_count: target_deltas.len() as u32,
179            changed_targets: target_deltas.iter().filter(|d| d.changed).count() as u32,
180            new_error_count: new_errors.len() as u32,
181            resolved_error_count: resolved_errors.len() as u32,
182            preexisting_error_count: preexisting_errors.len() as u32,
183            named_range_delta_count: named_range_deltas.len() as u32,
184            target_classification_counts,
185        },
186        target_deltas,
187        new_errors,
188        resolved_errors,
189        preexisting_errors,
190        named_range_deltas,
191    })
192}
193
194fn count_target_classifications(target_deltas: &[TargetDelta]) -> TargetClassificationCounts {
195    let mut counts = TargetClassificationCounts::default();
196    for delta in target_deltas {
197        match delta.classification.as_str() {
198            "unchanged" => counts.unchanged += 1,
199            "direct_edit" => counts.direct_edit += 1,
200            "recalc_result" => counts.recalc_result += 1,
201            "formula_shift" => counts.formula_shift += 1,
202            "new_error" => counts.new_error += 1,
203            _ => {}
204        }
205    }
206    counts
207}
208
209fn collect_target_deltas(
210    baseline: &WorkbookContext,
211    current: &WorkbookContext,
212    targets: Vec<String>,
213) -> Result<Vec<TargetDelta>> {
214    let mut deltas = Vec::new();
215    for target in targets {
216        let (sheet_name, cell_ref) = parse_sheet_cell_ref(&target)?;
217        let before = read_target_cell(baseline, &sheet_name, &cell_ref)?;
218        let after = read_target_cell(current, &sheet_name, &cell_ref)?;
219        let changed = !cell_values_equal(before.value.as_ref(), after.value.as_ref())
220            || before.formula != after.formula;
221        let classification = classify_target_delta(&before, &after, changed).to_string();
222        deltas.push(TargetDelta {
223            address: target,
224            before: before.value,
225            after: after.value,
226            before_formula: before.formula,
227            after_formula: after.formula,
228            classification,
229            changed,
230        });
231    }
232    Ok(deltas)
233}
234
235fn parse_sheet_cell_ref(raw: &str) -> Result<(String, String)> {
236    let (sheet_name, cell_ref) = raw.rsplit_once('!').ok_or_else(|| {
237        anyhow!(
238            "invalid argument: target '{}' must use Sheet!A1 notation",
239            raw
240        )
241    })?;
242    if sheet_name.trim().is_empty() || cell_ref.trim().is_empty() {
243        bail!(
244            "invalid argument: target '{}' must use Sheet!A1 notation",
245            raw
246        );
247    }
248
249    let sheet_name = extract_sheet_name(sheet_name);
250    let cell_ref = parse_target_cell_ref(raw, cell_ref)?;
251    Ok((sheet_name, cell_ref))
252}
253
254fn extract_sheet_name(raw: &str) -> String {
255    let trimmed = raw.trim();
256    if let Some(stripped) = trimmed.strip_prefix('\'')
257        && let Some(inner) = stripped.strip_suffix('\'')
258    {
259        return inner.replace("''", "'");
260    }
261    trimmed.to_string()
262}
263
264fn parse_target_cell_ref(target: &str, raw_cell_ref: &str) -> Result<String> {
265    let cell_ref = raw_cell_ref.trim();
266    let (col, row, _, _) = umya_spreadsheet::helper::coordinate::index_from_coordinate(cell_ref);
267    match (col, row) {
268        (Some(c), Some(r)) if c > 0 && r > 0 => Ok(cell_ref.to_string()),
269        _ => bail!(
270            "invalid argument: target '{}' must use Sheet!A1 notation with a single A1 cell reference",
271            target
272        ),
273    }
274}
275
276fn read_target_cell(
277    workbook: &WorkbookContext,
278    sheet_name: &str,
279    cell_ref: &str,
280) -> Result<TargetCellSnapshot> {
281    workbook.with_sheet(sheet_name, |sheet| {
282        if let Some(cell) = sheet.get_cell(cell_ref) {
283            let formula = non_empty_formula(cell.get_formula());
284            TargetCellSnapshot {
285                value: cell_to_value(cell),
286                formula,
287            }
288        } else {
289            TargetCellSnapshot {
290                value: None,
291                formula: None,
292            }
293        }
294    })
295}
296
297fn collect_error_cells(
298    workbook: &WorkbookContext,
299    sheet_filter: Option<&str>,
300) -> Result<BTreeMap<String, ErrorCellSnapshot>> {
301    let mut out = BTreeMap::new();
302    let sheet_names = if let Some(sheet_name) = sheet_filter {
303        vec![sheet_name.to_string()]
304    } else {
305        workbook.sheet_names()
306    };
307
308    for sheet_name in sheet_names {
309        let sheet_errors = workbook.with_sheet(&sheet_name, |sheet| {
310            let mut items = Vec::new();
311            for cell in sheet.get_cell_collection() {
312                let raw = cell.get_value();
313                if !is_error_text(&raw) {
314                    continue;
315                }
316                let address = format!("{}!{}", sheet_name, cell.get_coordinate().get_coordinate());
317                items.push((
318                    address,
319                    ErrorCellSnapshot {
320                        error: raw.to_string(),
321                        formula: non_empty_formula(cell.get_formula()),
322                    },
323                ));
324            }
325            items
326        })?;
327        for (address, snapshot) in sheet_errors {
328            out.insert(address, snapshot);
329        }
330    }
331    Ok(out)
332}
333
334fn compare_error_maps(
335    baseline: &BTreeMap<String, ErrorCellSnapshot>,
336    current: &BTreeMap<String, ErrorCellSnapshot>,
337) -> (Vec<ErrorDelta>, Vec<ErrorDelta>, Vec<ErrorDelta>) {
338    let mut new_errors = Vec::new();
339    let mut resolved_errors = Vec::new();
340    let mut preexisting_errors = Vec::new();
341
342    for (address, after) in current {
343        if let Some(before) = baseline.get(address) {
344            preexisting_errors.push(ErrorDelta {
345                address: address.clone(),
346                before_error: Some(before.error.clone()),
347                after_error: Some(after.error.clone()),
348                before_formula: before.formula.clone(),
349                after_formula: after.formula.clone(),
350            });
351        } else {
352            new_errors.push(ErrorDelta {
353                address: address.clone(),
354                before_error: None,
355                after_error: Some(after.error.clone()),
356                before_formula: None,
357                after_formula: after.formula.clone(),
358            });
359        }
360    }
361
362    for (address, before) in baseline {
363        if !current.contains_key(address) {
364            resolved_errors.push(ErrorDelta {
365                address: address.clone(),
366                before_error: Some(before.error.clone()),
367                after_error: None,
368                before_formula: before.formula.clone(),
369                after_formula: None,
370            });
371        }
372    }
373
374    (new_errors, resolved_errors, preexisting_errors)
375}
376
377fn compare_named_ranges(
378    baseline: &[NamedRangeDescriptor],
379    current: &[NamedRangeDescriptor],
380) -> Vec<NamedRangeDelta> {
381    let base_map: BTreeMap<String, &NamedRangeDescriptor> = baseline
382        .iter()
383        .map(|item| (named_range_key(item), item))
384        .collect();
385    let current_map: BTreeMap<String, &NamedRangeDescriptor> = current
386        .iter()
387        .map(|item| (named_range_key(item), item))
388        .collect();
389
390    let keys: BTreeSet<String> = base_map
391        .keys()
392        .cloned()
393        .chain(current_map.keys().cloned())
394        .collect();
395
396    let mut deltas = Vec::new();
397    for key in keys {
398        let before = base_map.get(&key).copied();
399        let after = current_map.get(&key).copied();
400        match (before, after) {
401            (Some(b), Some(a)) if b.refers_to != a.refers_to || b.kind != a.kind => {
402                deltas.push(NamedRangeDelta {
403                    name: a.name.clone(),
404                    scope_kind: a.scope_kind,
405                    scope_sheet_name: a.scope_sheet_name.clone(),
406                    change: "changed".to_string(),
407                    before_refers_to: Some(b.refers_to.clone()),
408                    after_refers_to: Some(a.refers_to.clone()),
409                    before_kind: Some(b.kind.clone()),
410                    after_kind: Some(a.kind.clone()),
411                });
412            }
413            (Some(b), None) => {
414                deltas.push(NamedRangeDelta {
415                    name: b.name.clone(),
416                    scope_kind: b.scope_kind,
417                    scope_sheet_name: b.scope_sheet_name.clone(),
418                    change: "removed".to_string(),
419                    before_refers_to: Some(b.refers_to.clone()),
420                    after_refers_to: None,
421                    before_kind: Some(b.kind.clone()),
422                    after_kind: None,
423                });
424            }
425            (None, Some(a)) => {
426                deltas.push(NamedRangeDelta {
427                    name: a.name.clone(),
428                    scope_kind: a.scope_kind,
429                    scope_sheet_name: a.scope_sheet_name.clone(),
430                    change: "added".to_string(),
431                    before_refers_to: None,
432                    after_refers_to: Some(a.refers_to.clone()),
433                    before_kind: None,
434                    after_kind: Some(a.kind.clone()),
435                });
436            }
437            _ => {}
438        }
439    }
440
441    deltas
442}
443
444fn named_range_key(item: &NamedRangeDescriptor) -> String {
445    format!(
446        "{}|{:?}|{}|{:?}",
447        item.name,
448        item.scope_kind,
449        item.scope_sheet_name.as_deref().unwrap_or(""),
450        item.kind
451    )
452}
453
454fn classify_target_delta(
455    before: &TargetCellSnapshot,
456    after: &TargetCellSnapshot,
457    changed: bool,
458) -> &'static str {
459    if !changed {
460        return "unchanged";
461    }
462    if is_error_value(after.value.as_ref()) && !is_error_value(before.value.as_ref()) {
463        return "new_error";
464    }
465    if before.formula != after.formula {
466        return "formula_shift";
467    }
468    if before.formula.is_none() && after.formula.is_none() {
469        return "direct_edit";
470    }
471    "recalc_result"
472}
473
474fn is_error_value(value: Option<&CellValue>) -> bool {
475    match value {
476        Some(CellValue::Error(_)) => true,
477        Some(other) => serde_json::to_value(other)
478            .ok()
479            .and_then(|json| {
480                json.get("value")
481                    .and_then(|v| v.as_str())
482                    .map(is_error_text)
483            })
484            .unwrap_or(false),
485        None => false,
486    }
487}
488
489fn cell_values_equal(left: Option<&CellValue>, right: Option<&CellValue>) -> bool {
490    match (left, right) {
491        (None, None) => true,
492        (Some(l), Some(r)) => serde_json::to_value(l).ok() == serde_json::to_value(r).ok(),
493        _ => false,
494    }
495}
496
497fn non_empty_formula(raw: &str) -> Option<String> {
498    let trimmed = raw.trim();
499    (!trimmed.is_empty()).then(|| trimmed.to_string())
500}
501
502fn is_error_text(raw: &str) -> bool {
503    let upper = raw.trim().to_ascii_uppercase();
504    matches!(
505        upper.as_str(),
506        "#DIV/0!"
507            | "#VALUE!"
508            | "#NAME?"
509            | "#REF!"
510            | "#N/A"
511            | "#NULL!"
512            | "#NUM!"
513            | "#SPILL!"
514            | "#CALC!"
515            | "#BUSY!"
516            | "#FIELD!"
517            | "#UNKNOWN!"
518    )
519}