1use sheetkit_xml::workbook::{DefinedName, DefinedNames, WorkbookXml};
8
9use crate::error::{Error, Result};
10
11const DEFINED_NAME_INVALID_CHARS: &[char] = &['\\', '/', '?', '*', '[', ']'];
13
14#[derive(Debug, Clone, PartialEq)]
16pub enum DefinedNameScope {
17 Workbook,
19 Sheet(u32),
21}
22
23#[derive(Debug, Clone)]
25pub struct DefinedNameInfo {
26 pub name: String,
27 pub value: String,
29 pub scope: DefinedNameScope,
30 pub comment: Option<String>,
31}
32
33fn 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
61fn 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
69fn 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
77pub 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 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
118pub 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
141pub 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 if defined_names.defined_names.is_empty() {
170 wb.defined_names = None;
171 }
172
173 Ok(())
174}
175
176pub 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 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 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 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_defined_name(
375 &mut wb,
376 "Total",
377 "Sheet1!$A$1",
378 DefinedNameScope::Workbook,
379 None,
380 )
381 .unwrap();
382
383 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 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}