Skip to main content

formualizer_eval/engine/
sheet_registry.rs

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