Skip to main content

sheetkit_core/workbook/
features.rs

1use super::*;
2
3impl Workbook {
4    /// Add a data validation rule to a sheet.
5    pub fn add_data_validation(
6        &mut self,
7        sheet: &str,
8        config: &DataValidationConfig,
9    ) -> Result<()> {
10        let ws = self.worksheet_mut(sheet)?;
11        crate::validation::add_validation(ws, config)
12    }
13
14    /// Get all data validation rules for a sheet.
15    pub fn get_data_validations(&self, sheet: &str) -> Result<Vec<DataValidationConfig>> {
16        let ws = self.worksheet_ref(sheet)?;
17        Ok(crate::validation::get_validations(ws))
18    }
19
20    /// Remove a data validation rule matching the given cell range from a sheet.
21    pub fn remove_data_validation(&mut self, sheet: &str, sqref: &str) -> Result<()> {
22        let ws = self.worksheet_mut(sheet)?;
23        crate::validation::remove_validation(ws, sqref)
24    }
25
26    /// Set conditional formatting rules on a cell range of a sheet.
27    pub fn set_conditional_format(
28        &mut self,
29        sheet: &str,
30        sqref: &str,
31        rules: &[ConditionalFormatRule],
32    ) -> Result<()> {
33        let idx = self.sheet_index(sheet)?;
34        let ws = &mut self.worksheets[idx].1;
35        crate::conditional::set_conditional_format(ws, &mut self.stylesheet, sqref, rules)
36    }
37
38    /// Get all conditional formatting rules for a sheet.
39    ///
40    /// Returns a list of `(sqref, rules)` pairs.
41    pub fn get_conditional_formats(
42        &self,
43        sheet: &str,
44    ) -> Result<Vec<(String, Vec<ConditionalFormatRule>)>> {
45        let ws = self.worksheet_ref(sheet)?;
46        Ok(crate::conditional::get_conditional_formats(
47            ws,
48            &self.stylesheet,
49        ))
50    }
51
52    /// Delete conditional formatting rules for a specific cell range on a sheet.
53    pub fn delete_conditional_format(&mut self, sheet: &str, sqref: &str) -> Result<()> {
54        let ws = self.worksheet_mut(sheet)?;
55        crate::conditional::delete_conditional_format(ws, sqref)
56    }
57
58    /// Add a comment to a cell on the given sheet.
59    ///
60    /// A VML drawing part is generated automatically when saving so that
61    /// the comment renders correctly in Excel.
62    pub fn add_comment(&mut self, sheet: &str, config: &CommentConfig) -> Result<()> {
63        let idx = self.sheet_index(sheet)?;
64        crate::comment::add_comment(&mut self.sheet_comments[idx], config);
65        // Invalidate cached VML so save() regenerates it from current comments.
66        if idx < self.sheet_vml.len() {
67            self.sheet_vml[idx] = None;
68        }
69        Ok(())
70    }
71
72    /// Get all comments for a sheet.
73    pub fn get_comments(&self, sheet: &str) -> Result<Vec<CommentConfig>> {
74        let idx = self.sheet_index(sheet)?;
75        Ok(crate::comment::get_all_comments(&self.sheet_comments[idx]))
76    }
77
78    /// Remove a comment from a cell on the given sheet.
79    ///
80    /// When the last comment on a sheet is removed, the VML drawing part is
81    /// cleaned up automatically during save.
82    pub fn remove_comment(&mut self, sheet: &str, cell: &str) -> Result<()> {
83        let idx = self.sheet_index(sheet)?;
84        crate::comment::remove_comment(&mut self.sheet_comments[idx], cell);
85        // Invalidate cached VML so save() regenerates or omits it.
86        if idx < self.sheet_vml.len() {
87            self.sheet_vml[idx] = None;
88        }
89        Ok(())
90    }
91
92    /// Set an auto-filter on a sheet for the given cell range.
93    pub fn set_auto_filter(&mut self, sheet: &str, range: &str) -> Result<()> {
94        let ws = self.worksheet_mut(sheet)?;
95        crate::table::set_auto_filter(ws, range)
96    }
97
98    /// Remove the auto-filter from a sheet.
99    pub fn remove_auto_filter(&mut self, sheet: &str) -> Result<()> {
100        let ws = self.worksheet_mut(sheet)?;
101        crate::table::remove_auto_filter(ws);
102        Ok(())
103    }
104
105    /// Set freeze panes on a sheet.
106    ///
107    /// The cell reference indicates the top-left cell of the scrollable area.
108    /// For example, `"A2"` freezes row 1, `"B1"` freezes column A, and `"B2"`
109    /// freezes both row 1 and column A.
110    pub fn set_panes(&mut self, sheet: &str, cell: &str) -> Result<()> {
111        let ws = self.worksheet_mut(sheet)?;
112        crate::sheet::set_panes(ws, cell)
113    }
114
115    /// Remove any freeze or split panes from a sheet.
116    pub fn unset_panes(&mut self, sheet: &str) -> Result<()> {
117        let ws = self.worksheet_mut(sheet)?;
118        crate::sheet::unset_panes(ws);
119        Ok(())
120    }
121
122    /// Get the current freeze pane cell reference for a sheet, if any.
123    ///
124    /// Returns the top-left cell of the unfrozen area (e.g., `"A2"` if row 1
125    /// is frozen), or `None` if no panes are configured.
126    pub fn get_panes(&self, sheet: &str) -> Result<Option<String>> {
127        let ws = self.worksheet_ref(sheet)?;
128        Ok(crate::sheet::get_panes(ws))
129    }
130
131    /// Set page margins on a sheet.
132    pub fn set_page_margins(
133        &mut self,
134        sheet: &str,
135        margins: &crate::page_layout::PageMarginsConfig,
136    ) -> Result<()> {
137        let ws = self.worksheet_mut(sheet)?;
138        crate::page_layout::set_page_margins(ws, margins)
139    }
140
141    /// Get page margins for a sheet, returning Excel defaults if not set.
142    pub fn get_page_margins(&self, sheet: &str) -> Result<crate::page_layout::PageMarginsConfig> {
143        let ws = self.worksheet_ref(sheet)?;
144        Ok(crate::page_layout::get_page_margins(ws))
145    }
146
147    /// Set page setup options (orientation, paper size, scale, fit-to-page).
148    ///
149    /// Only non-`None` parameters are applied; existing values for `None`
150    /// parameters are preserved.
151    pub fn set_page_setup(
152        &mut self,
153        sheet: &str,
154        orientation: Option<crate::page_layout::Orientation>,
155        paper_size: Option<crate::page_layout::PaperSize>,
156        scale: Option<u32>,
157        fit_to_width: Option<u32>,
158        fit_to_height: Option<u32>,
159    ) -> Result<()> {
160        let ws = self.worksheet_mut(sheet)?;
161        crate::page_layout::set_page_setup(
162            ws,
163            orientation,
164            paper_size,
165            scale,
166            fit_to_width,
167            fit_to_height,
168        )
169    }
170
171    /// Get the page orientation for a sheet.
172    pub fn get_orientation(&self, sheet: &str) -> Result<Option<crate::page_layout::Orientation>> {
173        let ws = self.worksheet_ref(sheet)?;
174        Ok(crate::page_layout::get_orientation(ws))
175    }
176
177    /// Get the paper size for a sheet.
178    pub fn get_paper_size(&self, sheet: &str) -> Result<Option<crate::page_layout::PaperSize>> {
179        let ws = self.worksheet_ref(sheet)?;
180        Ok(crate::page_layout::get_paper_size(ws))
181    }
182
183    /// Get scale, fit-to-width, and fit-to-height values for a sheet.
184    ///
185    /// Returns `(scale, fit_to_width, fit_to_height)`, each `None` if not set.
186    pub fn get_page_setup_details(
187        &self,
188        sheet: &str,
189    ) -> Result<(Option<u32>, Option<u32>, Option<u32>)> {
190        let ws = self.worksheet_ref(sheet)?;
191        Ok((
192            crate::page_layout::get_scale(ws),
193            crate::page_layout::get_fit_to_width(ws),
194            crate::page_layout::get_fit_to_height(ws),
195        ))
196    }
197
198    /// Set header and footer text for printing.
199    pub fn set_header_footer(
200        &mut self,
201        sheet: &str,
202        header: Option<&str>,
203        footer: Option<&str>,
204    ) -> Result<()> {
205        let ws = self.worksheet_mut(sheet)?;
206        crate::page_layout::set_header_footer(ws, header, footer)
207    }
208
209    /// Get the header and footer text for a sheet.
210    pub fn get_header_footer(&self, sheet: &str) -> Result<(Option<String>, Option<String>)> {
211        let ws = self.worksheet_ref(sheet)?;
212        Ok(crate::page_layout::get_header_footer(ws))
213    }
214
215    /// Set print options on a sheet.
216    pub fn set_print_options(
217        &mut self,
218        sheet: &str,
219        grid_lines: Option<bool>,
220        headings: Option<bool>,
221        h_centered: Option<bool>,
222        v_centered: Option<bool>,
223    ) -> Result<()> {
224        let ws = self.worksheet_mut(sheet)?;
225        crate::page_layout::set_print_options(ws, grid_lines, headings, h_centered, v_centered)
226    }
227
228    /// Get print options for a sheet.
229    ///
230    /// Returns `(grid_lines, headings, horizontal_centered, vertical_centered)`.
231    #[allow(clippy::type_complexity)]
232    pub fn get_print_options(
233        &self,
234        sheet: &str,
235    ) -> Result<(Option<bool>, Option<bool>, Option<bool>, Option<bool>)> {
236        let ws = self.worksheet_ref(sheet)?;
237        Ok(crate::page_layout::get_print_options(ws))
238    }
239
240    /// Insert a horizontal page break before the given 1-based row.
241    pub fn insert_page_break(&mut self, sheet: &str, row: u32) -> Result<()> {
242        let ws = self.worksheet_mut(sheet)?;
243        crate::page_layout::insert_page_break(ws, row)
244    }
245
246    /// Remove a horizontal page break at the given 1-based row.
247    pub fn remove_page_break(&mut self, sheet: &str, row: u32) -> Result<()> {
248        let ws = self.worksheet_mut(sheet)?;
249        crate::page_layout::remove_page_break(ws, row)
250    }
251
252    /// Get all row page break positions (1-based row numbers).
253    pub fn get_page_breaks(&self, sheet: &str) -> Result<Vec<u32>> {
254        let ws = self.worksheet_ref(sheet)?;
255        Ok(crate::page_layout::get_page_breaks(ws))
256    }
257
258    /// Set a hyperlink on a cell.
259    ///
260    /// For external URLs and email links, a relationship entry is created in
261    /// the worksheet's `.rels` file. Internal sheet references use only the
262    /// `location` attribute without a relationship.
263    pub fn set_cell_hyperlink(
264        &mut self,
265        sheet: &str,
266        cell: &str,
267        link: crate::hyperlink::HyperlinkType,
268        display: Option<&str>,
269        tooltip: Option<&str>,
270    ) -> Result<()> {
271        let sheet_idx = self.sheet_index(sheet)?;
272        let ws = &mut self.worksheets[sheet_idx].1;
273        let rels = self
274            .worksheet_rels
275            .entry(sheet_idx)
276            .or_insert_with(|| Relationships {
277                xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
278                relationships: vec![],
279            });
280        crate::hyperlink::set_cell_hyperlink(ws, rels, cell, &link, display, tooltip)
281    }
282
283    /// Get hyperlink information for a cell.
284    ///
285    /// Returns `None` if the cell has no hyperlink.
286    pub fn get_cell_hyperlink(
287        &self,
288        sheet: &str,
289        cell: &str,
290    ) -> Result<Option<crate::hyperlink::HyperlinkInfo>> {
291        let sheet_idx = self.sheet_index(sheet)?;
292        let ws = &self.worksheets[sheet_idx].1;
293        let empty_rels = Relationships {
294            xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
295            relationships: vec![],
296        };
297        let rels = self.worksheet_rels.get(&sheet_idx).unwrap_or(&empty_rels);
298        crate::hyperlink::get_cell_hyperlink(ws, rels, cell)
299    }
300
301    /// Delete a hyperlink from a cell.
302    ///
303    /// Removes both the hyperlink element from the worksheet XML and any
304    /// associated relationship entry.
305    pub fn delete_cell_hyperlink(&mut self, sheet: &str, cell: &str) -> Result<()> {
306        let sheet_idx = self.sheet_index(sheet)?;
307        let ws = &mut self.worksheets[sheet_idx].1;
308        let rels = self
309            .worksheet_rels
310            .entry(sheet_idx)
311            .or_insert_with(|| Relationships {
312                xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
313                relationships: vec![],
314            });
315        crate::hyperlink::delete_cell_hyperlink(ws, rels, cell)
316    }
317
318    /// Protect the workbook structure and/or windows.
319    pub fn protect_workbook(&mut self, config: WorkbookProtectionConfig) {
320        let password_hash = config.password.as_ref().map(|p| {
321            let hash = crate::protection::legacy_password_hash(p);
322            format!("{:04X}", hash)
323        });
324        self.workbook_xml.workbook_protection = Some(WorkbookProtection {
325            workbook_password: password_hash,
326            lock_structure: if config.lock_structure {
327                Some(true)
328            } else {
329                None
330            },
331            lock_windows: if config.lock_windows {
332                Some(true)
333            } else {
334                None
335            },
336            revisions_password: None,
337            lock_revision: if config.lock_revision {
338                Some(true)
339            } else {
340                None
341            },
342        });
343    }
344
345    /// Remove workbook protection.
346    pub fn unprotect_workbook(&mut self) {
347        self.workbook_xml.workbook_protection = None;
348    }
349
350    /// Check if the workbook is protected.
351    pub fn is_workbook_protected(&self) -> bool {
352        self.workbook_xml.workbook_protection.is_some()
353    }
354
355    /// Resolve a theme color by index (0-11) with optional tint.
356    /// Returns the ARGB hex string (e.g. "FF4472C4") or None if the index is out of range.
357    pub fn get_theme_color(&self, index: u32, tint: Option<f64>) -> Option<String> {
358        crate::theme::resolve_theme_color(&self.theme_colors, index, tint)
359    }
360
361    /// Add or update a defined name in the workbook.
362    ///
363    /// If `scope` is `None`, the name is workbook-scoped (visible from all sheets).
364    /// If `scope` is `Some(sheet_name)`, it is sheet-scoped using the sheet's 0-based index.
365    /// If a name with the same name and scope already exists, its value and comment are updated.
366    pub fn set_defined_name(
367        &mut self,
368        name: &str,
369        value: &str,
370        scope: Option<&str>,
371        comment: Option<&str>,
372    ) -> Result<()> {
373        let dn_scope = self.resolve_defined_name_scope(scope)?;
374        crate::defined_names::set_defined_name(
375            &mut self.workbook_xml,
376            name,
377            value,
378            dn_scope,
379            comment,
380        )
381    }
382
383    /// Get a defined name by name and scope.
384    ///
385    /// If `scope` is `None`, looks for a workbook-scoped name.
386    /// If `scope` is `Some(sheet_name)`, looks for a sheet-scoped name.
387    /// Returns `None` if no matching defined name is found.
388    pub fn get_defined_name(
389        &self,
390        name: &str,
391        scope: Option<&str>,
392    ) -> Result<Option<crate::defined_names::DefinedNameInfo>> {
393        let dn_scope = self.resolve_defined_name_scope(scope)?;
394        Ok(crate::defined_names::get_defined_name(
395            &self.workbook_xml,
396            name,
397            dn_scope,
398        ))
399    }
400
401    /// List all defined names in the workbook.
402    pub fn get_all_defined_names(&self) -> Vec<crate::defined_names::DefinedNameInfo> {
403        crate::defined_names::get_all_defined_names(&self.workbook_xml)
404    }
405
406    /// Delete a defined name by name and scope.
407    ///
408    /// Returns an error if the name does not exist for the given scope.
409    pub fn delete_defined_name(&mut self, name: &str, scope: Option<&str>) -> Result<()> {
410        let dn_scope = self.resolve_defined_name_scope(scope)?;
411        crate::defined_names::delete_defined_name(&mut self.workbook_xml, name, dn_scope)
412    }
413
414    /// Protect a sheet with optional password and permission settings.
415    ///
416    /// Delegates to [`crate::sheet::protect_sheet`] after looking up the sheet.
417    pub fn protect_sheet(
418        &mut self,
419        sheet: &str,
420        config: &crate::sheet::SheetProtectionConfig,
421    ) -> Result<()> {
422        let ws = self.worksheet_mut(sheet)?;
423        crate::sheet::protect_sheet(ws, config)
424    }
425
426    /// Remove sheet protection.
427    pub fn unprotect_sheet(&mut self, sheet: &str) -> Result<()> {
428        let ws = self.worksheet_mut(sheet)?;
429        crate::sheet::unprotect_sheet(ws)
430    }
431
432    /// Check if a sheet is protected.
433    pub fn is_sheet_protected(&self, sheet: &str) -> Result<bool> {
434        let ws = self.worksheet_ref(sheet)?;
435        Ok(crate::sheet::is_sheet_protected(ws))
436    }
437
438    /// Resolve an optional sheet name to a [`DefinedNameScope`](crate::defined_names::DefinedNameScope).
439    fn resolve_defined_name_scope(
440        &self,
441        scope: Option<&str>,
442    ) -> Result<crate::defined_names::DefinedNameScope> {
443        match scope {
444            None => Ok(crate::defined_names::DefinedNameScope::Workbook),
445            Some(sheet_name) => {
446                let idx = self.sheet_index(sheet_name)?;
447                Ok(crate::defined_names::DefinedNameScope::Sheet(idx as u32))
448            }
449        }
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use tempfile::TempDir;
457
458    #[test]
459    fn test_workbook_add_data_validation() {
460        let mut wb = Workbook::new();
461        let config =
462            crate::validation::DataValidationConfig::dropdown("A1:A100", &["Yes", "No", "Maybe"]);
463        wb.add_data_validation("Sheet1", &config).unwrap();
464
465        let validations = wb.get_data_validations("Sheet1").unwrap();
466        assert_eq!(validations.len(), 1);
467        assert_eq!(validations[0].sqref, "A1:A100");
468    }
469
470    #[test]
471    fn test_workbook_remove_data_validation() {
472        let mut wb = Workbook::new();
473        let config1 = crate::validation::DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
474        let config2 = crate::validation::DataValidationConfig::whole_number("B1:B100", 1, 100);
475        wb.add_data_validation("Sheet1", &config1).unwrap();
476        wb.add_data_validation("Sheet1", &config2).unwrap();
477
478        wb.remove_data_validation("Sheet1", "A1:A100").unwrap();
479
480        let validations = wb.get_data_validations("Sheet1").unwrap();
481        assert_eq!(validations.len(), 1);
482        assert_eq!(validations[0].sqref, "B1:B100");
483    }
484
485    #[test]
486    fn test_workbook_data_validation_sheet_not_found() {
487        let mut wb = Workbook::new();
488        let config = crate::validation::DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
489        let result = wb.add_data_validation("NoSheet", &config);
490        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
491    }
492
493    #[test]
494    fn test_workbook_data_validation_roundtrip() {
495        let dir = TempDir::new().unwrap();
496        let path = dir.path().join("validation_roundtrip.xlsx");
497
498        let mut wb = Workbook::new();
499        let config =
500            crate::validation::DataValidationConfig::dropdown("A1:A50", &["Red", "Blue", "Green"]);
501        wb.add_data_validation("Sheet1", &config).unwrap();
502        wb.save(&path).unwrap();
503
504        let wb2 = Workbook::open(&path).unwrap();
505        let validations = wb2.get_data_validations("Sheet1").unwrap();
506        assert_eq!(validations.len(), 1);
507        assert_eq!(validations[0].sqref, "A1:A50");
508        assert_eq!(
509            validations[0].validation_type,
510            crate::validation::ValidationType::List
511        );
512    }
513
514    #[test]
515    fn test_workbook_add_comment() {
516        let mut wb = Workbook::new();
517        let config = crate::comment::CommentConfig {
518            cell: "A1".to_string(),
519            author: "Alice".to_string(),
520            text: "Test comment".to_string(),
521        };
522        wb.add_comment("Sheet1", &config).unwrap();
523
524        let comments = wb.get_comments("Sheet1").unwrap();
525        assert_eq!(comments.len(), 1);
526        assert_eq!(comments[0].cell, "A1");
527        assert_eq!(comments[0].author, "Alice");
528        assert_eq!(comments[0].text, "Test comment");
529    }
530
531    #[test]
532    fn test_workbook_remove_comment() {
533        let mut wb = Workbook::new();
534        let config = crate::comment::CommentConfig {
535            cell: "A1".to_string(),
536            author: "Alice".to_string(),
537            text: "Test comment".to_string(),
538        };
539        wb.add_comment("Sheet1", &config).unwrap();
540        wb.remove_comment("Sheet1", "A1").unwrap();
541
542        let comments = wb.get_comments("Sheet1").unwrap();
543        assert!(comments.is_empty());
544    }
545
546    #[test]
547    fn test_workbook_multiple_comments() {
548        let mut wb = Workbook::new();
549        wb.add_comment(
550            "Sheet1",
551            &crate::comment::CommentConfig {
552                cell: "A1".to_string(),
553                author: "Alice".to_string(),
554                text: "First".to_string(),
555            },
556        )
557        .unwrap();
558        wb.add_comment(
559            "Sheet1",
560            &crate::comment::CommentConfig {
561                cell: "B2".to_string(),
562                author: "Bob".to_string(),
563                text: "Second".to_string(),
564            },
565        )
566        .unwrap();
567
568        let comments = wb.get_comments("Sheet1").unwrap();
569        assert_eq!(comments.len(), 2);
570    }
571
572    #[test]
573    fn test_workbook_comment_sheet_not_found() {
574        let mut wb = Workbook::new();
575        let config = crate::comment::CommentConfig {
576            cell: "A1".to_string(),
577            author: "Alice".to_string(),
578            text: "Test".to_string(),
579        };
580        let result = wb.add_comment("NoSheet", &config);
581        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
582    }
583
584    #[test]
585    fn test_workbook_comment_roundtrip() {
586        let dir = TempDir::new().unwrap();
587        let path = dir.path().join("comment_roundtrip.xlsx");
588
589        let mut wb = Workbook::new();
590        wb.add_comment(
591            "Sheet1",
592            &crate::comment::CommentConfig {
593                cell: "A1".to_string(),
594                author: "Author".to_string(),
595                text: "A saved comment".to_string(),
596            },
597        )
598        .unwrap();
599        wb.save(&path).unwrap();
600
601        // Verify the comments XML was written to the ZIP.
602        let file = std::fs::File::open(&path).unwrap();
603        let mut archive = zip::ZipArchive::new(file).unwrap();
604        assert!(
605            archive.by_name("xl/comments1.xml").is_ok(),
606            "comments1.xml should be present in the ZIP"
607        );
608    }
609
610    #[test]
611    fn test_workbook_comment_roundtrip_open() {
612        let dir = TempDir::new().unwrap();
613        let path = dir.path().join("comment_roundtrip_open.xlsx");
614
615        let mut wb = Workbook::new();
616        wb.add_comment(
617            "Sheet1",
618            &crate::comment::CommentConfig {
619                cell: "A1".to_string(),
620                author: "Author".to_string(),
621                text: "Persist me".to_string(),
622            },
623        )
624        .unwrap();
625        wb.save(&path).unwrap();
626
627        let wb2 = Workbook::open(&path).unwrap();
628        let comments = wb2.get_comments("Sheet1").unwrap();
629        assert_eq!(comments.len(), 1);
630        assert_eq!(comments[0].cell, "A1");
631        assert_eq!(comments[0].author, "Author");
632        assert_eq!(comments[0].text, "Persist me");
633    }
634
635    #[test]
636    fn test_workbook_comment_produces_vml_part() {
637        let dir = TempDir::new().unwrap();
638        let path = dir.path().join("comment_vml.xlsx");
639
640        let mut wb = Workbook::new();
641        wb.add_comment(
642            "Sheet1",
643            &crate::comment::CommentConfig {
644                cell: "B3".to_string(),
645                author: "Tester".to_string(),
646                text: "VML check".to_string(),
647            },
648        )
649        .unwrap();
650        wb.save(&path).unwrap();
651
652        let file = std::fs::File::open(&path).unwrap();
653        let mut archive = zip::ZipArchive::new(file).unwrap();
654        assert!(
655            archive.by_name("xl/drawings/vmlDrawing1.vml").is_ok(),
656            "vmlDrawing1.vml should be present in the ZIP"
657        );
658
659        // Verify the VML content references the correct cell.
660        let mut vml_data = Vec::new();
661        archive
662            .by_name("xl/drawings/vmlDrawing1.vml")
663            .unwrap()
664            .read_to_end(&mut vml_data)
665            .unwrap();
666        let vml_str = String::from_utf8(vml_data).unwrap();
667        assert!(vml_str.contains("<x:Row>2</x:Row>"));
668        assert!(vml_str.contains("<x:Column>1</x:Column>"));
669        assert!(vml_str.contains("ObjectType=\"Note\""));
670    }
671
672    #[test]
673    fn test_workbook_comment_vml_roundtrip_open() {
674        let dir = TempDir::new().unwrap();
675        let path = dir.path().join("comment_vml_roundtrip.xlsx");
676
677        let mut wb = Workbook::new();
678        wb.add_comment(
679            "Sheet1",
680            &crate::comment::CommentConfig {
681                cell: "A1".to_string(),
682                author: "Author".to_string(),
683                text: "Roundtrip VML".to_string(),
684            },
685        )
686        .unwrap();
687        wb.save(&path).unwrap();
688
689        // Reopen and re-save.
690        let wb2 = Workbook::open(&path).unwrap();
691        let path2 = dir.path().join("comment_vml_roundtrip2.xlsx");
692        wb2.save(&path2).unwrap();
693
694        // Verify VML part is preserved through the round-trip.
695        let file = std::fs::File::open(&path2).unwrap();
696        let mut archive = zip::ZipArchive::new(file).unwrap();
697        assert!(archive.by_name("xl/drawings/vmlDrawing1.vml").is_ok());
698
699        // Comments should still be readable.
700        let wb3 = Workbook::open(&path2).unwrap();
701        let comments = wb3.get_comments("Sheet1").unwrap();
702        assert_eq!(comments.len(), 1);
703        assert_eq!(comments[0].text, "Roundtrip VML");
704    }
705
706    #[test]
707    fn test_workbook_comment_vml_legacy_drawing_ref() {
708        let dir = TempDir::new().unwrap();
709        let path = dir.path().join("comment_vml_legacy_ref.xlsx");
710
711        let mut wb = Workbook::new();
712        wb.add_comment(
713            "Sheet1",
714            &crate::comment::CommentConfig {
715                cell: "C5".to_string(),
716                author: "Author".to_string(),
717                text: "Legacy drawing test".to_string(),
718            },
719        )
720        .unwrap();
721        wb.save(&path).unwrap();
722
723        // Verify the worksheet XML contains a legacyDrawing element.
724        let file = std::fs::File::open(&path).unwrap();
725        let mut archive = zip::ZipArchive::new(file).unwrap();
726        let mut ws_data = Vec::new();
727        archive
728            .by_name("xl/worksheets/sheet1.xml")
729            .unwrap()
730            .read_to_end(&mut ws_data)
731            .unwrap();
732        let ws_str = String::from_utf8(ws_data).unwrap();
733        assert!(
734            ws_str.contains("legacyDrawing"),
735            "worksheet should contain legacyDrawing element"
736        );
737    }
738
739    #[test]
740    fn test_workbook_comment_vml_cleanup_on_last_remove() {
741        let dir = TempDir::new().unwrap();
742        let path = dir.path().join("comment_vml_cleanup.xlsx");
743
744        let mut wb = Workbook::new();
745        wb.add_comment(
746            "Sheet1",
747            &crate::comment::CommentConfig {
748                cell: "A1".to_string(),
749                author: "Author".to_string(),
750                text: "Will be removed".to_string(),
751            },
752        )
753        .unwrap();
754        wb.remove_comment("Sheet1", "A1").unwrap();
755        wb.save(&path).unwrap();
756
757        // Verify no VML part when all comments are removed.
758        let file = std::fs::File::open(&path).unwrap();
759        let mut archive = zip::ZipArchive::new(file).unwrap();
760        assert!(
761            archive.by_name("xl/drawings/vmlDrawing1.vml").is_err(),
762            "vmlDrawing1.vml should not be present when there are no comments"
763        );
764    }
765
766    #[test]
767    fn test_workbook_multiple_comments_vml() {
768        let dir = TempDir::new().unwrap();
769        let path = dir.path().join("multi_comment_vml.xlsx");
770
771        let mut wb = Workbook::new();
772        wb.add_comment(
773            "Sheet1",
774            &crate::comment::CommentConfig {
775                cell: "A1".to_string(),
776                author: "Alice".to_string(),
777                text: "First".to_string(),
778            },
779        )
780        .unwrap();
781        wb.add_comment(
782            "Sheet1",
783            &crate::comment::CommentConfig {
784                cell: "D10".to_string(),
785                author: "Bob".to_string(),
786                text: "Second".to_string(),
787            },
788        )
789        .unwrap();
790        wb.save(&path).unwrap();
791
792        let file = std::fs::File::open(&path).unwrap();
793        let mut archive = zip::ZipArchive::new(file).unwrap();
794        let mut vml_data = Vec::new();
795        archive
796            .by_name("xl/drawings/vmlDrawing1.vml")
797            .unwrap()
798            .read_to_end(&mut vml_data)
799            .unwrap();
800        let vml_str = String::from_utf8(vml_data).unwrap();
801        // Should have two shapes.
802        assert!(vml_str.contains("_x0000_s1025"));
803        assert!(vml_str.contains("_x0000_s1026"));
804    }
805
806    #[test]
807    fn test_workbook_set_auto_filter() {
808        let mut wb = Workbook::new();
809        wb.set_auto_filter("Sheet1", "A1:D10").unwrap();
810
811        let ws = wb.worksheet_ref("Sheet1").unwrap();
812        assert!(ws.auto_filter.is_some());
813        assert_eq!(ws.auto_filter.as_ref().unwrap().reference, "A1:D10");
814    }
815
816    #[test]
817    fn test_workbook_remove_auto_filter() {
818        let mut wb = Workbook::new();
819        wb.set_auto_filter("Sheet1", "A1:D10").unwrap();
820        wb.remove_auto_filter("Sheet1").unwrap();
821
822        let ws = wb.worksheet_ref("Sheet1").unwrap();
823        assert!(ws.auto_filter.is_none());
824    }
825
826    #[test]
827    fn test_workbook_auto_filter_sheet_not_found() {
828        let mut wb = Workbook::new();
829        let result = wb.set_auto_filter("NoSheet", "A1:D10");
830        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
831    }
832
833    #[test]
834    fn test_workbook_auto_filter_roundtrip() {
835        let dir = TempDir::new().unwrap();
836        let path = dir.path().join("autofilter_roundtrip.xlsx");
837
838        let mut wb = Workbook::new();
839        wb.set_auto_filter("Sheet1", "A1:C50").unwrap();
840        wb.save(&path).unwrap();
841
842        let wb2 = Workbook::open(&path).unwrap();
843        let ws = wb2.worksheet_ref("Sheet1").unwrap();
844        assert!(ws.auto_filter.is_some());
845        assert_eq!(ws.auto_filter.as_ref().unwrap().reference, "A1:C50");
846    }
847
848    #[test]
849    fn test_protect_unprotect_workbook() {
850        let mut wb = Workbook::new();
851        assert!(!wb.is_workbook_protected());
852
853        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
854            password: None,
855            lock_structure: true,
856            lock_windows: false,
857            lock_revision: false,
858        });
859        assert!(wb.is_workbook_protected());
860
861        wb.unprotect_workbook();
862        assert!(!wb.is_workbook_protected());
863    }
864
865    #[test]
866    fn test_protect_workbook_with_password() {
867        let mut wb = Workbook::new();
868        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
869            password: Some("secret".to_string()),
870            lock_structure: true,
871            lock_windows: false,
872            lock_revision: false,
873        });
874
875        let prot = wb.workbook_xml.workbook_protection.as_ref().unwrap();
876        assert!(prot.workbook_password.is_some());
877        let hash_str = prot.workbook_password.as_ref().unwrap();
878        // Should be a 4-character uppercase hex string
879        assert_eq!(hash_str.len(), 4);
880        assert!(hash_str.chars().all(|c| c.is_ascii_hexdigit()));
881        assert_eq!(prot.lock_structure, Some(true));
882    }
883
884    #[test]
885    fn test_protect_workbook_structure_only() {
886        let mut wb = Workbook::new();
887        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
888            password: None,
889            lock_structure: true,
890            lock_windows: false,
891            lock_revision: false,
892        });
893
894        let prot = wb.workbook_xml.workbook_protection.as_ref().unwrap();
895        assert!(prot.workbook_password.is_none());
896        assert_eq!(prot.lock_structure, Some(true));
897        assert!(prot.lock_windows.is_none());
898        assert!(prot.lock_revision.is_none());
899    }
900
901    #[test]
902    fn test_protect_workbook_save_roundtrip() {
903        let dir = TempDir::new().unwrap();
904        let path = dir.path().join("protected.xlsx");
905
906        let mut wb = Workbook::new();
907        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
908            password: Some("hello".to_string()),
909            lock_structure: true,
910            lock_windows: true,
911            lock_revision: false,
912        });
913        wb.save(&path).unwrap();
914
915        let wb2 = Workbook::open(&path).unwrap();
916        assert!(wb2.is_workbook_protected());
917        let prot = wb2.workbook_xml.workbook_protection.as_ref().unwrap();
918        assert!(prot.workbook_password.is_some());
919        assert_eq!(prot.lock_structure, Some(true));
920        assert_eq!(prot.lock_windows, Some(true));
921    }
922
923    #[test]
924    fn test_is_workbook_protected() {
925        let wb = Workbook::new();
926        assert!(!wb.is_workbook_protected());
927
928        let mut wb2 = Workbook::new();
929        wb2.protect_workbook(crate::protection::WorkbookProtectionConfig {
930            password: None,
931            lock_structure: false,
932            lock_windows: false,
933            lock_revision: false,
934        });
935        // Even with no locks, the protection element is present
936        assert!(wb2.is_workbook_protected());
937    }
938
939    #[test]
940    fn test_unprotect_already_unprotected() {
941        let mut wb = Workbook::new();
942        assert!(!wb.is_workbook_protected());
943        // Should be a no-op, not panic
944        wb.unprotect_workbook();
945        assert!(!wb.is_workbook_protected());
946    }
947
948    #[test]
949    fn test_set_and_get_external_hyperlink() {
950        use crate::hyperlink::HyperlinkType;
951
952        let mut wb = Workbook::new();
953        wb.set_cell_hyperlink(
954            "Sheet1",
955            "A1",
956            HyperlinkType::External("https://example.com".to_string()),
957            Some("Example"),
958            Some("Visit Example"),
959        )
960        .unwrap();
961
962        let info = wb.get_cell_hyperlink("Sheet1", "A1").unwrap().unwrap();
963        assert_eq!(
964            info.link_type,
965            HyperlinkType::External("https://example.com".to_string())
966        );
967        assert_eq!(info.display, Some("Example".to_string()));
968        assert_eq!(info.tooltip, Some("Visit Example".to_string()));
969    }
970
971    #[test]
972    fn test_set_and_get_internal_hyperlink() {
973        use crate::hyperlink::HyperlinkType;
974
975        let mut wb = Workbook::new();
976        wb.new_sheet("Data").unwrap();
977        wb.set_cell_hyperlink(
978            "Sheet1",
979            "B2",
980            HyperlinkType::Internal("Data!A1".to_string()),
981            Some("Go to Data"),
982            None,
983        )
984        .unwrap();
985
986        let info = wb.get_cell_hyperlink("Sheet1", "B2").unwrap().unwrap();
987        assert_eq!(
988            info.link_type,
989            HyperlinkType::Internal("Data!A1".to_string())
990        );
991        assert_eq!(info.display, Some("Go to Data".to_string()));
992    }
993
994    #[test]
995    fn test_set_and_get_email_hyperlink() {
996        use crate::hyperlink::HyperlinkType;
997
998        let mut wb = Workbook::new();
999        wb.set_cell_hyperlink(
1000            "Sheet1",
1001            "C3",
1002            HyperlinkType::Email("mailto:user@example.com".to_string()),
1003            None,
1004            None,
1005        )
1006        .unwrap();
1007
1008        let info = wb.get_cell_hyperlink("Sheet1", "C3").unwrap().unwrap();
1009        assert_eq!(
1010            info.link_type,
1011            HyperlinkType::Email("mailto:user@example.com".to_string())
1012        );
1013    }
1014
1015    #[test]
1016    fn test_delete_hyperlink_via_workbook() {
1017        use crate::hyperlink::HyperlinkType;
1018
1019        let mut wb = Workbook::new();
1020        wb.set_cell_hyperlink(
1021            "Sheet1",
1022            "A1",
1023            HyperlinkType::External("https://example.com".to_string()),
1024            None,
1025            None,
1026        )
1027        .unwrap();
1028
1029        wb.delete_cell_hyperlink("Sheet1", "A1").unwrap();
1030
1031        let info = wb.get_cell_hyperlink("Sheet1", "A1").unwrap();
1032        assert!(info.is_none());
1033    }
1034
1035    #[test]
1036    fn test_hyperlink_roundtrip_save_open() {
1037        use crate::hyperlink::HyperlinkType;
1038
1039        let dir = TempDir::new().unwrap();
1040        let path = dir.path().join("hyperlink.xlsx");
1041
1042        let mut wb = Workbook::new();
1043        wb.set_cell_hyperlink(
1044            "Sheet1",
1045            "A1",
1046            HyperlinkType::External("https://rust-lang.org".to_string()),
1047            Some("Rust"),
1048            Some("Rust Homepage"),
1049        )
1050        .unwrap();
1051        wb.set_cell_hyperlink(
1052            "Sheet1",
1053            "B1",
1054            HyperlinkType::Internal("Sheet1!C1".to_string()),
1055            Some("Go to C1"),
1056            None,
1057        )
1058        .unwrap();
1059        wb.set_cell_hyperlink(
1060            "Sheet1",
1061            "C1",
1062            HyperlinkType::Email("mailto:hello@example.com".to_string()),
1063            Some("Email"),
1064            None,
1065        )
1066        .unwrap();
1067        wb.save(&path).unwrap();
1068
1069        let wb2 = Workbook::open(&path).unwrap();
1070
1071        // External link roundtrip.
1072        let a1 = wb2.get_cell_hyperlink("Sheet1", "A1").unwrap().unwrap();
1073        assert_eq!(
1074            a1.link_type,
1075            HyperlinkType::External("https://rust-lang.org".to_string())
1076        );
1077        assert_eq!(a1.display, Some("Rust".to_string()));
1078        assert_eq!(a1.tooltip, Some("Rust Homepage".to_string()));
1079
1080        // Internal link roundtrip.
1081        let b1 = wb2.get_cell_hyperlink("Sheet1", "B1").unwrap().unwrap();
1082        assert_eq!(
1083            b1.link_type,
1084            HyperlinkType::Internal("Sheet1!C1".to_string())
1085        );
1086        assert_eq!(b1.display, Some("Go to C1".to_string()));
1087
1088        // Email link roundtrip.
1089        let c1 = wb2.get_cell_hyperlink("Sheet1", "C1").unwrap().unwrap();
1090        assert_eq!(
1091            c1.link_type,
1092            HyperlinkType::Email("mailto:hello@example.com".to_string())
1093        );
1094        assert_eq!(c1.display, Some("Email".to_string()));
1095    }
1096
1097    #[test]
1098    fn test_hyperlink_on_nonexistent_sheet() {
1099        use crate::hyperlink::HyperlinkType;
1100
1101        let mut wb = Workbook::new();
1102        let result = wb.set_cell_hyperlink(
1103            "NoSheet",
1104            "A1",
1105            HyperlinkType::External("https://example.com".to_string()),
1106            None,
1107            None,
1108        );
1109        assert!(result.is_err());
1110    }
1111
1112    #[test]
1113    fn test_set_defined_name_workbook_scope() {
1114        let mut wb = Workbook::new();
1115        wb.set_defined_name("SalesData", "Sheet1!$A$1:$D$10", None, None)
1116            .unwrap();
1117
1118        let info = wb.get_defined_name("SalesData", None).unwrap().unwrap();
1119        assert_eq!(info.name, "SalesData");
1120        assert_eq!(info.value, "Sheet1!$A$1:$D$10");
1121        assert_eq!(info.scope, crate::defined_names::DefinedNameScope::Workbook);
1122        assert!(info.comment.is_none());
1123    }
1124
1125    #[test]
1126    fn test_set_defined_name_sheet_scope() {
1127        let mut wb = Workbook::new();
1128        wb.set_defined_name("LocalRange", "Sheet1!$B$2:$C$5", Some("Sheet1"), None)
1129            .unwrap();
1130
1131        let info = wb
1132            .get_defined_name("LocalRange", Some("Sheet1"))
1133            .unwrap()
1134            .unwrap();
1135        assert_eq!(info.name, "LocalRange");
1136        assert_eq!(info.value, "Sheet1!$B$2:$C$5");
1137        assert_eq!(info.scope, crate::defined_names::DefinedNameScope::Sheet(0));
1138    }
1139
1140    #[test]
1141    fn test_update_existing_defined_name() {
1142        let mut wb = Workbook::new();
1143        wb.set_defined_name("DataRange", "Sheet1!$A$1:$A$10", None, None)
1144            .unwrap();
1145
1146        wb.set_defined_name("DataRange", "Sheet1!$A$1:$A$50", None, Some("Updated"))
1147            .unwrap();
1148
1149        let all = wb.get_all_defined_names();
1150        assert_eq!(all.len(), 1, "should not duplicate the entry");
1151        assert_eq!(all[0].value, "Sheet1!$A$1:$A$50");
1152        assert_eq!(all[0].comment, Some("Updated".to_string()));
1153    }
1154
1155    #[test]
1156    fn test_get_all_defined_names() {
1157        let mut wb = Workbook::new();
1158        wb.new_sheet("Sheet2").unwrap();
1159
1160        wb.set_defined_name("Alpha", "Sheet1!$A$1", None, None)
1161            .unwrap();
1162        wb.set_defined_name("Beta", "Sheet1!$B$1", Some("Sheet1"), None)
1163            .unwrap();
1164        wb.set_defined_name("Gamma", "Sheet2!$C$1", Some("Sheet2"), None)
1165            .unwrap();
1166
1167        let all = wb.get_all_defined_names();
1168        assert_eq!(all.len(), 3);
1169        assert_eq!(all[0].name, "Alpha");
1170        assert_eq!(all[1].name, "Beta");
1171        assert_eq!(all[2].name, "Gamma");
1172    }
1173
1174    #[test]
1175    fn test_delete_defined_name() {
1176        let mut wb = Workbook::new();
1177        wb.set_defined_name("ToDelete", "Sheet1!$A$1", None, None)
1178            .unwrap();
1179        assert!(wb.get_defined_name("ToDelete", None).unwrap().is_some());
1180
1181        wb.delete_defined_name("ToDelete", None).unwrap();
1182        assert!(wb.get_defined_name("ToDelete", None).unwrap().is_none());
1183    }
1184
1185    #[test]
1186    fn test_delete_nonexistent_defined_name_returns_error() {
1187        let mut wb = Workbook::new();
1188        let result = wb.delete_defined_name("Ghost", None);
1189        assert!(result.is_err());
1190        assert!(result.unwrap_err().to_string().contains("Ghost"));
1191    }
1192
1193    #[test]
1194    fn test_defined_name_sheet_scope_requires_existing_sheet() {
1195        let mut wb = Workbook::new();
1196        let result = wb.set_defined_name("TestName", "Sheet1!$A$1", Some("NonExistent"), None);
1197        assert!(result.is_err());
1198        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1199    }
1200
1201    #[test]
1202    fn test_defined_name_roundtrip() {
1203        let dir = TempDir::new().unwrap();
1204        let path = dir.path().join("defined_names.xlsx");
1205
1206        let mut wb = Workbook::new();
1207        wb.set_defined_name("Revenue", "Sheet1!$E$1:$E$100", None, Some("Total revenue"))
1208            .unwrap();
1209        wb.set_defined_name("LocalName", "Sheet1!$A$1", Some("Sheet1"), None)
1210            .unwrap();
1211        wb.save(&path).unwrap();
1212
1213        let wb2 = Workbook::open(&path).unwrap();
1214        let all = wb2.get_all_defined_names();
1215        assert_eq!(all.len(), 2);
1216        assert_eq!(all[0].name, "Revenue");
1217        assert_eq!(all[0].value, "Sheet1!$E$1:$E$100");
1218        assert_eq!(all[0].comment, Some("Total revenue".to_string()));
1219        assert_eq!(all[1].name, "LocalName");
1220        assert_eq!(all[1].value, "Sheet1!$A$1");
1221        assert_eq!(
1222            all[1].scope,
1223            crate::defined_names::DefinedNameScope::Sheet(0)
1224        );
1225    }
1226
1227    #[test]
1228    fn test_protect_sheet_via_workbook() {
1229        let mut wb = Workbook::new();
1230        let config = crate::sheet::SheetProtectionConfig::default();
1231        wb.protect_sheet("Sheet1", &config).unwrap();
1232
1233        assert!(wb.is_sheet_protected("Sheet1").unwrap());
1234    }
1235
1236    #[test]
1237    fn test_unprotect_sheet_via_workbook() {
1238        let mut wb = Workbook::new();
1239        let config = crate::sheet::SheetProtectionConfig::default();
1240        wb.protect_sheet("Sheet1", &config).unwrap();
1241        assert!(wb.is_sheet_protected("Sheet1").unwrap());
1242
1243        wb.unprotect_sheet("Sheet1").unwrap();
1244        assert!(!wb.is_sheet_protected("Sheet1").unwrap());
1245    }
1246
1247    #[test]
1248    fn test_protect_sheet_nonexistent_returns_error() {
1249        let mut wb = Workbook::new();
1250        let config = crate::sheet::SheetProtectionConfig::default();
1251        let result = wb.protect_sheet("NoSuchSheet", &config);
1252        assert!(result.is_err());
1253        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1254    }
1255
1256    #[test]
1257    fn test_is_sheet_protected_nonexistent_returns_error() {
1258        let wb = Workbook::new();
1259        let result = wb.is_sheet_protected("NoSuchSheet");
1260        assert!(result.is_err());
1261    }
1262
1263    #[test]
1264    fn test_protect_sheet_with_password_and_permissions() {
1265        let mut wb = Workbook::new();
1266        let config = crate::sheet::SheetProtectionConfig {
1267            password: Some("secret".to_string()),
1268            format_cells: true,
1269            insert_rows: true,
1270            sort: true,
1271            ..crate::sheet::SheetProtectionConfig::default()
1272        };
1273        wb.protect_sheet("Sheet1", &config).unwrap();
1274        assert!(wb.is_sheet_protected("Sheet1").unwrap());
1275    }
1276
1277    #[test]
1278    fn test_sheet_protection_roundtrip() {
1279        let dir = TempDir::new().unwrap();
1280        let path = dir.path().join("sheet_protection.xlsx");
1281
1282        let mut wb = Workbook::new();
1283        let config = crate::sheet::SheetProtectionConfig {
1284            password: Some("pass".to_string()),
1285            format_cells: true,
1286            ..crate::sheet::SheetProtectionConfig::default()
1287        };
1288        wb.protect_sheet("Sheet1", &config).unwrap();
1289        wb.save(&path).unwrap();
1290
1291        let wb2 = Workbook::open(&path).unwrap();
1292        assert!(wb2.is_sheet_protected("Sheet1").unwrap());
1293    }
1294}