Skip to main content

spreadsheet_mcp/diff/
tables.rs

1use anyhow::Result;
2use quick_xml::events::Event;
3use quick_xml::reader::Reader;
4use schemars::JsonSchema;
5use serde::Serialize;
6use std::collections::{HashMap, HashSet};
7use std::io::BufRead;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct TableInfo {
11    pub display_name: String,
12    pub range: String, // "A1:D5"
13    pub sheet: String, // "Sheet1"
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum TableDiff {
19    TableAdded {
20        display_name: String,
21        sheet: String,
22        range: String,
23    },
24    TableDeleted {
25        display_name: String,
26        sheet: String,
27    },
28    TableModified {
29        display_name: String,
30        sheet: String,
31        old_range: String,
32        new_range: String,
33    },
34}
35
36pub fn parse_table_xml<R: BufRead>(
37    reader: &mut Reader<R>,
38    sheet_name: String,
39) -> Result<TableInfo> {
40    let mut buf = Vec::new();
41    let mut display_name = String::new();
42    let mut range = String::new();
43
44    loop {
45        match reader.read_event_into(&mut buf) {
46            Ok(Event::Start(ref e)) if e.name().as_ref() == b"table" => {
47                for attr in e.attributes() {
48                    let attr = attr?;
49                    match attr.key.as_ref() {
50                        b"displayName" => {
51                            display_name = String::from_utf8_lossy(&attr.value).to_string()
52                        }
53                        b"ref" => range = String::from_utf8_lossy(&attr.value).to_string(),
54                        _ => {}
55                    }
56                }
57                // We only need the top-level attributes
58                break;
59            }
60            Ok(Event::Eof) => break,
61            Err(e) => return Err(e.into()),
62            _ => {}
63        }
64        buf.clear();
65    }
66
67    if display_name.is_empty() || range.is_empty() {
68        // Fallback or error? Some tables might lack displayName?
69        // Spec says displayName is required.
70        // If we didn't find <table ...>, return error
71        if display_name.is_empty() {
72            return Err(anyhow::anyhow!("Missing displayName in table definition"));
73        }
74    }
75
76    Ok(TableInfo {
77        display_name,
78        range,
79        sheet: sheet_name,
80    })
81}
82
83pub fn diff_tables(
84    base_tables: &HashMap<String, TableInfo>, // Keyed by displayName
85    fork_tables: &HashMap<String, TableInfo>,
86) -> Vec<TableDiff> {
87    let mut diffs = Vec::new();
88    let all_keys: HashSet<_> = base_tables.keys().chain(fork_tables.keys()).collect();
89
90    for key in all_keys {
91        let base = base_tables.get(key);
92        let fork = fork_tables.get(key);
93
94        match (base, fork) {
95            (None, Some(f)) => {
96                diffs.push(TableDiff::TableAdded {
97                    display_name: f.display_name.clone(),
98                    sheet: f.sheet.clone(),
99                    range: f.range.clone(),
100                });
101            }
102            (Some(b), None) => {
103                diffs.push(TableDiff::TableDeleted {
104                    display_name: b.display_name.clone(),
105                    sheet: b.sheet.clone(),
106                });
107            }
108            (Some(b), Some(f)) => {
109                // If semantic identity (displayName) matches, check for changes
110                if b.range != f.range {
111                    diffs.push(TableDiff::TableModified {
112                        display_name: b.display_name.clone(),
113                        sheet: b.sheet.clone(),
114                        old_range: b.range.clone(),
115                        new_range: f.range.clone(),
116                    });
117                }
118                // Note: If sheet changed (e.g. table moved to another sheet),
119                // it would look like a modification here if displayName is preserved.
120                // In practice, moving a table across sheets is rare/complex.
121                // If it happens, we might want to report it.
122                // For V1, we ignore sheet changes in TableModified, or we could add it.
123            }
124            (None, None) => unreachable!(),
125        }
126    }
127
128    // Sort
129    diffs.sort_by(|a, b| {
130        let name_a = match a {
131            TableDiff::TableAdded { display_name, .. } => display_name,
132            TableDiff::TableDeleted { display_name, .. } => display_name,
133            TableDiff::TableModified { display_name, .. } => display_name,
134        };
135        let name_b = match b {
136            TableDiff::TableAdded { display_name, .. } => display_name,
137            TableDiff::TableDeleted { display_name, .. } => display_name,
138            TableDiff::TableModified { display_name, .. } => display_name,
139        };
140        name_a.cmp(name_b)
141    });
142
143    diffs
144}