Skip to main content

sheets_diff/
address.rs

1//! Cell addressing: A1 label encoding, 1-based coordinates, Excel bounds.
2//!
3//! Excel limits: rows 1–1_048_576, columns 1–16_384 (A–XFD).
4
5use std::fmt;
6
7#[cfg(feature = "serde")]
8use serde::Serialize;
9
10// ---------------------------------------------------------------------------
11// Public constants
12// ---------------------------------------------------------------------------
13
14/// Maximum valid 1-based row index in an Excel .xlsx workbook.
15pub const MAX_ROW: u32 = 1_048_576;
16/// Maximum valid 1-based column index in an Excel .xlsx workbook.
17pub const MAX_COL: u32 = 16_384;
18/// The A1 label of the last valid Excel column (column 16384).
19pub const MAX_COL_LABEL: &str = "XFD";
20
21// ---------------------------------------------------------------------------
22// CellAddress
23// ---------------------------------------------------------------------------
24
25/// The address of a single cell, carrying both numeric coordinates and the A1
26/// label.
27///
28/// - `row` and `col` are **1-based**.
29/// - `a1` is the canonical Excel A1 string (e.g. `"XFD1048576"`).
30/// - Sorting must use `(row, col)`, never lexicographic A1 order.
31#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
32#[cfg_attr(feature = "serde", derive(Serialize))]
33pub struct CellAddress {
34    pub row: u32,
35    pub col: u32,
36    pub a1: String,
37}
38
39impl CellAddress {
40    /// Construct a `CellAddress` from 1-based `(row, col)`.
41    ///
42    /// Returns `None` when `row` or `col` is zero or exceeds the Excel limit.
43    pub fn new(row: u32, col: u32) -> Option<Self> {
44        if row == 0 || row > MAX_ROW || col == 0 || col > MAX_COL {
45            return None;
46        }
47        let a1 = format!("{}{}", col_to_label(col), row);
48        Some(Self { row, col, a1 })
49    }
50
51    /// Construct without bounds checking.  Caller asserts validity.
52    #[inline]
53    pub(crate) fn new_unchecked(row: u32, col: u32) -> Self {
54        Self {
55            a1: format!("{}{}", col_to_label(col), row),
56            row,
57            col,
58        }
59    }
60}
61
62impl fmt::Display for CellAddress {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(&self.a1)
65    }
66}
67
68// ---------------------------------------------------------------------------
69// ComparedRange
70// ---------------------------------------------------------------------------
71
72/// The bounding rectangle that was compared for a sheet pair.
73///
74/// `None` on either side means that side was empty (no used range).
75#[derive(Clone, Debug)]
76#[cfg_attr(feature = "serde", derive(Serialize))]
77#[derive(PartialEq)]
78pub struct ComparedRange {
79    /// Inclusive top-left, 1-based.
80    pub start: Option<(u32, u32)>,
81    /// Inclusive bottom-right, 1-based.
82    pub end: Option<(u32, u32)>,
83}
84
85impl ComparedRange {
86    pub fn empty() -> Self {
87        Self {
88            start: None,
89            end: None,
90        }
91    }
92
93    /// Expand to contain both sides' used ranges.
94    pub fn union(
95        old_start: Option<(u32, u32)>,
96        old_end: Option<(u32, u32)>,
97        new_start: Option<(u32, u32)>,
98        new_end: Option<(u32, u32)>,
99    ) -> Self {
100        let start = match (old_start, new_start) {
101            (None, None) => None,
102            (Some(a), None) | (None, Some(a)) => Some(a),
103            (Some((ar, ac)), Some((br, bc))) => Some((ar.min(br), ac.min(bc))),
104        };
105        let end = match (old_end, new_end) {
106            (None, None) => None,
107            (Some(a), None) | (None, Some(a)) => Some(a),
108            (Some((ar, ac)), Some((br, bc))) => Some((ar.max(br), ac.max(bc))),
109        };
110        Self { start, end }
111    }
112}
113
114// ---------------------------------------------------------------------------
115// A1 encoding helpers
116// ---------------------------------------------------------------------------
117
118/// Convert a 1-based column index to an Excel column label (`1` → `"A"`,
119/// `16384` → `"XFD"`).
120///
121/// Panics (debug) if `col == 0`.
122pub fn col_to_label(mut col: u32) -> String {
123    debug_assert!(col > 0, "col must be 1-based");
124    let mut bytes = Vec::with_capacity(3);
125    while col > 0 {
126        let rem = (col - 1) % 26;
127        bytes.push(b'A' + rem as u8);
128        col = (col - 1) / 26;
129    }
130    bytes.reverse();
131    // Only ASCII uppercase letters were pushed, so this can never fail —
132    // RFC-035 §5.6: no `unsafe` buys anything the safe constructor doesn't.
133    String::from_utf8(bytes).expect("col_to_label only pushes ASCII uppercase bytes")
134}
135
136/// Convert a 1-based `(row, col)` pair to an Excel A1 address string.
137pub fn cell_pos_to_a1(row: u32, col: u32) -> String {
138    format!("{}{}", col_to_label(col), row)
139}
140
141// ---------------------------------------------------------------------------
142// Tests
143// ---------------------------------------------------------------------------
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn col_label_single_letters() {
151        assert_eq!(col_to_label(1), "A");
152        assert_eq!(col_to_label(26), "Z");
153    }
154
155    #[test]
156    fn col_label_double_letters() {
157        assert_eq!(col_to_label(27), "AA");
158        assert_eq!(col_to_label(52), "AZ");
159        assert_eq!(col_to_label(53), "BA");
160        assert_eq!(col_to_label(702), "ZZ");
161    }
162
163    #[test]
164    fn col_label_triple_letters() {
165        assert_eq!(col_to_label(703), "AAA");
166        assert_eq!(col_to_label(16_384), MAX_COL_LABEL);
167    }
168
169    #[test]
170    fn cell_address_new_valid() {
171        let addr = CellAddress::new(1, 1).unwrap();
172        assert_eq!(addr.a1, "A1");
173        assert_eq!(addr.row, 1);
174        assert_eq!(addr.col, 1);
175
176        let last = CellAddress::new(MAX_ROW, MAX_COL).unwrap();
177        assert_eq!(last.a1, "XFD1048576");
178    }
179
180    #[test]
181    fn cell_address_new_out_of_bounds() {
182        assert!(CellAddress::new(0, 1).is_none());
183        assert!(CellAddress::new(1, 0).is_none());
184        assert!(CellAddress::new(MAX_ROW + 1, 1).is_none());
185        assert!(CellAddress::new(1, MAX_COL + 1).is_none());
186    }
187
188    #[test]
189    fn sort_order_is_row_col_not_a1_lexicographic() {
190        let a2 = CellAddress::new(2, 1).unwrap();
191        let a10 = CellAddress::new(10, 1).unwrap();
192        assert!(a2 < a10, "A10 must sort after A2 (numeric row, not lex)");
193    }
194}