1use crate::runtime::stateless::StatelessRuntime;
2use anyhow::{Result, anyhow, bail};
3use serde::Serialize;
4use serde_json::{Map, Value, json};
5use std::collections::{BTreeMap, BTreeSet};
6use std::path::PathBuf;
7
8const DIFF_LIMIT_MAX: u32 = 2_000;
9const GROUP_PREVIEW_LIMIT: usize = 25;
10
11#[derive(Debug, Clone, Copy)]
12struct A1Bounds {
13 start_col: u32,
14 end_col: u32,
15 start_row: u32,
16 end_row: u32,
17}
18
19#[derive(Debug, Clone, Serialize)]
20struct DiffGroup {
21 group_id: String,
22 kind: String,
23 group_type: String,
24 review_priority: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 sheet: Option<String>,
27 change_count: u32,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 range: Option<String>,
30 #[serde(skip_serializing_if = "Vec::is_empty")]
31 sample_addresses: Vec<String>,
32 #[serde(skip_serializing_if = "Vec::is_empty")]
33 sample_items: Vec<String>,
34}
35
36#[derive(Debug, Clone, Serialize)]
37struct SheetDiffSummary {
38 #[serde(skip_serializing_if = "Option::is_none")]
39 sheet: Option<String>,
40 total_changes: u32,
41 direct_change_count: u32,
42 recalc_result_change_count: u32,
43 counts_by_subtype: BTreeMap<String, u32>,
44 group_count: u32,
45 counts_by_group_type: BTreeMap<String, u32>,
46 group_preview: Vec<DiffGroup>,
47 group_preview_truncated: bool,
48}
49
50#[derive(Debug, Clone)]
51struct DiffGroupBuilder {
52 kind: String,
53 group_type: String,
54 sheet: Option<String>,
55 change_count: u32,
56 min_col: Option<u32>,
57 max_col: Option<u32>,
58 min_row: Option<u32>,
59 max_row: Option<u32>,
60 last_address: Option<String>,
61 sample_addresses: Vec<String>,
62 sample_items: Vec<String>,
63}
64
65pub struct DiffCommandArgs {
66 pub original: PathBuf,
67 pub modified: PathBuf,
68 pub sheet: Option<String>,
69 pub sheets: Option<Vec<String>>,
70 pub range: Option<String>,
71 pub details: bool,
72 pub limit: u32,
73 pub offset: u32,
74 pub exclude_recalc_result: bool,
75}
76
77pub async fn diff(args: DiffCommandArgs) -> Result<Value> {
78 let DiffCommandArgs {
79 original,
80 modified,
81 sheet,
82 sheets,
83 range,
84 details,
85 limit,
86 offset,
87 exclude_recalc_result,
88 } = args;
89 if sheet.is_some() && sheets.is_some() {
90 bail!("invalid argument: --sheet and --sheets are mutually exclusive");
91 }
92
93 let runtime = StatelessRuntime;
94 let original = runtime.normalize_existing_file(&original)?;
95 let modified = runtime.normalize_existing_file(&modified)?;
96
97 if details && (limit == 0 || limit > DIFF_LIMIT_MAX) {
98 bail!(
99 "invalid argument: --limit must be between 1 and {}",
100 DIFF_LIMIT_MAX
101 );
102 }
103
104 let sheet_filters: Vec<String> = if let Some(s) = sheet {
105 vec![s]
106 } else {
107 sheets.unwrap_or_default()
108 };
109
110 let range_bounds = if let Some(range) = range.as_ref() {
111 Some(
112 parse_a1_range(range)
113 .ok_or_else(|| anyhow!("invalid argument: --range must be A1 notation"))?,
114 )
115 } else {
116 None
117 };
118
119 let mut payload = runtime.diff_json(&original, &modified)?;
120 let changes = payload
121 .get_mut("changes")
122 .and_then(Value::as_array_mut)
123 .map(std::mem::take)
124 .unwrap_or_default();
125
126 let mut counts_by_kind: BTreeMap<String, u32> = BTreeMap::new();
127 let mut counts_by_type: BTreeMap<String, u32> = BTreeMap::new();
128 let mut counts_by_subtype: BTreeMap<String, u32> = BTreeMap::new();
129 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
130
131 let mut filtered = Vec::new();
132 let mut recalc_result_change_count = 0u32;
133 for change in changes {
134 if !change_matches_filters(&change, &sheet_filters, range_bounds) {
135 continue;
136 }
137
138 let subtype = change_subtype_key(&change).map(str::to_string);
139 if exclude_recalc_result && subtype.as_deref() == Some("recalc_result") {
140 continue;
141 }
142
143 let kind = change_kind(&change).to_string();
144 *counts_by_kind.entry(kind).or_default() += 1;
145
146 let type_key = change_type_key(&change).to_string();
147 *counts_by_type.entry(type_key).or_default() += 1;
148
149 if let Some(subtype_key) = subtype {
150 if subtype_key == "recalc_result" {
151 recalc_result_change_count += 1;
152 }
153 *counts_by_subtype.entry(subtype_key).or_default() += 1;
154 }
155
156 if let Some(sheet_name) = change_sheet_name(&change) {
157 affected_sheets.insert(sheet_name.to_string());
158 }
159
160 filtered.push(change);
161 }
162
163 let total_changes = filtered.len() as u32;
164 let direct_change_count = total_changes.saturating_sub(recalc_result_change_count);
165 let groups = build_groups(&filtered);
166 let sheet_summaries = build_sheet_summaries(&filtered, &groups);
167 let mut counts_by_group_type: BTreeMap<String, u32> = BTreeMap::new();
168 for group in &groups {
169 *counts_by_group_type
170 .entry(group.group_type.clone())
171 .or_default() += 1;
172 }
173 let group_preview: Vec<Value> = groups
174 .iter()
175 .take(GROUP_PREVIEW_LIMIT)
176 .map(|group| serde_json::to_value(group).expect("group to value"))
177 .collect();
178 let group_preview_truncated = groups.len() > GROUP_PREVIEW_LIMIT;
179
180 let (returned_changes, paged_changes, truncated, next_offset) = if details {
181 let offset = offset as usize;
182 let limit = limit as usize;
183 let total = filtered.len();
184 let page: Vec<Value> = filtered.into_iter().skip(offset).take(limit).collect();
185 let returned = page.len() as u32;
186 let consumed = offset.saturating_add(returned as usize);
187 let truncated = consumed < total;
188 let next_offset = truncated.then_some(consumed as u32);
189 (returned, page, truncated, next_offset)
190 } else {
191 (0, Vec::new(), false, None)
192 };
193
194 let summary = json!({
195 "total_changes": total_changes,
196 "returned_changes": returned_changes,
197 "truncated": truncated,
198 "next_offset": next_offset,
199 "counts_by_kind": counts_by_kind,
200 "counts_by_type": counts_by_type,
201 "counts_by_subtype": counts_by_subtype,
202 "affected_sheets": affected_sheets.into_iter().collect::<Vec<_>>(),
203 "recalc_result_change_count": recalc_result_change_count,
204 "direct_change_count": direct_change_count,
205 "group_count": groups.len(),
206 "counts_by_group_type": counts_by_group_type,
207 "group_preview": group_preview,
208 "group_preview_truncated": group_preview_truncated,
209 "sheet_summaries": sheet_summaries,
210 "filters": {
211 "exclude_recalc_result": exclude_recalc_result,
212 }
213 });
214
215 let mut response = Map::new();
216 response.insert(
217 "original".to_string(),
218 Value::String(original.display().to_string()),
219 );
220 response.insert(
221 "modified".to_string(),
222 Value::String(modified.display().to_string()),
223 );
224 response.insert("change_count".to_string(), Value::from(total_changes));
225 response.insert("summary".to_string(), summary);
226
227 if details {
228 response.insert("changes".to_string(), Value::Array(paged_changes));
229 response.insert(
230 "groups".to_string(),
231 Value::Array(
232 groups
233 .into_iter()
234 .map(|group| serde_json::to_value(group).expect("group to value"))
235 .collect(),
236 ),
237 );
238 }
239
240 Ok(Value::Object(response))
241}
242
243fn build_groups(changes: &[Value]) -> Vec<DiffGroup> {
244 let mut ordered = changes.to_vec();
245 ordered.sort_by_key(group_sort_key);
246
247 let mut out = Vec::new();
248 let mut current: Option<DiffGroupBuilder> = None;
249
250 for change in &ordered {
251 let next = group_builder_for_change(change);
252 match current.take() {
253 Some(mut active) if can_merge_group(&active, &next, change) => {
254 merge_group(&mut active, change);
255 current = Some(active);
256 }
257 Some(active) => {
258 out.push(finalize_group(active, 0));
259 current = Some(next);
260 }
261 None => current = Some(next),
262 }
263 }
264
265 if let Some(active) = current {
266 out.push(finalize_group(active, 0));
267 }
268
269 out.sort_by_key(diff_group_sort_key);
270 for (idx, group) in out.iter_mut().enumerate() {
271 group.group_id = format!("grp_{:04}", idx + 1);
272 }
273 out
274}
275
276fn group_builder_for_change(change: &Value) -> DiffGroupBuilder {
277 let kind = change_kind(change).to_string();
278 let group_type = change_group_type(change).to_string();
279 let sheet = change_sheet_name(change).map(str::to_string);
280 let mut builder = DiffGroupBuilder {
281 kind,
282 group_type,
283 sheet,
284 change_count: 0,
285 min_col: None,
286 max_col: None,
287 min_row: None,
288 max_row: None,
289 last_address: None,
290 sample_addresses: Vec::new(),
291 sample_items: Vec::new(),
292 };
293 merge_group(&mut builder, change);
294 builder
295}
296
297fn can_merge_group(current: &DiffGroupBuilder, next: &DiffGroupBuilder, change: &Value) -> bool {
298 if current.kind != "cell" || next.kind != "cell" {
299 return false;
300 }
301 if current.group_type != next.group_type || current.sheet != next.sheet {
302 return false;
303 }
304 let Some(last_address) = current.last_address.as_deref() else {
305 return false;
306 };
307 let Some(next_address) = change_address(change) else {
308 return false;
309 };
310 addresses_are_adjacent(last_address, next_address)
311}
312
313fn merge_group(group: &mut DiffGroupBuilder, change: &Value) {
314 group.change_count += 1;
315
316 if let Some(address) = change_address(change) {
317 group.last_address = Some(address.to_string());
318 if group.sample_addresses.len() < 5 {
319 group.sample_addresses.push(address.to_string());
320 }
321 if let Some((col, row)) = parse_a1_coord(address) {
322 group.min_col = Some(group.min_col.map_or(col, |v| v.min(col)));
323 group.max_col = Some(group.max_col.map_or(col, |v| v.max(col)));
324 group.min_row = Some(group.min_row.map_or(row, |v| v.min(row)));
325 group.max_row = Some(group.max_row.map_or(row, |v| v.max(row)));
326 }
327 return;
328 }
329
330 if let Some(item_name) = change_item_name(change)
331 && group.sample_items.len() < 5
332 {
333 group.sample_items.push(item_name.to_string());
334 }
335}
336
337fn finalize_group(group: DiffGroupBuilder, index: usize) -> DiffGroup {
338 let group_type = group.group_type;
339 DiffGroup {
340 group_id: format!("grp_{:04}", index + 1),
341 kind: group.kind,
342 review_priority: review_priority_label(&group_type).to_string(),
343 group_type,
344 sheet: group.sheet,
345 change_count: group.change_count,
346 range: match (group.min_col, group.max_col, group.min_row, group.max_row) {
347 (Some(start_col), Some(end_col), Some(start_row), Some(end_row)) => {
348 Some(format_a1_range(start_col, end_col, start_row, end_row))
349 }
350 _ => None,
351 },
352 sample_addresses: group.sample_addresses,
353 sample_items: group.sample_items,
354 }
355}
356
357fn change_group_type(change: &Value) -> &str {
358 if let Some(subtype) = change_subtype_key(change) {
359 return subtype;
360 }
361 change_type_key(change)
362}
363
364fn change_kind(change: &Value) -> &'static str {
365 if change.get("address").is_some() {
366 "cell"
367 } else if change.get("display_name").is_some() {
368 "table"
369 } else if change.get("name").is_some() {
370 "name"
371 } else {
372 "unknown"
373 }
374}
375
376fn change_type_key(change: &Value) -> &str {
377 match change_kind(change) {
378 "cell" => change
379 .get("type")
380 .and_then(Value::as_str)
381 .unwrap_or("unknown"),
382 "table" => match change.get("type").and_then(Value::as_str) {
383 Some("table_added") => "table_added",
384 Some("table_deleted") => "table_deleted",
385 Some("table_modified") => "table_modified",
386 _ => "table_unknown",
387 },
388 "name" => match change.get("type").and_then(Value::as_str) {
389 Some("name_added") => "name_added",
390 Some("name_deleted") => "name_deleted",
391 Some("name_modified") => "name_modified",
392 _ => "name_unknown",
393 },
394 _ => "unknown",
395 }
396}
397
398fn change_subtype_key(change: &Value) -> Option<&str> {
399 change.get("subtype").and_then(Value::as_str)
400}
401
402fn change_sheet_name(change: &Value) -> Option<&str> {
403 change
404 .get("sheet")
405 .and_then(Value::as_str)
406 .or_else(|| change.get("scope_sheet").and_then(Value::as_str))
407}
408
409fn change_address(change: &Value) -> Option<&str> {
410 change.get("address").and_then(Value::as_str)
411}
412
413fn change_item_name(change: &Value) -> Option<&str> {
414 change
415 .get("display_name")
416 .and_then(Value::as_str)
417 .or_else(|| change.get("name").and_then(Value::as_str))
418}
419
420fn group_sort_key(change: &Value) -> (String, String, u32, u32, u32, u32, String) {
421 let (start_row, start_col, end_row, end_col, label) =
422 change_position_key(change_address(change), change_item_name(change));
423 (
424 change_sheet_name(change).unwrap_or("").to_string(),
425 change_group_type(change).to_string(),
426 start_row,
427 start_col,
428 end_row,
429 end_col,
430 label,
431 )
432}
433
434fn diff_group_sort_key(group: &DiffGroup) -> (u8, String, u32, u32, u32, u32, String, String) {
435 let (start_row, start_col, end_row, end_col, label) = change_position_key(
436 group
437 .range
438 .as_deref()
439 .or_else(|| group.sample_addresses.first().map(String::as_str)),
440 group.sample_items.first().map(String::as_str),
441 );
442 (
443 review_priority_rank(&group.group_type),
444 group.sheet.clone().unwrap_or_default(),
445 start_row,
446 start_col,
447 end_row,
448 end_col,
449 group.group_type.clone(),
450 label,
451 )
452}
453
454fn review_priority_rank(group_type: &str) -> u8 {
455 match group_type {
456 "formula_edit" | "value_edit" | "style_edit" | "added" | "deleted" => 0,
457 "table_modified" | "name_modified" | "table_added" | "table_deleted" | "name_added"
458 | "name_deleted" => 1,
459 "recalc_result" => 2,
460 _ => 3,
461 }
462}
463
464fn review_priority_label(group_type: &str) -> &'static str {
465 match review_priority_rank(group_type) {
466 0 => "direct",
467 1 => "structural",
468 2 => "derived",
469 _ => "other",
470 }
471}
472
473fn change_position_key(
474 address_or_range: Option<&str>,
475 item_name: Option<&str>,
476) -> (u32, u32, u32, u32, String) {
477 if let Some(text) = address_or_range {
478 if let Some(bounds) = parse_a1_range(text) {
479 return (
480 bounds.start_row,
481 bounds.start_col,
482 bounds.end_row,
483 bounds.end_col,
484 text.to_string(),
485 );
486 }
487 if let Some((col, row)) = parse_a1_coord(text) {
488 return (row, col, row, col, text.to_string());
489 }
490 }
491
492 (
493 u32::MAX,
494 u32::MAX,
495 u32::MAX,
496 u32::MAX,
497 item_name.unwrap_or("").to_string(),
498 )
499}
500
501fn build_sheet_summaries(changes: &[Value], groups: &[DiffGroup]) -> Vec<SheetDiffSummary> {
502 let mut counts_by_sheet: BTreeMap<Option<String>, SheetDiffSummary> = BTreeMap::new();
503
504 for change in changes {
505 let sheet_key = change_sheet_name(change).map(str::to_string);
506 let entry = counts_by_sheet
507 .entry(sheet_key.clone())
508 .or_insert_with(|| SheetDiffSummary {
509 sheet: sheet_key.clone(),
510 total_changes: 0,
511 direct_change_count: 0,
512 recalc_result_change_count: 0,
513 counts_by_subtype: BTreeMap::new(),
514 group_count: 0,
515 counts_by_group_type: BTreeMap::new(),
516 group_preview: Vec::new(),
517 group_preview_truncated: false,
518 });
519 entry.total_changes += 1;
520 match change_subtype_key(change) {
521 Some("recalc_result") => {
522 entry.recalc_result_change_count += 1;
523 *entry
524 .counts_by_subtype
525 .entry("recalc_result".to_string())
526 .or_default() += 1;
527 }
528 Some(subtype) => {
529 entry.direct_change_count += 1;
530 *entry
531 .counts_by_subtype
532 .entry(subtype.to_string())
533 .or_default() += 1;
534 }
535 None => {
536 entry.direct_change_count += 1;
537 }
538 }
539 }
540
541 for group in groups {
542 let entry = counts_by_sheet
543 .entry(group.sheet.clone())
544 .or_insert_with(|| SheetDiffSummary {
545 sheet: group.sheet.clone(),
546 total_changes: 0,
547 direct_change_count: 0,
548 recalc_result_change_count: 0,
549 counts_by_subtype: BTreeMap::new(),
550 group_count: 0,
551 counts_by_group_type: BTreeMap::new(),
552 group_preview: Vec::new(),
553 group_preview_truncated: false,
554 });
555 entry.group_count += 1;
556 *entry
557 .counts_by_group_type
558 .entry(group.group_type.clone())
559 .or_default() += 1;
560 if entry.group_preview.len() < 10 {
561 entry.group_preview.push(group.clone());
562 } else {
563 entry.group_preview_truncated = true;
564 }
565 }
566
567 counts_by_sheet.into_values().collect()
568}
569
570fn change_matches_filters(
571 change: &Value,
572 sheet_filters: &[String],
573 range: Option<A1Bounds>,
574) -> bool {
575 if !sheet_filters.is_empty() {
576 let Some(sheet_name) = change_sheet_name(change) else {
577 return false;
578 };
579 if !sheet_filters
580 .iter()
581 .any(|f| sheet_name.eq_ignore_ascii_case(f))
582 {
583 return false;
584 }
585 }
586
587 let Some(bounds) = range else {
588 return true;
589 };
590
591 if let Some(address) = change.get("address").and_then(Value::as_str) {
592 return address_in_bounds(address, bounds);
593 }
594
595 ["range", "old_range", "new_range"]
596 .iter()
597 .filter_map(|key| change.get(*key).and_then(Value::as_str))
598 .any(|candidate| range_intersects(candidate, bounds))
599}
600
601fn address_in_bounds(address: &str, bounds: A1Bounds) -> bool {
602 let Some((col, row)) = parse_a1_coord(address) else {
603 return false;
604 };
605 col >= bounds.start_col
606 && col <= bounds.end_col
607 && row >= bounds.start_row
608 && row <= bounds.end_row
609}
610
611fn range_intersects(range: &str, bounds: A1Bounds) -> bool {
612 let Some(candidate) = parse_a1_range(range) else {
613 return false;
614 };
615
616 !(candidate.end_col < bounds.start_col
617 || candidate.start_col > bounds.end_col
618 || candidate.end_row < bounds.start_row
619 || candidate.start_row > bounds.end_row)
620}
621
622fn addresses_are_adjacent(left: &str, right: &str) -> bool {
623 let (left_col, left_row) = match parse_a1_coord(left) {
624 Some(v) => v,
625 None => return false,
626 };
627 let (right_col, right_row) = match parse_a1_coord(right) {
628 Some(v) => v,
629 None => return false,
630 };
631
632 (left_row == right_row && left_col.abs_diff(right_col) == 1)
633 || (left_col == right_col && left_row.abs_diff(right_row) == 1)
634}
635
636fn format_a1_range(start_col: u32, end_col: u32, start_row: u32, end_row: u32) -> String {
637 let start = format!("{}{}", column_number_to_name(start_col), start_row);
638 let end = format!("{}{}", column_number_to_name(end_col), end_row);
639 if start == end {
640 start
641 } else {
642 format!("{}:{}", start, end)
643 }
644}
645
646fn column_number_to_name(mut col: u32) -> String {
647 let mut chars = Vec::new();
648 while col > 0 {
649 let rem = ((col - 1) % 26) as u8;
650 chars.push((b'A' + rem) as char);
651 col = (col - 1) / 26;
652 }
653 chars.iter().rev().collect()
654}
655
656fn parse_a1_range(raw: &str) -> Option<A1Bounds> {
657 let mut text = raw.trim();
658 if text.is_empty() {
659 return None;
660 }
661
662 if let Some((_, tail)) = text.rsplit_once('!') {
663 text = tail;
664 }
665
666 let (left, right) = text.split_once(':').map_or((text, text), |(a, b)| (a, b));
667 let (c1, r1) = parse_a1_coord(left)?;
668 let (c2, r2) = parse_a1_coord(right)?;
669
670 Some(A1Bounds {
671 start_col: c1.min(c2),
672 end_col: c1.max(c2),
673 start_row: r1.min(r2),
674 end_row: r1.max(r2),
675 })
676}
677
678fn parse_a1_coord(raw: &str) -> Option<(u32, u32)> {
679 let coord = raw.trim().trim_start_matches('$');
680 if coord.is_empty() {
681 return None;
682 }
683
684 let mut letters = String::new();
685 let mut digits = String::new();
686 for ch in coord.chars() {
687 if ch == '$' {
688 continue;
689 }
690 if ch.is_ascii_alphabetic() {
691 if !digits.is_empty() {
692 return None;
693 }
694 letters.push(ch.to_ascii_uppercase());
695 } else if ch.is_ascii_digit() {
696 digits.push(ch);
697 } else {
698 return None;
699 }
700 }
701
702 if letters.is_empty() || digits.is_empty() {
703 return None;
704 }
705
706 let mut col = 0u32;
707 for ch in letters.bytes() {
708 col = col
709 .saturating_mul(26)
710 .saturating_add((ch - b'A' + 1) as u32);
711 }
712
713 let row: u32 = digits.parse().ok()?;
714 if col == 0 || row == 0 {
715 return None;
716 }
717
718 Some((col, row))
719}