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, ¤t_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}