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 { start: None, end: None }
88    }
89
90    /// Expand to contain both sides' used ranges.
91    pub(crate) fn union(
92        old_start: Option<(u32, u32)>,
93        old_end: Option<(u32, u32)>,
94        new_start: Option<(u32, u32)>,
95        new_end: Option<(u32, u32)>,
96    ) -> Self {
97        let start = match (old_start, new_start) {
98            (None, None) => None,
99            (Some(a), None) | (None, Some(a)) => Some(a),
100            (Some((ar, ac)), Some((br, bc))) => Some((ar.min(br), ac.min(bc))),
101        };
102        let end = match (old_end, new_end) {
103            (None, None) => None,
104            (Some(a), None) | (None, Some(a)) => Some(a),
105            (Some((ar, ac)), Some((br, bc))) => Some((ar.max(br), ac.max(bc))),
106        };
107        Self { start, end }
108    }
109}
110
111// ---------------------------------------------------------------------------
112// A1 encoding helpers
113// ---------------------------------------------------------------------------
114
115/// Convert a 1-based column index to an Excel column label (`1` → `"A"`,
116/// `16384` → `"XFD"`).
117///
118/// Panics (debug) if `col == 0`.
119pub fn col_to_label(mut col: u32) -> String {
120    debug_assert!(col > 0, "col must be 1-based");
121    let mut bytes = Vec::with_capacity(3);
122    while col > 0 {
123        let rem = (col - 1) % 26;
124        bytes.push(b'A' + rem as u8);
125        col = (col - 1) / 26;
126    }
127    bytes.reverse();
128    // Safety: only ASCII uppercase letters were pushed.
129    unsafe { String::from_utf8_unchecked(bytes) }
130}
131
132/// Convert a 1-based `(row, col)` pair to an Excel A1 address string.
133pub fn cell_pos_to_a1(row: u32, col: u32) -> String {
134    format!("{}{}", col_to_label(col), row)
135}
136
137// ---------------------------------------------------------------------------
138// Tests
139// ---------------------------------------------------------------------------
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn col_label_single_letters() {
147        assert_eq!(col_to_label(1), "A");
148        assert_eq!(col_to_label(26), "Z");
149    }
150
151    #[test]
152    fn col_label_double_letters() {
153        assert_eq!(col_to_label(27), "AA");
154        assert_eq!(col_to_label(52), "AZ");
155        assert_eq!(col_to_label(53), "BA");
156        assert_eq!(col_to_label(702), "ZZ");
157    }
158
159    #[test]
160    fn col_label_triple_letters() {
161        assert_eq!(col_to_label(703), "AAA");
162        assert_eq!(col_to_label(16_384), MAX_COL_LABEL);
163    }
164
165    #[test]
166    fn cell_address_new_valid() {
167        let addr = CellAddress::new(1, 1).unwrap();
168        assert_eq!(addr.a1, "A1");
169        assert_eq!(addr.row, 1);
170        assert_eq!(addr.col, 1);
171
172        let last = CellAddress::new(MAX_ROW, MAX_COL).unwrap();
173        assert_eq!(last.a1, "XFD1048576");
174    }
175
176    #[test]
177    fn cell_address_new_out_of_bounds() {
178        assert!(CellAddress::new(0, 1).is_none());
179        assert!(CellAddress::new(1, 0).is_none());
180        assert!(CellAddress::new(MAX_ROW + 1, 1).is_none());
181        assert!(CellAddress::new(1, MAX_COL + 1).is_none());
182    }
183
184    #[test]
185    fn sort_order_is_row_col_not_a1_lexicographic() {
186        let a2 = CellAddress::new(2, 1).unwrap();
187        let a10 = CellAddress::new(10, 1).unwrap();
188        assert!(a2 < a10, "A10 must sort after A2 (numeric row, not lex)");
189    }
190}