Skip to main content

excel_mcp_server/
cell_ref.rs

1// A1 notation parser: cell reference and range parsing utilities
2
3use crate::error::ExcelMcpError;
4
5/// Maximum Excel column index (0-based). Column XFD = 16383.
6const MAX_COL: u16 = 16383;
7/// Maximum Excel row (1-based). Row 1048576 → 0-based index 1048575.
8const MAX_ROW: u32 = 1_048_575;
9
10/// A zero-based cell position.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct CellPos {
13    /// 0-based row index (A1 row "1" → 0).
14    pub row: u32,
15    /// 0-based column index (A → 0, B → 1, …, XFD → 16383).
16    pub col: u16,
17}
18
19/// A rectangular range defined by two cell positions (inclusive).
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct CellRange {
22    pub start: CellPos,
23    pub end: CellPos,
24}
25
26/// Convert a column letter string (e.g. "A", "Z", "AA", "XFD") to a 0-based index.
27///
28/// Uses base-26 conversion where A=0, B=1, …, Z=25, AA=26, etc.
29pub fn col_letter_to_index(s: &str) -> Result<u16, ExcelMcpError> {
30    if s.is_empty() {
31        return Err(ExcelMcpError::ParseError(
32            "Column letter string is empty".into(),
33        ));
34    }
35
36    let mut index: u32 = 0;
37    for ch in s.chars() {
38        if !ch.is_ascii_alphabetic() {
39            return Err(ExcelMcpError::ParseError(format!(
40                "Invalid character '{}' in column letters '{}'",
41                ch, s
42            )));
43        }
44        let val = ch.to_ascii_uppercase() as u32 - b'A' as u32;
45        index = index * 26 + val + 1;
46    }
47    // Convert from 1-based to 0-based
48    let index = index - 1;
49
50    if index > MAX_COL as u32 {
51        return Err(ExcelMcpError::InvalidInput(format!(
52            "Column '{}' (index {}) exceeds maximum Excel column XFD ({})",
53            s, index, MAX_COL
54        )));
55    }
56    Ok(index as u16)
57}
58
59/// Convert a 0-based column index to a column letter string (e.g. 0 → "A", 25 → "Z", 26 → "AA").
60pub fn index_to_col_letter(col: u16) -> String {
61    let mut result = String::new();
62    let mut n = col as u32 + 1; // convert to 1-based
63    while n > 0 {
64        n -= 1;
65        let ch = (b'A' + (n % 26) as u8) as char;
66        result.push(ch);
67        n /= 26;
68    }
69    result.chars().rev().collect()
70}
71
72/// Convert a `CellPos` back to A1 notation (e.g. `CellPos { row: 0, col: 0 }` → "A1").
73pub fn cell_pos_to_a1(pos: &CellPos) -> String {
74    format!("{}{}", index_to_col_letter(pos.col), pos.row + 1)
75}
76
77/// Parse an A1-notation cell reference (e.g. "B3") into a `CellPos`.
78///
79/// Splits the string at the boundary between letters and digits, converts
80/// the column letters via base-26, and converts the 1-based row to 0-based.
81pub fn parse_cell_ref(s: &str) -> Result<CellPos, ExcelMcpError> {
82    let s = s.trim();
83    if s.is_empty() {
84        return Err(ExcelMcpError::ParseError("Cell reference is empty".into()));
85    }
86
87    // Find the split point between letters and digits
88    let split = s.find(|c: char| c.is_ascii_digit()).ok_or_else(|| {
89        ExcelMcpError::ParseError(format!("Cell reference '{}' has no row number", s))
90    })?;
91
92    if split == 0 {
93        return Err(ExcelMcpError::ParseError(format!(
94            "Cell reference '{}' has no column letters",
95            s
96        )));
97    }
98
99    let col_str = &s[..split];
100    let row_str = &s[split..];
101
102    // Validate that the remaining part is all digits
103    if !row_str.chars().all(|c| c.is_ascii_digit()) {
104        return Err(ExcelMcpError::ParseError(format!(
105            "Cell reference '{}' contains invalid characters in row portion '{}'",
106            s, row_str
107        )));
108    }
109
110    let col = col_letter_to_index(col_str)?;
111
112    let row_1based: u32 = row_str.parse().map_err(|_| {
113        ExcelMcpError::ParseError(format!(
114            "Invalid row number '{}' in cell reference '{}'",
115            row_str, s
116        ))
117    })?;
118
119    if row_1based == 0 {
120        return Err(ExcelMcpError::InvalidInput(format!(
121            "Row number in '{}' must be >= 1",
122            s
123        )));
124    }
125
126    let row = row_1based - 1;
127    if row > MAX_ROW {
128        return Err(ExcelMcpError::InvalidInput(format!(
129            "Row {} in '{}' exceeds maximum Excel row 1048576",
130            row_1based, s
131        )));
132    }
133
134    Ok(CellPos { row, col })
135}
136
137/// Parse a range reference in "A1:B2" notation into a `CellRange`.
138pub fn parse_range_ref(s: &str) -> Result<CellRange, ExcelMcpError> {
139    let s = s.trim();
140    let parts: Vec<&str> = s.split(':').collect();
141    if parts.len() != 2 {
142        return Err(ExcelMcpError::ParseError(format!(
143            "Range reference '{}' must contain exactly one ':'",
144            s
145        )));
146    }
147
148    let start = parse_cell_ref(parts[0])?;
149    let end = parse_cell_ref(parts[1])?;
150
151    Ok(CellRange { start, end })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use proptest::prelude::*;
158
159    proptest! {
160        /// **Validates: Requirements 7.5, 8.3**
161        #[test]
162        fn roundtrip_cell_ref(row in 0u32..1_048_576, col in 0u16..16384) {
163            let pos = CellPos { row, col };
164            let a1 = cell_pos_to_a1(&pos);
165            let parsed = parse_cell_ref(&a1).unwrap();
166            prop_assert_eq!(parsed.row, pos.row);
167            prop_assert_eq!(parsed.col, pos.col);
168        }
169    }
170
171    /// **Validates: Requirements 7.5, 8.3**
172    #[test]
173    fn test_parse_cell_ref_known() {
174        let cases = [
175            ("A1", 0, 0),
176            ("B1", 0, 1),
177            ("Z1", 0, 25),
178            ("AA1", 0, 26),
179            ("AB1", 0, 27),
180            ("AZ1", 0, 51),
181            ("BA1", 0, 52),
182            ("XFD1", 0, 16383),
183            ("A2", 1, 0),
184            ("A1048576", 1_048_575, 0),
185            ("C10", 9, 2),
186        ];
187        for (input, expected_row, expected_col) in cases {
188            let pos = parse_cell_ref(input)
189                .unwrap_or_else(|e| panic!("Failed to parse '{}': {}", input, e));
190            assert_eq!(pos.row, expected_row, "Row mismatch for '{}'", input);
191            assert_eq!(pos.col, expected_col, "Col mismatch for '{}'", input);
192        }
193    }
194
195    /// **Validates: Requirements 7.5, 8.3**
196    #[test]
197    fn test_parse_cell_ref_invalid() {
198        // Empty string
199        assert!(parse_cell_ref("").is_err());
200
201        // Digits only (no column letters)
202        assert!(parse_cell_ref("123").is_err());
203
204        // Letters only (no row number)
205        assert!(parse_cell_ref("ABC").is_err());
206
207        // Row 0 is invalid (Excel rows are 1-based)
208        assert!(parse_cell_ref("A0").is_err());
209
210        // Exceeding max row (1048576 is max, so 1048577 is invalid)
211        assert!(parse_cell_ref("A1048577").is_err());
212
213        // Exceeding max column (XFD = 16383, XFE would be 16384)
214        assert!(parse_cell_ref("XFE1").is_err());
215
216        // Non-alphabetic characters in column
217        assert!(parse_cell_ref("1A1").is_err());
218    }
219
220    /// **Validates: Requirements 7.5, 8.3**
221    #[test]
222    fn test_parse_range_ref() {
223        // Simple range
224        let range = parse_range_ref("A1:B2").unwrap();
225        assert_eq!(range.start, CellPos { row: 0, col: 0 });
226        assert_eq!(range.end, CellPos { row: 1, col: 1 });
227
228        // Full-sheet range
229        let range = parse_range_ref("A1:XFD1048576").unwrap();
230        assert_eq!(range.start, CellPos { row: 0, col: 0 });
231        assert_eq!(
232            range.end,
233            CellPos {
234                row: 1_048_575,
235                col: 16383
236            }
237        );
238
239        // Same cell range
240        let range = parse_range_ref("C3:C3").unwrap();
241        assert_eq!(range.start, CellPos { row: 2, col: 2 });
242        assert_eq!(range.end, CellPos { row: 2, col: 2 });
243
244        // Invalid: no colon
245        assert!(parse_range_ref("A1B2").is_err());
246
247        // Invalid: too many colons
248        assert!(parse_range_ref("A1:B2:C3").is_err());
249
250        // Invalid: bad cell ref in range
251        assert!(parse_range_ref("A0:B2").is_err());
252    }
253
254    /// **Validates: Requirements 7.5, 8.3**
255    #[test]
256    fn test_col_letter_to_index() {
257        assert_eq!(col_letter_to_index("A").unwrap(), 0);
258        assert_eq!(col_letter_to_index("B").unwrap(), 1);
259        assert_eq!(col_letter_to_index("Z").unwrap(), 25);
260        assert_eq!(col_letter_to_index("AA").unwrap(), 26);
261        assert_eq!(col_letter_to_index("AB").unwrap(), 27);
262        assert_eq!(col_letter_to_index("AZ").unwrap(), 51);
263        assert_eq!(col_letter_to_index("BA").unwrap(), 52);
264        assert_eq!(col_letter_to_index("XFD").unwrap(), 16383);
265
266        // Case insensitive
267        assert_eq!(col_letter_to_index("a").unwrap(), 0);
268        assert_eq!(col_letter_to_index("aa").unwrap(), 26);
269
270        // Empty string
271        assert!(col_letter_to_index("").is_err());
272
273        // Invalid character
274        assert!(col_letter_to_index("A1").is_err());
275
276        // Exceeds max column
277        assert!(col_letter_to_index("XFE").is_err());
278    }
279
280    /// **Validates: Requirements 7.5, 8.3**
281    #[test]
282    fn test_index_to_col_letter() {
283        assert_eq!(index_to_col_letter(0), "A");
284        assert_eq!(index_to_col_letter(1), "B");
285        assert_eq!(index_to_col_letter(25), "Z");
286        assert_eq!(index_to_col_letter(26), "AA");
287        assert_eq!(index_to_col_letter(27), "AB");
288        assert_eq!(index_to_col_letter(51), "AZ");
289        assert_eq!(index_to_col_letter(52), "BA");
290        assert_eq!(index_to_col_letter(16383), "XFD");
291    }
292}