Skip to main content

formualizer_eval/engine/
sheet_registry.rs

1use std::collections::HashMap;
2
3use crate::SheetId;
4
5#[derive(Default, Debug)]
6pub struct SheetRegistry {
7    id_by_name: HashMap<String, SheetId>,
8    name_by_id: Vec<String>,
9}
10
11impl SheetRegistry {
12    pub fn new() -> Self {
13        SheetRegistry::default()
14    }
15
16    pub fn id_for(&mut self, name: &str) -> SheetId {
17        // Sheet names are CASE-INSENSITIVE (Excel behavior): index by the lowercased name, but
18        // keep the original casing in name_by_id for display.
19        let key = name.to_lowercase();
20        if let Some(&id) = self.id_by_name.get(&key) {
21            return id;
22        }
23
24        let id = self.name_by_id.len() as SheetId;
25        self.name_by_id.push(name.to_string());
26        self.id_by_name.insert(key, id);
27        id
28    }
29
30    pub fn name(&self, id: SheetId) -> &str {
31        if (id as usize) < self.name_by_id.len() {
32            &self.name_by_id[id as usize]
33        } else {
34            ""
35        }
36    }
37
38    pub fn get_id(&self, name: &str) -> Option<SheetId> {
39        // Case-insensitive (Excel): e.g. INDIRECT("Config!B8") must find the sheet named "CONFIG".
40        self.id_by_name.get(&name.to_lowercase()).copied()
41    }
42
43    /// Count active sheets without cloning sheet names.
44    pub fn active_len(&self) -> usize {
45        self.name_by_id
46            .iter()
47            .filter(|name| !name.is_empty())
48            .count()
49    }
50
51    /// Excel-style 1-based active sheet position for a sheet id.
52    pub fn active_position_by_id(&self, id: SheetId) -> Option<usize> {
53        let idx = id as usize;
54        if idx >= self.name_by_id.len() || self.name_by_id[idx].is_empty() {
55            return None;
56        }
57        Some(
58            self.name_by_id
59                .iter()
60                .take(idx + 1)
61                .filter(|name| !name.is_empty())
62                .count(),
63        )
64    }
65
66    /// Excel-style 1-based active sheet position for a sheet name.
67    pub fn active_position(&self, name: &str) -> Option<usize> {
68        self.get_id(name)
69            .and_then(|id| self.active_position_by_id(id))
70    }
71
72    /// Inclusive count of active sheets between two sheet names.
73    pub fn active_span_len(&self, first: &str, last: &str) -> Option<usize> {
74        let a = self.active_position(first)?;
75        let b = self.active_position(last)?;
76        Some(a.abs_diff(b) + 1)
77    }
78
79    /// Get all sheet IDs and names (excluding removed sheets)
80    pub fn all_sheets(&self) -> Vec<(SheetId, String)> {
81        self.name_by_id
82            .iter()
83            .enumerate()
84            .filter(|(_, name)| !name.is_empty())
85            .map(|(id, name)| (id as SheetId, name.clone()))
86            .collect()
87    }
88
89    /// Remove a sheet from the registry
90    /// Note: This doesn't actually free the ID, it just marks it as removed
91    pub fn remove(&mut self, id: SheetId) -> Result<(), formualizer_common::ExcelError> {
92        use formualizer_common::{ExcelError, ExcelErrorKind};
93
94        // Check if the ID exists
95        if id as usize >= self.name_by_id.len() {
96            return Err(
97                ExcelError::new(ExcelErrorKind::Value).with_message("Sheet ID does not exist")
98            );
99        }
100
101        // Get the name to remove from id_by_name
102        let name = self.name_by_id[id as usize].clone();
103        if name.is_empty() {
104            // Already removed
105            return Ok(());
106        }
107
108        // Remove from id_by_name mapping (case-insensitive key)
109        self.id_by_name.remove(&name.to_lowercase());
110
111        // Mark as removed in name_by_id (we can't actually remove it to preserve IDs)
112        self.name_by_id[id as usize] = String::new();
113
114        Ok(())
115    }
116
117    /// Rename a sheet
118    pub fn rename(
119        &mut self,
120        id: SheetId,
121        new_name: &str,
122    ) -> Result<(), formualizer_common::ExcelError> {
123        use formualizer_common::{ExcelError, ExcelErrorKind};
124
125        // Check if the ID exists
126        if id as usize >= self.name_by_id.len() {
127            return Err(
128                ExcelError::new(ExcelErrorKind::Value).with_message("Sheet ID does not exist")
129            );
130        }
131
132        // Get the old name
133        let old_name = self.name_by_id[id as usize].clone();
134
135        // Check if new name is already taken by another sheet (case-insensitive)
136        if let Some(&existing_id) = self.id_by_name.get(&new_name.to_lowercase())
137            && existing_id != id
138        {
139            return Err(ExcelError::new(ExcelErrorKind::Value)
140                .with_message(format!("Sheet name '{new_name}' already exists")));
141        }
142
143        // Remove old name mapping
144        self.id_by_name.remove(&old_name.to_lowercase());
145
146        // Update to new name
147        self.name_by_id[id as usize] = new_name.to_string();
148        self.id_by_name.insert(new_name.to_lowercase(), id);
149
150        Ok(())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn sheet_names_are_case_insensitive() {
160        let mut reg = SheetRegistry::new();
161        let id = reg.id_for("CONFIG");
162        // Excel resolves sheet names case-insensitively: all casings map to the same sheet.
163        assert_eq!(reg.get_id("CONFIG"), Some(id));
164        assert_eq!(reg.get_id("Config"), Some(id));
165        assert_eq!(reg.get_id("config"), Some(id));
166        // id_for must reuse the same sheet regardless of casing (no duplicate sheet created).
167        assert_eq!(reg.id_for("Config"), id);
168        // Original casing is preserved for display.
169        assert_eq!(reg.name(id), "CONFIG");
170    }
171}