Skip to main content

sheetkit_core/
defined_names.rs

1//! Defined names (named ranges) management.
2//!
3//! Provides functions to add, retrieve, update, delete, and list defined names
4//! in an Excel workbook. Defined names can be workbook-scoped (visible from all
5//! sheets) or sheet-scoped (visible only in the specified sheet).
6
7use sheetkit_xml::workbook::{DefinedName, DefinedNames, WorkbookXml};
8
9use crate::error::{Error, Result};
10
11/// Characters that are not allowed in defined names.
12const DEFINED_NAME_INVALID_CHARS: &[char] = &['\\', '/', '?', '*', '[', ']'];
13
14/// Scope of a defined name.
15#[derive(Debug, Clone, PartialEq)]
16pub enum DefinedNameScope {
17    /// Workbook-level scope (visible from all sheets).
18    Workbook,
19    /// Sheet-level scope (only visible in the specified sheet).
20    Sheet(u32),
21}
22
23/// Information about a defined name.
24#[derive(Debug, Clone)]
25pub struct DefinedNameInfo {
26    pub name: String,
27    /// The reference or formula, e.g. "Sheet1!$A$1:$D$10".
28    pub value: String,
29    pub scope: DefinedNameScope,
30    pub comment: Option<String>,
31}
32
33/// Validate a defined name.
34///
35/// A valid defined name must:
36/// - Be non-empty
37/// - Not start or end with whitespace
38/// - Not contain any of the characters `\ / ? * [ ]`
39fn validate_defined_name(name: &str) -> Result<()> {
40    if name.is_empty() {
41        return Err(Error::InvalidDefinedName(
42            "defined name cannot be empty".into(),
43        ));
44    }
45    if name != name.trim() {
46        return Err(Error::InvalidDefinedName(
47            "defined name cannot start or end with whitespace".into(),
48        ));
49    }
50    for ch in DEFINED_NAME_INVALID_CHARS {
51        if name.contains(*ch) {
52            return Err(Error::InvalidDefinedName(format!(
53                "defined name '{}' contains invalid character '{}'",
54                name, ch
55            )));
56        }
57    }
58    Ok(())
59}
60
61/// Convert a `DefinedNameScope` to the corresponding `local_sheet_id` value.
62fn scope_to_local_sheet_id(scope: &DefinedNameScope) -> Option<u32> {
63    match scope {
64        DefinedNameScope::Workbook => None,
65        DefinedNameScope::Sheet(id) => Some(*id),
66    }
67}
68
69/// Convert an optional `local_sheet_id` to a `DefinedNameScope`.
70fn local_sheet_id_to_scope(local_sheet_id: Option<u32>) -> DefinedNameScope {
71    match local_sheet_id {
72        None => DefinedNameScope::Workbook,
73        Some(id) => DefinedNameScope::Sheet(id),
74    }
75}
76
77/// Add or update a defined name in the workbook.
78///
79/// If a defined name with the same name and scope already exists, its value
80/// and comment are updated. Otherwise, a new entry is created.
81pub fn set_defined_name(
82    wb: &mut WorkbookXml,
83    name: &str,
84    value: &str,
85    scope: DefinedNameScope,
86    comment: Option<&str>,
87) -> Result<()> {
88    validate_defined_name(name)?;
89
90    let local_sheet_id = scope_to_local_sheet_id(&scope);
91
92    let defined_names = wb.defined_names.get_or_insert_with(|| DefinedNames {
93        defined_names: Vec::new(),
94    });
95
96    // Check if a name with the same name+scope already exists.
97    if let Some(existing) = defined_names
98        .defined_names
99        .iter_mut()
100        .find(|dn| dn.name == name && dn.local_sheet_id == local_sheet_id)
101    {
102        existing.value = value.to_string();
103        existing.comment = comment.map(|c| c.to_string());
104        return Ok(());
105    }
106
107    defined_names.defined_names.push(DefinedName {
108        name: name.to_string(),
109        local_sheet_id,
110        comment: comment.map(|c| c.to_string()),
111        hidden: None,
112        value: value.to_string(),
113    });
114
115    Ok(())
116}
117
118/// Get a defined name by name and scope.
119///
120/// Returns `None` if no matching defined name is found.
121pub fn get_defined_name(
122    wb: &WorkbookXml,
123    name: &str,
124    scope: DefinedNameScope,
125) -> Option<DefinedNameInfo> {
126    let defined_names = wb.defined_names.as_ref()?;
127    let local_sheet_id = scope_to_local_sheet_id(&scope);
128
129    defined_names
130        .defined_names
131        .iter()
132        .find(|dn| dn.name == name && dn.local_sheet_id == local_sheet_id)
133        .map(|dn| DefinedNameInfo {
134            name: dn.name.clone(),
135            value: dn.value.clone(),
136            scope: local_sheet_id_to_scope(dn.local_sheet_id),
137            comment: dn.comment.clone(),
138        })
139}
140
141/// Delete a defined name by name and scope.
142///
143/// Returns an error if the defined name does not exist.
144pub fn delete_defined_name(
145    wb: &mut WorkbookXml,
146    name: &str,
147    scope: DefinedNameScope,
148) -> Result<()> {
149    let local_sheet_id = scope_to_local_sheet_id(&scope);
150
151    let defined_names = wb
152        .defined_names
153        .as_mut()
154        .ok_or_else(|| Error::DefinedNameNotFound {
155            name: name.to_string(),
156        })?;
157
158    let idx = defined_names
159        .defined_names
160        .iter()
161        .position(|dn| dn.name == name && dn.local_sheet_id == local_sheet_id)
162        .ok_or_else(|| Error::DefinedNameNotFound {
163            name: name.to_string(),
164        })?;
165
166    defined_names.defined_names.remove(idx);
167
168    // Clean up: remove the container if empty.
169    if defined_names.defined_names.is_empty() {
170        wb.defined_names = None;
171    }
172
173    Ok(())
174}
175
176/// List all defined names in the workbook.
177pub fn get_all_defined_names(wb: &WorkbookXml) -> Vec<DefinedNameInfo> {
178    let Some(defined_names) = wb.defined_names.as_ref() else {
179        return Vec::new();
180    };
181
182    defined_names
183        .defined_names
184        .iter()
185        .map(|dn| DefinedNameInfo {
186            name: dn.name.clone(),
187            value: dn.value.clone(),
188            scope: local_sheet_id_to_scope(dn.local_sheet_id),
189            comment: dn.comment.clone(),
190        })
191        .collect()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    /// Create a minimal WorkbookXml for testing.
199    fn test_workbook() -> WorkbookXml {
200        WorkbookXml::default()
201    }
202
203    #[test]
204    fn test_set_defined_name_workbook_scope() {
205        let mut wb = test_workbook();
206        set_defined_name(
207            &mut wb,
208            "SalesData",
209            "Sheet1!$A$1:$D$10",
210            DefinedNameScope::Workbook,
211            None,
212        )
213        .unwrap();
214
215        let dn = wb.defined_names.as_ref().unwrap();
216        assert_eq!(dn.defined_names.len(), 1);
217        assert_eq!(dn.defined_names[0].name, "SalesData");
218        assert_eq!(dn.defined_names[0].value, "Sheet1!$A$1:$D$10");
219        assert!(dn.defined_names[0].local_sheet_id.is_none());
220    }
221
222    #[test]
223    fn test_set_defined_name_sheet_scope() {
224        let mut wb = test_workbook();
225        set_defined_name(
226            &mut wb,
227            "LocalRange",
228            "Sheet1!$B$2:$C$5",
229            DefinedNameScope::Sheet(0),
230            None,
231        )
232        .unwrap();
233
234        let dn = wb.defined_names.as_ref().unwrap();
235        assert_eq!(dn.defined_names.len(), 1);
236        assert_eq!(dn.defined_names[0].name, "LocalRange");
237        assert_eq!(dn.defined_names[0].local_sheet_id, Some(0));
238    }
239
240    #[test]
241    fn test_get_defined_name() {
242        let mut wb = test_workbook();
243        set_defined_name(
244            &mut wb,
245            "Revenue",
246            "Sheet1!$E$1:$E$100",
247            DefinedNameScope::Workbook,
248            None,
249        )
250        .unwrap();
251
252        let info = get_defined_name(&wb, "Revenue", DefinedNameScope::Workbook).unwrap();
253        assert_eq!(info.name, "Revenue");
254        assert_eq!(info.value, "Sheet1!$E$1:$E$100");
255        assert_eq!(info.scope, DefinedNameScope::Workbook);
256        assert!(info.comment.is_none());
257    }
258
259    #[test]
260    fn test_get_defined_name_not_found() {
261        let wb = test_workbook();
262        let result = get_defined_name(&wb, "NonExistent", DefinedNameScope::Workbook);
263        assert!(result.is_none());
264    }
265
266    #[test]
267    fn test_update_defined_name() {
268        let mut wb = test_workbook();
269        set_defined_name(
270            &mut wb,
271            "DataRange",
272            "Sheet1!$A$1:$A$10",
273            DefinedNameScope::Workbook,
274            None,
275        )
276        .unwrap();
277
278        // Update the same name with a new value.
279        set_defined_name(
280            &mut wb,
281            "DataRange",
282            "Sheet1!$A$1:$A$50",
283            DefinedNameScope::Workbook,
284            Some("Updated range"),
285        )
286        .unwrap();
287
288        let dn = wb.defined_names.as_ref().unwrap();
289        assert_eq!(dn.defined_names.len(), 1, "should not duplicate the entry");
290        assert_eq!(dn.defined_names[0].value, "Sheet1!$A$1:$A$50");
291        assert_eq!(
292            dn.defined_names[0].comment,
293            Some("Updated range".to_string())
294        );
295    }
296
297    #[test]
298    fn test_delete_defined_name() {
299        let mut wb = test_workbook();
300        set_defined_name(
301            &mut wb,
302            "ToDelete",
303            "Sheet1!$A$1",
304            DefinedNameScope::Workbook,
305            None,
306        )
307        .unwrap();
308        assert!(wb.defined_names.is_some());
309
310        delete_defined_name(&mut wb, "ToDelete", DefinedNameScope::Workbook).unwrap();
311        // Container should be cleaned up since it is now empty.
312        assert!(wb.defined_names.is_none());
313    }
314
315    #[test]
316    fn test_delete_defined_name_not_found() {
317        let mut wb = test_workbook();
318        let result = delete_defined_name(&mut wb, "Ghost", DefinedNameScope::Workbook);
319        assert!(result.is_err());
320        let err = result.unwrap_err();
321        assert!(
322            err.to_string().contains("Ghost"),
323            "error message should contain the name"
324        );
325    }
326
327    #[test]
328    fn test_get_all_defined_names() {
329        let mut wb = test_workbook();
330        set_defined_name(
331            &mut wb,
332            "Alpha",
333            "Sheet1!$A$1",
334            DefinedNameScope::Workbook,
335            None,
336        )
337        .unwrap();
338        set_defined_name(
339            &mut wb,
340            "Beta",
341            "Sheet1!$B$1",
342            DefinedNameScope::Sheet(0),
343            None,
344        )
345        .unwrap();
346        set_defined_name(
347            &mut wb,
348            "Gamma",
349            "Sheet1!$C$1",
350            DefinedNameScope::Sheet(1),
351            None,
352        )
353        .unwrap();
354
355        let all = get_all_defined_names(&wb);
356        assert_eq!(all.len(), 3);
357        assert_eq!(all[0].name, "Alpha");
358        assert_eq!(all[1].name, "Beta");
359        assert_eq!(all[2].name, "Gamma");
360    }
361
362    #[test]
363    fn test_get_all_defined_names_empty() {
364        let wb = test_workbook();
365        let all = get_all_defined_names(&wb);
366        assert!(all.is_empty());
367    }
368
369    #[test]
370    fn test_same_name_different_scopes() {
371        let mut wb = test_workbook();
372
373        // Set workbook-scoped name.
374        set_defined_name(
375            &mut wb,
376            "Total",
377            "Sheet1!$A$1",
378            DefinedNameScope::Workbook,
379            None,
380        )
381        .unwrap();
382
383        // Set sheet-scoped name with the same name.
384        set_defined_name(
385            &mut wb,
386            "Total",
387            "Sheet1!$B$1",
388            DefinedNameScope::Sheet(0),
389            None,
390        )
391        .unwrap();
392
393        let dn = wb.defined_names.as_ref().unwrap();
394        assert_eq!(dn.defined_names.len(), 2, "both scopes should coexist");
395
396        let wb_info = get_defined_name(&wb, "Total", DefinedNameScope::Workbook).unwrap();
397        assert_eq!(wb_info.value, "Sheet1!$A$1");
398
399        let sheet_info = get_defined_name(&wb, "Total", DefinedNameScope::Sheet(0)).unwrap();
400        assert_eq!(sheet_info.value, "Sheet1!$B$1");
401    }
402
403    #[test]
404    fn test_defined_name_with_comment() {
405        let mut wb = test_workbook();
406        set_defined_name(
407            &mut wb,
408            "Annotated",
409            "Sheet1!$A$1:$Z$100",
410            DefinedNameScope::Workbook,
411            Some("Main data area"),
412        )
413        .unwrap();
414
415        let info = get_defined_name(&wb, "Annotated", DefinedNameScope::Workbook).unwrap();
416        assert_eq!(info.comment, Some("Main data area".to_string()));
417    }
418
419    #[test]
420    fn test_invalid_defined_name_empty() {
421        let mut wb = test_workbook();
422        let result = set_defined_name(&mut wb, "", "Sheet1!$A$1", DefinedNameScope::Workbook, None);
423        assert!(result.is_err());
424        assert!(result.unwrap_err().to_string().contains("empty"));
425    }
426
427    #[test]
428    fn test_invalid_defined_name_leading_whitespace() {
429        let mut wb = test_workbook();
430        let result = set_defined_name(
431            &mut wb,
432            " Leading",
433            "Sheet1!$A$1",
434            DefinedNameScope::Workbook,
435            None,
436        );
437        assert!(result.is_err());
438        assert!(result.unwrap_err().to_string().contains("whitespace"));
439    }
440
441    #[test]
442    fn test_invalid_defined_name_trailing_whitespace() {
443        let mut wb = test_workbook();
444        let result = set_defined_name(
445            &mut wb,
446            "Trailing ",
447            "Sheet1!$A$1",
448            DefinedNameScope::Workbook,
449            None,
450        );
451        assert!(result.is_err());
452        assert!(result.unwrap_err().to_string().contains("whitespace"));
453    }
454
455    #[test]
456    fn test_invalid_defined_name_special_chars() {
457        let mut wb = test_workbook();
458        for ch in DEFINED_NAME_INVALID_CHARS {
459            let name = format!("Bad{}Name", ch);
460            let result = set_defined_name(
461                &mut wb,
462                &name,
463                "Sheet1!$A$1",
464                DefinedNameScope::Workbook,
465                None,
466            );
467            assert!(result.is_err(), "should reject '{}' in name", ch);
468            assert!(
469                result
470                    .unwrap_err()
471                    .to_string()
472                    .contains("invalid character"),
473                "error for '{}' should mention invalid character",
474                ch
475            );
476        }
477    }
478
479    #[test]
480    fn test_delete_one_keeps_others() {
481        let mut wb = test_workbook();
482        set_defined_name(
483            &mut wb,
484            "Keep",
485            "Sheet1!$A$1",
486            DefinedNameScope::Workbook,
487            None,
488        )
489        .unwrap();
490        set_defined_name(
491            &mut wb,
492            "Remove",
493            "Sheet1!$B$1",
494            DefinedNameScope::Workbook,
495            None,
496        )
497        .unwrap();
498
499        delete_defined_name(&mut wb, "Remove", DefinedNameScope::Workbook).unwrap();
500
501        let dn = wb.defined_names.as_ref().unwrap();
502        assert_eq!(dn.defined_names.len(), 1);
503        assert_eq!(dn.defined_names[0].name, "Keep");
504    }
505
506    #[test]
507    fn test_delete_wrong_scope_not_found() {
508        let mut wb = test_workbook();
509        set_defined_name(
510            &mut wb,
511            "ScopedName",
512            "Sheet1!$A$1",
513            DefinedNameScope::Sheet(0),
514            None,
515        )
516        .unwrap();
517
518        // Deleting with workbook scope should fail because it only exists at sheet scope.
519        let result = delete_defined_name(&mut wb, "ScopedName", DefinedNameScope::Workbook);
520        assert!(result.is_err());
521    }
522
523    #[test]
524    fn test_xml_roundtrip_with_defined_names() {
525        let mut wb = test_workbook();
526        set_defined_name(
527            &mut wb,
528            "RangeA",
529            "Sheet1!$A$1:$A$10",
530            DefinedNameScope::Workbook,
531            Some("First range"),
532        )
533        .unwrap();
534        set_defined_name(
535            &mut wb,
536            "RangeB",
537            "Sheet1!$B$1:$B$5",
538            DefinedNameScope::Sheet(0),
539            None,
540        )
541        .unwrap();
542
543        let xml = quick_xml::se::to_string(&wb).unwrap();
544        let parsed: WorkbookXml = quick_xml::de::from_str(&xml).unwrap();
545
546        let all = get_all_defined_names(&parsed);
547        assert_eq!(all.len(), 2);
548        assert_eq!(all[0].name, "RangeA");
549        assert_eq!(all[0].value, "Sheet1!$A$1:$A$10");
550        assert_eq!(all[0].scope, DefinedNameScope::Workbook);
551        assert_eq!(all[0].comment, Some("First range".to_string()));
552        assert_eq!(all[1].name, "RangeB");
553        assert_eq!(all[1].value, "Sheet1!$B$1:$B$5");
554        assert_eq!(all[1].scope, DefinedNameScope::Sheet(0));
555        assert!(all[1].comment.is_none());
556    }
557}