Skip to main content

spreadsheet_kit/cli/commands/
diff.rs

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}