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    /// Add a threaded comment to a cell on the given sheet.
93    ///
94    /// Returns the generated comment ID. If the author does not exist in the
95    /// person list, they are added automatically.
96    pub fn add_threaded_comment(
97        &mut self,
98        sheet: &str,
99        cell: &str,
100        input: &ThreadedCommentInput,
101    ) -> Result<String> {
102        let idx = self.sheet_index(sheet)?;
103        crate::threaded_comment::add_threaded_comment(
104            &mut self.sheet_threaded_comments[idx],
105            &mut self.person_list,
106            cell,
107            input,
108        )
109    }
110
111    /// Get all threaded comments for a sheet.
112    pub fn get_threaded_comments(&self, sheet: &str) -> Result<Vec<ThreadedCommentData>> {
113        let idx = self.sheet_index(sheet)?;
114        Ok(crate::threaded_comment::get_threaded_comments(
115            &self.sheet_threaded_comments[idx],
116            &self.person_list,
117        ))
118    }
119
120    /// Get threaded comments for a specific cell on a sheet.
121    pub fn get_threaded_comments_by_cell(
122        &self,
123        sheet: &str,
124        cell: &str,
125    ) -> Result<Vec<ThreadedCommentData>> {
126        let idx = self.sheet_index(sheet)?;
127        Ok(crate::threaded_comment::get_threaded_comments_by_cell(
128            &self.sheet_threaded_comments[idx],
129            &self.person_list,
130            cell,
131        ))
132    }
133
134    /// Delete a threaded comment by its ID.
135    ///
136    /// Returns an error if the comment was not found.
137    pub fn delete_threaded_comment(&mut self, sheet: &str, comment_id: &str) -> Result<()> {
138        let idx = self.sheet_index(sheet)?;
139        crate::threaded_comment::delete_threaded_comment(
140            &mut self.sheet_threaded_comments[idx],
141            comment_id,
142        )
143    }
144
145    /// Set the resolved (done) state of a threaded comment.
146    ///
147    /// Returns an error if the comment was not found.
148    pub fn resolve_threaded_comment(
149        &mut self,
150        sheet: &str,
151        comment_id: &str,
152        done: bool,
153    ) -> Result<()> {
154        let idx = self.sheet_index(sheet)?;
155        crate::threaded_comment::resolve_threaded_comment(
156            &mut self.sheet_threaded_comments[idx],
157            comment_id,
158            done,
159        )
160    }
161
162    /// Add a person to the person list. Returns the person ID.
163    /// If a person with the same display name already exists, returns their ID.
164    pub fn add_person(&mut self, input: &PersonInput) -> String {
165        crate::threaded_comment::add_person(&mut self.person_list, input)
166    }
167
168    /// Get all persons in the person list.
169    pub fn get_persons(&self) -> Vec<PersonData> {
170        crate::threaded_comment::get_persons(&self.person_list)
171    }
172
173    /// Set an auto-filter on a sheet for the given cell range.
174    pub fn set_auto_filter(&mut self, sheet: &str, range: &str) -> Result<()> {
175        let ws = self.worksheet_mut(sheet)?;
176        crate::table::set_auto_filter(ws, range)
177    }
178
179    /// Remove the auto-filter from a sheet.
180    pub fn remove_auto_filter(&mut self, sheet: &str) -> Result<()> {
181        let ws = self.worksheet_mut(sheet)?;
182        crate::table::remove_auto_filter(ws);
183        Ok(())
184    }
185
186    /// Add a table to a sheet.
187    ///
188    /// Creates the table XML part, adds the appropriate relationship and
189    /// content type entries. The table name must be unique within the workbook.
190    pub fn add_table(&mut self, sheet: &str, config: &crate::table::TableConfig) -> Result<()> {
191        crate::table::validate_table_config(config)?;
192        let sheet_idx = self.sheet_index(sheet)?;
193
194        // Check for duplicate table name across the entire workbook.
195        if self.tables.iter().any(|(_, t, _)| t.name == config.name) {
196            return Err(Error::TableAlreadyExists {
197                name: config.name.clone(),
198            });
199        }
200
201        // Assign a unique table ID (max existing + 1).
202        let table_id = self.tables.iter().map(|(_, t, _)| t.id).max().unwrap_or(0) + 1;
203
204        let max_existing = self
205            .tables
206            .iter()
207            .filter_map(|(path, _, _)| {
208                path.trim_start_matches("xl/tables/table")
209                    .trim_end_matches(".xml")
210                    .parse::<u32>()
211                    .ok()
212            })
213            .max()
214            .unwrap_or(0);
215        let table_num = max_existing + 1;
216        let table_path = format!("xl/tables/table{}.xml", table_num);
217        let table_xml = crate::table::build_table_xml(config, table_id);
218
219        self.tables.push((table_path, table_xml, sheet_idx));
220        Ok(())
221    }
222
223    /// List all tables on a sheet.
224    ///
225    /// Returns metadata for each table associated with the given sheet.
226    pub fn get_tables(&self, sheet: &str) -> Result<Vec<crate::table::TableInfo>> {
227        let sheet_idx = self.sheet_index(sheet)?;
228        let infos = self
229            .tables
230            .iter()
231            .filter(|(_, _, idx)| *idx == sheet_idx)
232            .map(|(_, table_xml, _)| crate::table::table_xml_to_info(table_xml))
233            .collect();
234        Ok(infos)
235    }
236
237    /// Delete a table from a sheet by name.
238    ///
239    /// Removes the table part, relationship, and content type entries.
240    pub fn delete_table(&mut self, sheet: &str, table_name: &str) -> Result<()> {
241        let sheet_idx = self.sheet_index(sheet)?;
242
243        let pos = self
244            .tables
245            .iter()
246            .position(|(_, t, idx)| t.name == table_name && *idx == sheet_idx);
247        match pos {
248            Some(i) => {
249                self.tables.remove(i);
250                Ok(())
251            }
252            None => Err(Error::TableNotFound {
253                name: table_name.to_string(),
254            }),
255        }
256    }
257
258    /// Set freeze panes on a sheet.
259    ///
260    /// The cell reference indicates the top-left cell of the scrollable area.
261    /// For example, `"A2"` freezes row 1, `"B1"` freezes column A, and `"B2"`
262    /// freezes both row 1 and column A.
263    pub fn set_panes(&mut self, sheet: &str, cell: &str) -> Result<()> {
264        let ws = self.worksheet_mut(sheet)?;
265        crate::sheet::set_panes(ws, cell)
266    }
267
268    /// Remove any freeze or split panes from a sheet.
269    pub fn unset_panes(&mut self, sheet: &str) -> Result<()> {
270        let ws = self.worksheet_mut(sheet)?;
271        crate::sheet::unset_panes(ws);
272        Ok(())
273    }
274
275    /// Get the current freeze pane cell reference for a sheet, if any.
276    ///
277    /// Returns the top-left cell of the unfrozen area (e.g., `"A2"` if row 1
278    /// is frozen), or `None` if no panes are configured.
279    pub fn get_panes(&self, sheet: &str) -> Result<Option<String>> {
280        let ws = self.worksheet_ref(sheet)?;
281        Ok(crate::sheet::get_panes(ws))
282    }
283
284    /// Set page margins on a sheet.
285    pub fn set_page_margins(
286        &mut self,
287        sheet: &str,
288        margins: &crate::page_layout::PageMarginsConfig,
289    ) -> Result<()> {
290        let ws = self.worksheet_mut(sheet)?;
291        crate::page_layout::set_page_margins(ws, margins)
292    }
293
294    /// Get page margins for a sheet, returning Excel defaults if not set.
295    pub fn get_page_margins(&self, sheet: &str) -> Result<crate::page_layout::PageMarginsConfig> {
296        let ws = self.worksheet_ref(sheet)?;
297        Ok(crate::page_layout::get_page_margins(ws))
298    }
299
300    /// Set page setup options (orientation, paper size, scale, fit-to-page).
301    ///
302    /// Only non-`None` parameters are applied; existing values for `None`
303    /// parameters are preserved.
304    pub fn set_page_setup(
305        &mut self,
306        sheet: &str,
307        orientation: Option<crate::page_layout::Orientation>,
308        paper_size: Option<crate::page_layout::PaperSize>,
309        scale: Option<u32>,
310        fit_to_width: Option<u32>,
311        fit_to_height: Option<u32>,
312    ) -> Result<()> {
313        let ws = self.worksheet_mut(sheet)?;
314        crate::page_layout::set_page_setup(
315            ws,
316            orientation,
317            paper_size,
318            scale,
319            fit_to_width,
320            fit_to_height,
321        )
322    }
323
324    /// Get the page orientation for a sheet.
325    pub fn get_orientation(&self, sheet: &str) -> Result<Option<crate::page_layout::Orientation>> {
326        let ws = self.worksheet_ref(sheet)?;
327        Ok(crate::page_layout::get_orientation(ws))
328    }
329
330    /// Get the paper size for a sheet.
331    pub fn get_paper_size(&self, sheet: &str) -> Result<Option<crate::page_layout::PaperSize>> {
332        let ws = self.worksheet_ref(sheet)?;
333        Ok(crate::page_layout::get_paper_size(ws))
334    }
335
336    /// Get scale, fit-to-width, and fit-to-height values for a sheet.
337    ///
338    /// Returns `(scale, fit_to_width, fit_to_height)`, each `None` if not set.
339    pub fn get_page_setup_details(
340        &self,
341        sheet: &str,
342    ) -> Result<(Option<u32>, Option<u32>, Option<u32>)> {
343        let ws = self.worksheet_ref(sheet)?;
344        Ok((
345            crate::page_layout::get_scale(ws),
346            crate::page_layout::get_fit_to_width(ws),
347            crate::page_layout::get_fit_to_height(ws),
348        ))
349    }
350
351    /// Set header and footer text for printing.
352    pub fn set_header_footer(
353        &mut self,
354        sheet: &str,
355        header: Option<&str>,
356        footer: Option<&str>,
357    ) -> Result<()> {
358        let ws = self.worksheet_mut(sheet)?;
359        crate::page_layout::set_header_footer(ws, header, footer)
360    }
361
362    /// Get the header and footer text for a sheet.
363    pub fn get_header_footer(&self, sheet: &str) -> Result<(Option<String>, Option<String>)> {
364        let ws = self.worksheet_ref(sheet)?;
365        Ok(crate::page_layout::get_header_footer(ws))
366    }
367
368    /// Set print options on a sheet.
369    pub fn set_print_options(
370        &mut self,
371        sheet: &str,
372        grid_lines: Option<bool>,
373        headings: Option<bool>,
374        h_centered: Option<bool>,
375        v_centered: Option<bool>,
376    ) -> Result<()> {
377        let ws = self.worksheet_mut(sheet)?;
378        crate::page_layout::set_print_options(ws, grid_lines, headings, h_centered, v_centered)
379    }
380
381    /// Get print options for a sheet.
382    ///
383    /// Returns `(grid_lines, headings, horizontal_centered, vertical_centered)`.
384    #[allow(clippy::type_complexity)]
385    pub fn get_print_options(
386        &self,
387        sheet: &str,
388    ) -> Result<(Option<bool>, Option<bool>, Option<bool>, Option<bool>)> {
389        let ws = self.worksheet_ref(sheet)?;
390        Ok(crate::page_layout::get_print_options(ws))
391    }
392
393    /// Insert a horizontal page break before the given 1-based row.
394    pub fn insert_page_break(&mut self, sheet: &str, row: u32) -> Result<()> {
395        let ws = self.worksheet_mut(sheet)?;
396        crate::page_layout::insert_page_break(ws, row)
397    }
398
399    /// Remove a horizontal page break at the given 1-based row.
400    pub fn remove_page_break(&mut self, sheet: &str, row: u32) -> Result<()> {
401        let ws = self.worksheet_mut(sheet)?;
402        crate::page_layout::remove_page_break(ws, row)
403    }
404
405    /// Get all row page break positions (1-based row numbers).
406    pub fn get_page_breaks(&self, sheet: &str) -> Result<Vec<u32>> {
407        let ws = self.worksheet_ref(sheet)?;
408        Ok(crate::page_layout::get_page_breaks(ws))
409    }
410
411    /// Set a hyperlink on a cell.
412    ///
413    /// For external URLs and email links, a relationship entry is created in
414    /// the worksheet's `.rels` file. Internal sheet references use only the
415    /// `location` attribute without a relationship.
416    pub fn set_cell_hyperlink(
417        &mut self,
418        sheet: &str,
419        cell: &str,
420        link: crate::hyperlink::HyperlinkType,
421        display: Option<&str>,
422        tooltip: Option<&str>,
423    ) -> Result<()> {
424        let sheet_idx = self.sheet_index(sheet)?;
425        let ws = &mut self.worksheets[sheet_idx].1;
426        let rels = self
427            .worksheet_rels
428            .entry(sheet_idx)
429            .or_insert_with(|| Relationships {
430                xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
431                relationships: vec![],
432            });
433        crate::hyperlink::set_cell_hyperlink(ws, rels, cell, &link, display, tooltip)
434    }
435
436    /// Get hyperlink information for a cell.
437    ///
438    /// Returns `None` if the cell has no hyperlink.
439    pub fn get_cell_hyperlink(
440        &self,
441        sheet: &str,
442        cell: &str,
443    ) -> Result<Option<crate::hyperlink::HyperlinkInfo>> {
444        let sheet_idx = self.sheet_index(sheet)?;
445        let ws = &self.worksheets[sheet_idx].1;
446        let empty_rels = Relationships {
447            xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
448            relationships: vec![],
449        };
450        let rels = self.worksheet_rels.get(&sheet_idx).unwrap_or(&empty_rels);
451        crate::hyperlink::get_cell_hyperlink(ws, rels, cell)
452    }
453
454    /// Delete a hyperlink from a cell.
455    ///
456    /// Removes both the hyperlink element from the worksheet XML and any
457    /// associated relationship entry.
458    pub fn delete_cell_hyperlink(&mut self, sheet: &str, cell: &str) -> Result<()> {
459        let sheet_idx = self.sheet_index(sheet)?;
460        let ws = &mut self.worksheets[sheet_idx].1;
461        let rels = self
462            .worksheet_rels
463            .entry(sheet_idx)
464            .or_insert_with(|| Relationships {
465                xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
466                relationships: vec![],
467            });
468        crate::hyperlink::delete_cell_hyperlink(ws, rels, cell)
469    }
470
471    /// Protect the workbook structure and/or windows.
472    pub fn protect_workbook(&mut self, config: WorkbookProtectionConfig) {
473        let password_hash = config.password.as_ref().map(|p| {
474            let hash = crate::protection::legacy_password_hash(p);
475            format!("{:04X}", hash)
476        });
477        self.workbook_xml.workbook_protection = Some(WorkbookProtection {
478            workbook_password: password_hash,
479            lock_structure: if config.lock_structure {
480                Some(true)
481            } else {
482                None
483            },
484            lock_windows: if config.lock_windows {
485                Some(true)
486            } else {
487                None
488            },
489            revisions_password: None,
490            lock_revision: if config.lock_revision {
491                Some(true)
492            } else {
493                None
494            },
495        });
496    }
497
498    /// Remove workbook protection.
499    pub fn unprotect_workbook(&mut self) {
500        self.workbook_xml.workbook_protection = None;
501    }
502
503    /// Check if the workbook is protected.
504    pub fn is_workbook_protected(&self) -> bool {
505        self.workbook_xml.workbook_protection.is_some()
506    }
507
508    /// Resolve a theme color by index (0-11) with optional tint.
509    /// Returns the ARGB hex string (e.g. "FF4472C4") or None if the index is out of range.
510    pub fn get_theme_color(&self, index: u32, tint: Option<f64>) -> Option<String> {
511        crate::theme::resolve_theme_color(&self.theme_colors, index, tint)
512    }
513
514    /// Add or update a defined name in the workbook.
515    ///
516    /// If `scope` is `None`, the name is workbook-scoped (visible from all sheets).
517    /// If `scope` is `Some(sheet_name)`, it is sheet-scoped using the sheet's 0-based index.
518    /// If a name with the same name and scope already exists, its value and comment are updated.
519    pub fn set_defined_name(
520        &mut self,
521        name: &str,
522        value: &str,
523        scope: Option<&str>,
524        comment: Option<&str>,
525    ) -> Result<()> {
526        let dn_scope = self.resolve_defined_name_scope(scope)?;
527        crate::defined_names::set_defined_name(
528            &mut self.workbook_xml,
529            name,
530            value,
531            dn_scope,
532            comment,
533        )
534    }
535
536    /// Get a defined name by name and scope.
537    ///
538    /// If `scope` is `None`, looks for a workbook-scoped name.
539    /// If `scope` is `Some(sheet_name)`, looks for a sheet-scoped name.
540    /// Returns `None` if no matching defined name is found.
541    pub fn get_defined_name(
542        &self,
543        name: &str,
544        scope: Option<&str>,
545    ) -> Result<Option<crate::defined_names::DefinedNameInfo>> {
546        let dn_scope = self.resolve_defined_name_scope(scope)?;
547        Ok(crate::defined_names::get_defined_name(
548            &self.workbook_xml,
549            name,
550            dn_scope,
551        ))
552    }
553
554    /// List all defined names in the workbook.
555    pub fn get_all_defined_names(&self) -> Vec<crate::defined_names::DefinedNameInfo> {
556        crate::defined_names::get_all_defined_names(&self.workbook_xml)
557    }
558
559    /// Delete a defined name by name and scope.
560    ///
561    /// Returns an error if the name does not exist for the given scope.
562    pub fn delete_defined_name(&mut self, name: &str, scope: Option<&str>) -> Result<()> {
563        let dn_scope = self.resolve_defined_name_scope(scope)?;
564        crate::defined_names::delete_defined_name(&mut self.workbook_xml, name, dn_scope)
565    }
566
567    /// Protect a sheet with optional password and permission settings.
568    ///
569    /// Delegates to [`crate::sheet::protect_sheet`] after looking up the sheet.
570    pub fn protect_sheet(
571        &mut self,
572        sheet: &str,
573        config: &crate::sheet::SheetProtectionConfig,
574    ) -> Result<()> {
575        let ws = self.worksheet_mut(sheet)?;
576        crate::sheet::protect_sheet(ws, config)
577    }
578
579    /// Remove sheet protection.
580    pub fn unprotect_sheet(&mut self, sheet: &str) -> Result<()> {
581        let ws = self.worksheet_mut(sheet)?;
582        crate::sheet::unprotect_sheet(ws)
583    }
584
585    /// Check if a sheet is protected.
586    pub fn is_sheet_protected(&self, sheet: &str) -> Result<bool> {
587        let ws = self.worksheet_ref(sheet)?;
588        Ok(crate::sheet::is_sheet_protected(ws))
589    }
590
591    /// Set sheet view display options (gridlines, formulas, zoom, view mode, etc.).
592    ///
593    /// Only non-`None` fields in the options struct are applied.
594    pub fn set_sheet_view_options(
595        &mut self,
596        sheet: &str,
597        opts: &crate::sheet::SheetViewOptions,
598    ) -> Result<()> {
599        let ws = self.worksheet_mut(sheet)?;
600        crate::sheet::set_sheet_view_options(ws, opts)
601    }
602
603    /// Get the current sheet view display options.
604    pub fn get_sheet_view_options(&self, sheet: &str) -> Result<crate::sheet::SheetViewOptions> {
605        let ws = self.worksheet_ref(sheet)?;
606        Ok(crate::sheet::get_sheet_view_options(ws))
607    }
608
609    /// Set the visibility state of a sheet (Visible, Hidden, VeryHidden).
610    ///
611    /// At least one sheet must remain visible. Returns an error if hiding
612    /// this sheet would leave no visible sheets.
613    pub fn set_sheet_visibility(
614        &mut self,
615        sheet: &str,
616        visibility: crate::sheet::SheetVisibility,
617    ) -> Result<()> {
618        let idx = self.sheet_index(sheet)?;
619
620        if visibility != crate::sheet::SheetVisibility::Visible {
621            let visible_count = self
622                .workbook_xml
623                .sheets
624                .sheets
625                .iter()
626                .enumerate()
627                .filter(|(i, entry)| {
628                    if *i == idx {
629                        return false;
630                    }
631                    crate::sheet::SheetVisibility::from_xml_str(entry.state.as_deref())
632                        == crate::sheet::SheetVisibility::Visible
633                })
634                .count();
635            if visible_count == 0 {
636                return Err(Error::InvalidArgument(
637                    "cannot hide the last visible sheet".to_string(),
638                ));
639            }
640        }
641
642        self.workbook_xml.sheets.sheets[idx].state = visibility.as_xml_str().map(|s| s.to_string());
643        Ok(())
644    }
645
646    /// Get the visibility state of a sheet.
647    pub fn get_sheet_visibility(&self, sheet: &str) -> Result<crate::sheet::SheetVisibility> {
648        let idx = self.sheet_index(sheet)?;
649        let entry = &self.workbook_xml.sheets.sheets[idx];
650        Ok(crate::sheet::SheetVisibility::from_xml_str(
651            entry.state.as_deref(),
652        ))
653    }
654
655    /// Get the raw VBA project binary (`xl/vbaProject.bin`), if present.
656    ///
657    /// Returns `None` for standard `.xlsx` files (which have no VBA project).
658    /// Returns `Some(bytes)` for `.xlsm` files that contain a VBA project.
659    pub fn get_vba_project(&self) -> Option<&[u8]> {
660        self.vba_blob.as_deref()
661    }
662
663    /// Extract VBA module source code from the workbook's VBA project.
664    ///
665    /// Parses the OLE/CFB container inside `xl/vbaProject.bin`, reads the
666    /// `dir` stream for module metadata, and decompresses each module's
667    /// source code.
668    ///
669    /// Returns `Ok(None)` if no VBA project is present.
670    /// Returns `Ok(Some(project))` with the extracted modules and any warnings.
671    /// Returns `Err` if the VBA project exists but is corrupt or unreadable.
672    pub fn get_vba_modules(&self) -> Result<Option<crate::vba::VbaProject>> {
673        match &self.vba_blob {
674            None => Ok(None),
675            Some(bin) => {
676                let project = crate::vba::extract_vba_modules(bin)?;
677                Ok(Some(project))
678            }
679        }
680    }
681
682    /// Hydrate form controls from VML bytes into `sheet_form_controls`.
683    ///
684    /// When a workbook is opened from a file, existing form controls are stored
685    /// only as raw VML bytes in `sheet_vml`. This method parses the VML and
686    /// populates `sheet_form_controls` so that add/delete/get operations work
687    /// correctly on files with pre-existing controls.
688    ///
689    /// After hydration, form control shapes are stripped from the preserved VML
690    /// to prevent duplication on save. Comment (Note) shapes are preserved.
691    fn hydrate_form_controls(&mut self, idx: usize) {
692        while self.sheet_form_controls.len() <= idx {
693            self.sheet_form_controls.push(vec![]);
694        }
695        if !self.sheet_form_controls[idx].is_empty() {
696            return;
697        }
698        if let Some(Some(vml_bytes)) = self.sheet_vml.get(idx) {
699            let vml_str = String::from_utf8_lossy(vml_bytes);
700            let parsed = crate::control::parse_form_controls(&vml_str);
701            if !parsed.is_empty() {
702                self.sheet_form_controls[idx] =
703                    parsed.iter().map(|info| info.to_config()).collect();
704                // Strip form control shapes from preserved VML so save()
705                // regenerates them solely from sheet_form_controls, avoiding
706                // duplication.
707                let cleaned = crate::control::strip_form_control_shapes_from_vml(vml_bytes);
708                self.sheet_vml[idx] = cleaned;
709            }
710        }
711    }
712
713    /// Add a form control to a sheet.
714    ///
715    /// The control is positioned at the cell specified in `config.cell`.
716    /// Supported control types: Button, CheckBox, OptionButton, SpinButton,
717    /// ScrollBar, GroupBox, Label.
718    pub fn add_form_control(
719        &mut self,
720        sheet: &str,
721        config: crate::control::FormControlConfig,
722    ) -> Result<()> {
723        let idx = self.sheet_index(sheet)?;
724        config.validate()?;
725        self.hydrate_form_controls(idx);
726        self.sheet_form_controls[idx].push(config);
727        // Invalidate cached VML so save() regenerates from current state.
728        if idx < self.sheet_vml.len() {
729            self.sheet_vml[idx] = None;
730        }
731        Ok(())
732    }
733
734    /// Get all form controls on a sheet.
735    ///
736    /// If the sheet has VML content from an opened file, form controls are
737    /// hydrated from the VML first and then returned.
738    pub fn get_form_controls(
739        &mut self,
740        sheet: &str,
741    ) -> Result<Vec<crate::control::FormControlInfo>> {
742        let idx = self.sheet_index(sheet)?;
743        self.hydrate_form_controls(idx);
744
745        let controls = &self.sheet_form_controls[idx];
746        if !controls.is_empty() {
747            let vml = crate::control::build_form_control_vml(controls, 1025);
748            return Ok(crate::control::parse_form_controls(&vml));
749        }
750
751        Ok(vec![])
752    }
753
754    /// Delete a form control from a sheet by its 0-based index.
755    pub fn delete_form_control(&mut self, sheet: &str, index: usize) -> Result<()> {
756        let idx = self.sheet_index(sheet)?;
757        self.hydrate_form_controls(idx);
758        let controls = &mut self.sheet_form_controls[idx];
759        if index >= controls.len() {
760            return Err(Error::InvalidArgument(format!(
761                "form control index {index} out of bounds (sheet has {} controls)",
762                controls.len()
763            )));
764        }
765        controls.remove(index);
766        // Invalidate cached VML.
767        if idx < self.sheet_vml.len() {
768            self.sheet_vml[idx] = None;
769        }
770        Ok(())
771    }
772
773    /// Resolve an optional sheet name to a [`DefinedNameScope`](crate::defined_names::DefinedNameScope).
774    fn resolve_defined_name_scope(
775        &self,
776        scope: Option<&str>,
777    ) -> Result<crate::defined_names::DefinedNameScope> {
778        match scope {
779            None => Ok(crate::defined_names::DefinedNameScope::Workbook),
780            Some(sheet_name) => {
781                let idx = self.sheet_index(sheet_name)?;
782                Ok(crate::defined_names::DefinedNameScope::Sheet(idx as u32))
783            }
784        }
785    }
786
787    /// Render a worksheet to an SVG string.
788    ///
789    /// Produces a visual representation of the sheet's cells, styles, gridlines,
790    /// and headers. The `options` parameter controls which sheet, range, and
791    /// visual features to include.
792    pub fn render_to_svg(&self, options: &crate::render::RenderOptions) -> Result<String> {
793        let ws = self.worksheet_ref(&options.sheet_name)?;
794        crate::render::render_to_svg(ws, &self.sst_runtime, &self.stylesheet, options)
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use tempfile::TempDir;
802
803    #[test]
804    fn test_workbook_add_data_validation() {
805        let mut wb = Workbook::new();
806        let config =
807            crate::validation::DataValidationConfig::dropdown("A1:A100", &["Yes", "No", "Maybe"]);
808        wb.add_data_validation("Sheet1", &config).unwrap();
809
810        let validations = wb.get_data_validations("Sheet1").unwrap();
811        assert_eq!(validations.len(), 1);
812        assert_eq!(validations[0].sqref, "A1:A100");
813    }
814
815    #[test]
816    fn test_workbook_remove_data_validation() {
817        let mut wb = Workbook::new();
818        let config1 = crate::validation::DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
819        let config2 = crate::validation::DataValidationConfig::whole_number("B1:B100", 1, 100);
820        wb.add_data_validation("Sheet1", &config1).unwrap();
821        wb.add_data_validation("Sheet1", &config2).unwrap();
822
823        wb.remove_data_validation("Sheet1", "A1:A100").unwrap();
824
825        let validations = wb.get_data_validations("Sheet1").unwrap();
826        assert_eq!(validations.len(), 1);
827        assert_eq!(validations[0].sqref, "B1:B100");
828    }
829
830    #[test]
831    fn test_workbook_data_validation_sheet_not_found() {
832        let mut wb = Workbook::new();
833        let config = crate::validation::DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
834        let result = wb.add_data_validation("NoSheet", &config);
835        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
836    }
837
838    #[test]
839    fn test_workbook_data_validation_roundtrip() {
840        let dir = TempDir::new().unwrap();
841        let path = dir.path().join("validation_roundtrip.xlsx");
842
843        let mut wb = Workbook::new();
844        let config =
845            crate::validation::DataValidationConfig::dropdown("A1:A50", &["Red", "Blue", "Green"]);
846        wb.add_data_validation("Sheet1", &config).unwrap();
847        wb.save(&path).unwrap();
848
849        let wb2 = Workbook::open(&path).unwrap();
850        let validations = wb2.get_data_validations("Sheet1").unwrap();
851        assert_eq!(validations.len(), 1);
852        assert_eq!(validations[0].sqref, "A1:A50");
853        assert_eq!(
854            validations[0].validation_type,
855            crate::validation::ValidationType::List
856        );
857    }
858
859    #[test]
860    fn test_workbook_add_comment() {
861        let mut wb = Workbook::new();
862        let config = crate::comment::CommentConfig {
863            cell: "A1".to_string(),
864            author: "Alice".to_string(),
865            text: "Test comment".to_string(),
866        };
867        wb.add_comment("Sheet1", &config).unwrap();
868
869        let comments = wb.get_comments("Sheet1").unwrap();
870        assert_eq!(comments.len(), 1);
871        assert_eq!(comments[0].cell, "A1");
872        assert_eq!(comments[0].author, "Alice");
873        assert_eq!(comments[0].text, "Test comment");
874    }
875
876    #[test]
877    fn test_workbook_remove_comment() {
878        let mut wb = Workbook::new();
879        let config = crate::comment::CommentConfig {
880            cell: "A1".to_string(),
881            author: "Alice".to_string(),
882            text: "Test comment".to_string(),
883        };
884        wb.add_comment("Sheet1", &config).unwrap();
885        wb.remove_comment("Sheet1", "A1").unwrap();
886
887        let comments = wb.get_comments("Sheet1").unwrap();
888        assert!(comments.is_empty());
889    }
890
891    #[test]
892    fn test_workbook_multiple_comments() {
893        let mut wb = Workbook::new();
894        wb.add_comment(
895            "Sheet1",
896            &crate::comment::CommentConfig {
897                cell: "A1".to_string(),
898                author: "Alice".to_string(),
899                text: "First".to_string(),
900            },
901        )
902        .unwrap();
903        wb.add_comment(
904            "Sheet1",
905            &crate::comment::CommentConfig {
906                cell: "B2".to_string(),
907                author: "Bob".to_string(),
908                text: "Second".to_string(),
909            },
910        )
911        .unwrap();
912
913        let comments = wb.get_comments("Sheet1").unwrap();
914        assert_eq!(comments.len(), 2);
915    }
916
917    #[test]
918    fn test_workbook_comment_sheet_not_found() {
919        let mut wb = Workbook::new();
920        let config = crate::comment::CommentConfig {
921            cell: "A1".to_string(),
922            author: "Alice".to_string(),
923            text: "Test".to_string(),
924        };
925        let result = wb.add_comment("NoSheet", &config);
926        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
927    }
928
929    #[test]
930    fn test_workbook_comment_roundtrip() {
931        let dir = TempDir::new().unwrap();
932        let path = dir.path().join("comment_roundtrip.xlsx");
933
934        let mut wb = Workbook::new();
935        wb.add_comment(
936            "Sheet1",
937            &crate::comment::CommentConfig {
938                cell: "A1".to_string(),
939                author: "Author".to_string(),
940                text: "A saved comment".to_string(),
941            },
942        )
943        .unwrap();
944        wb.save(&path).unwrap();
945
946        // Verify the comments XML was written to the ZIP.
947        let file = std::fs::File::open(&path).unwrap();
948        let mut archive = zip::ZipArchive::new(file).unwrap();
949        assert!(
950            archive.by_name("xl/comments1.xml").is_ok(),
951            "comments1.xml should be present in the ZIP"
952        );
953    }
954
955    #[test]
956    fn test_workbook_comment_roundtrip_open() {
957        let dir = TempDir::new().unwrap();
958        let path = dir.path().join("comment_roundtrip_open.xlsx");
959
960        let mut wb = Workbook::new();
961        wb.add_comment(
962            "Sheet1",
963            &crate::comment::CommentConfig {
964                cell: "A1".to_string(),
965                author: "Author".to_string(),
966                text: "Persist me".to_string(),
967            },
968        )
969        .unwrap();
970        wb.save(&path).unwrap();
971
972        let wb2 = Workbook::open(&path).unwrap();
973        let comments = wb2.get_comments("Sheet1").unwrap();
974        assert_eq!(comments.len(), 1);
975        assert_eq!(comments[0].cell, "A1");
976        assert_eq!(comments[0].author, "Author");
977        assert_eq!(comments[0].text, "Persist me");
978    }
979
980    #[test]
981    fn test_workbook_comment_produces_vml_part() {
982        let dir = TempDir::new().unwrap();
983        let path = dir.path().join("comment_vml.xlsx");
984
985        let mut wb = Workbook::new();
986        wb.add_comment(
987            "Sheet1",
988            &crate::comment::CommentConfig {
989                cell: "B3".to_string(),
990                author: "Tester".to_string(),
991                text: "VML check".to_string(),
992            },
993        )
994        .unwrap();
995        wb.save(&path).unwrap();
996
997        let file = std::fs::File::open(&path).unwrap();
998        let mut archive = zip::ZipArchive::new(file).unwrap();
999        assert!(
1000            archive.by_name("xl/drawings/vmlDrawing1.vml").is_ok(),
1001            "vmlDrawing1.vml should be present in the ZIP"
1002        );
1003
1004        // Verify the VML content references the correct cell.
1005        let mut vml_data = Vec::new();
1006        archive
1007            .by_name("xl/drawings/vmlDrawing1.vml")
1008            .unwrap()
1009            .read_to_end(&mut vml_data)
1010            .unwrap();
1011        let vml_str = String::from_utf8(vml_data).unwrap();
1012        assert!(vml_str.contains("<x:Row>2</x:Row>"));
1013        assert!(vml_str.contains("<x:Column>1</x:Column>"));
1014        assert!(vml_str.contains("ObjectType=\"Note\""));
1015    }
1016
1017    #[test]
1018    fn test_workbook_comment_vml_roundtrip_open() {
1019        let dir = TempDir::new().unwrap();
1020        let path = dir.path().join("comment_vml_roundtrip.xlsx");
1021
1022        let mut wb = Workbook::new();
1023        wb.add_comment(
1024            "Sheet1",
1025            &crate::comment::CommentConfig {
1026                cell: "A1".to_string(),
1027                author: "Author".to_string(),
1028                text: "Roundtrip VML".to_string(),
1029            },
1030        )
1031        .unwrap();
1032        wb.save(&path).unwrap();
1033
1034        // Reopen and re-save.
1035        let wb2 = Workbook::open(&path).unwrap();
1036        let path2 = dir.path().join("comment_vml_roundtrip2.xlsx");
1037        wb2.save(&path2).unwrap();
1038
1039        // Verify VML part is preserved through the round-trip.
1040        let file = std::fs::File::open(&path2).unwrap();
1041        let mut archive = zip::ZipArchive::new(file).unwrap();
1042        assert!(archive.by_name("xl/drawings/vmlDrawing1.vml").is_ok());
1043
1044        // Comments should still be readable.
1045        let wb3 = Workbook::open(&path2).unwrap();
1046        let comments = wb3.get_comments("Sheet1").unwrap();
1047        assert_eq!(comments.len(), 1);
1048        assert_eq!(comments[0].text, "Roundtrip VML");
1049    }
1050
1051    #[test]
1052    fn test_workbook_comment_vml_legacy_drawing_ref() {
1053        let dir = TempDir::new().unwrap();
1054        let path = dir.path().join("comment_vml_legacy_ref.xlsx");
1055
1056        let mut wb = Workbook::new();
1057        wb.add_comment(
1058            "Sheet1",
1059            &crate::comment::CommentConfig {
1060                cell: "C5".to_string(),
1061                author: "Author".to_string(),
1062                text: "Legacy drawing test".to_string(),
1063            },
1064        )
1065        .unwrap();
1066        wb.save(&path).unwrap();
1067
1068        // Verify the worksheet XML contains a legacyDrawing element.
1069        let file = std::fs::File::open(&path).unwrap();
1070        let mut archive = zip::ZipArchive::new(file).unwrap();
1071        let mut ws_data = Vec::new();
1072        archive
1073            .by_name("xl/worksheets/sheet1.xml")
1074            .unwrap()
1075            .read_to_end(&mut ws_data)
1076            .unwrap();
1077        let ws_str = String::from_utf8(ws_data).unwrap();
1078        assert!(
1079            ws_str.contains("legacyDrawing"),
1080            "worksheet should contain legacyDrawing element"
1081        );
1082    }
1083
1084    #[test]
1085    fn test_workbook_comment_vml_cleanup_on_last_remove() {
1086        let dir = TempDir::new().unwrap();
1087        let path = dir.path().join("comment_vml_cleanup.xlsx");
1088
1089        let mut wb = Workbook::new();
1090        wb.add_comment(
1091            "Sheet1",
1092            &crate::comment::CommentConfig {
1093                cell: "A1".to_string(),
1094                author: "Author".to_string(),
1095                text: "Will be removed".to_string(),
1096            },
1097        )
1098        .unwrap();
1099        wb.remove_comment("Sheet1", "A1").unwrap();
1100        wb.save(&path).unwrap();
1101
1102        // Verify no VML part when all comments are removed.
1103        let file = std::fs::File::open(&path).unwrap();
1104        let mut archive = zip::ZipArchive::new(file).unwrap();
1105        assert!(
1106            archive.by_name("xl/drawings/vmlDrawing1.vml").is_err(),
1107            "vmlDrawing1.vml should not be present when there are no comments"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_workbook_multiple_comments_vml() {
1113        let dir = TempDir::new().unwrap();
1114        let path = dir.path().join("multi_comment_vml.xlsx");
1115
1116        let mut wb = Workbook::new();
1117        wb.add_comment(
1118            "Sheet1",
1119            &crate::comment::CommentConfig {
1120                cell: "A1".to_string(),
1121                author: "Alice".to_string(),
1122                text: "First".to_string(),
1123            },
1124        )
1125        .unwrap();
1126        wb.add_comment(
1127            "Sheet1",
1128            &crate::comment::CommentConfig {
1129                cell: "D10".to_string(),
1130                author: "Bob".to_string(),
1131                text: "Second".to_string(),
1132            },
1133        )
1134        .unwrap();
1135        wb.save(&path).unwrap();
1136
1137        let file = std::fs::File::open(&path).unwrap();
1138        let mut archive = zip::ZipArchive::new(file).unwrap();
1139        let mut vml_data = Vec::new();
1140        archive
1141            .by_name("xl/drawings/vmlDrawing1.vml")
1142            .unwrap()
1143            .read_to_end(&mut vml_data)
1144            .unwrap();
1145        let vml_str = String::from_utf8(vml_data).unwrap();
1146        // Should have two shapes.
1147        assert!(vml_str.contains("_x0000_s1025"));
1148        assert!(vml_str.contains("_x0000_s1026"));
1149    }
1150
1151    #[test]
1152    fn test_workbook_set_auto_filter() {
1153        let mut wb = Workbook::new();
1154        wb.set_auto_filter("Sheet1", "A1:D10").unwrap();
1155
1156        let ws = wb.worksheet_ref("Sheet1").unwrap();
1157        assert!(ws.auto_filter.is_some());
1158        assert_eq!(ws.auto_filter.as_ref().unwrap().reference, "A1:D10");
1159    }
1160
1161    #[test]
1162    fn test_workbook_remove_auto_filter() {
1163        let mut wb = Workbook::new();
1164        wb.set_auto_filter("Sheet1", "A1:D10").unwrap();
1165        wb.remove_auto_filter("Sheet1").unwrap();
1166
1167        let ws = wb.worksheet_ref("Sheet1").unwrap();
1168        assert!(ws.auto_filter.is_none());
1169    }
1170
1171    #[test]
1172    fn test_workbook_auto_filter_sheet_not_found() {
1173        let mut wb = Workbook::new();
1174        let result = wb.set_auto_filter("NoSheet", "A1:D10");
1175        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1176    }
1177
1178    #[test]
1179    fn test_workbook_auto_filter_roundtrip() {
1180        let dir = TempDir::new().unwrap();
1181        let path = dir.path().join("autofilter_roundtrip.xlsx");
1182
1183        let mut wb = Workbook::new();
1184        wb.set_auto_filter("Sheet1", "A1:C50").unwrap();
1185        wb.save(&path).unwrap();
1186
1187        let wb2 = Workbook::open(&path).unwrap();
1188        let ws = wb2.worksheet_ref("Sheet1").unwrap();
1189        assert!(ws.auto_filter.is_some());
1190        assert_eq!(ws.auto_filter.as_ref().unwrap().reference, "A1:C50");
1191    }
1192
1193    #[test]
1194    fn test_protect_unprotect_workbook() {
1195        let mut wb = Workbook::new();
1196        assert!(!wb.is_workbook_protected());
1197
1198        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
1199            password: None,
1200            lock_structure: true,
1201            lock_windows: false,
1202            lock_revision: false,
1203        });
1204        assert!(wb.is_workbook_protected());
1205
1206        wb.unprotect_workbook();
1207        assert!(!wb.is_workbook_protected());
1208    }
1209
1210    #[test]
1211    fn test_protect_workbook_with_password() {
1212        let mut wb = Workbook::new();
1213        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
1214            password: Some("secret".to_string()),
1215            lock_structure: true,
1216            lock_windows: false,
1217            lock_revision: false,
1218        });
1219
1220        let prot = wb.workbook_xml.workbook_protection.as_ref().unwrap();
1221        assert!(prot.workbook_password.is_some());
1222        let hash_str = prot.workbook_password.as_ref().unwrap();
1223        // Should be a 4-character uppercase hex string
1224        assert_eq!(hash_str.len(), 4);
1225        assert!(hash_str.chars().all(|c| c.is_ascii_hexdigit()));
1226        assert_eq!(prot.lock_structure, Some(true));
1227    }
1228
1229    #[test]
1230    fn test_protect_workbook_structure_only() {
1231        let mut wb = Workbook::new();
1232        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
1233            password: None,
1234            lock_structure: true,
1235            lock_windows: false,
1236            lock_revision: false,
1237        });
1238
1239        let prot = wb.workbook_xml.workbook_protection.as_ref().unwrap();
1240        assert!(prot.workbook_password.is_none());
1241        assert_eq!(prot.lock_structure, Some(true));
1242        assert!(prot.lock_windows.is_none());
1243        assert!(prot.lock_revision.is_none());
1244    }
1245
1246    #[test]
1247    fn test_protect_workbook_save_roundtrip() {
1248        let dir = TempDir::new().unwrap();
1249        let path = dir.path().join("protected.xlsx");
1250
1251        let mut wb = Workbook::new();
1252        wb.protect_workbook(crate::protection::WorkbookProtectionConfig {
1253            password: Some("hello".to_string()),
1254            lock_structure: true,
1255            lock_windows: true,
1256            lock_revision: false,
1257        });
1258        wb.save(&path).unwrap();
1259
1260        let wb2 = Workbook::open(&path).unwrap();
1261        assert!(wb2.is_workbook_protected());
1262        let prot = wb2.workbook_xml.workbook_protection.as_ref().unwrap();
1263        assert!(prot.workbook_password.is_some());
1264        assert_eq!(prot.lock_structure, Some(true));
1265        assert_eq!(prot.lock_windows, Some(true));
1266    }
1267
1268    #[test]
1269    fn test_is_workbook_protected() {
1270        let wb = Workbook::new();
1271        assert!(!wb.is_workbook_protected());
1272
1273        let mut wb2 = Workbook::new();
1274        wb2.protect_workbook(crate::protection::WorkbookProtectionConfig {
1275            password: None,
1276            lock_structure: false,
1277            lock_windows: false,
1278            lock_revision: false,
1279        });
1280        // Even with no locks, the protection element is present
1281        assert!(wb2.is_workbook_protected());
1282    }
1283
1284    #[test]
1285    fn test_unprotect_already_unprotected() {
1286        let mut wb = Workbook::new();
1287        assert!(!wb.is_workbook_protected());
1288        // Should be a no-op, not panic
1289        wb.unprotect_workbook();
1290        assert!(!wb.is_workbook_protected());
1291    }
1292
1293    #[test]
1294    fn test_set_and_get_external_hyperlink() {
1295        use crate::hyperlink::HyperlinkType;
1296
1297        let mut wb = Workbook::new();
1298        wb.set_cell_hyperlink(
1299            "Sheet1",
1300            "A1",
1301            HyperlinkType::External("https://example.com".to_string()),
1302            Some("Example"),
1303            Some("Visit Example"),
1304        )
1305        .unwrap();
1306
1307        let info = wb.get_cell_hyperlink("Sheet1", "A1").unwrap().unwrap();
1308        assert_eq!(
1309            info.link_type,
1310            HyperlinkType::External("https://example.com".to_string())
1311        );
1312        assert_eq!(info.display, Some("Example".to_string()));
1313        assert_eq!(info.tooltip, Some("Visit Example".to_string()));
1314    }
1315
1316    #[test]
1317    fn test_set_and_get_internal_hyperlink() {
1318        use crate::hyperlink::HyperlinkType;
1319
1320        let mut wb = Workbook::new();
1321        wb.new_sheet("Data").unwrap();
1322        wb.set_cell_hyperlink(
1323            "Sheet1",
1324            "B2",
1325            HyperlinkType::Internal("Data!A1".to_string()),
1326            Some("Go to Data"),
1327            None,
1328        )
1329        .unwrap();
1330
1331        let info = wb.get_cell_hyperlink("Sheet1", "B2").unwrap().unwrap();
1332        assert_eq!(
1333            info.link_type,
1334            HyperlinkType::Internal("Data!A1".to_string())
1335        );
1336        assert_eq!(info.display, Some("Go to Data".to_string()));
1337    }
1338
1339    #[test]
1340    fn test_set_and_get_email_hyperlink() {
1341        use crate::hyperlink::HyperlinkType;
1342
1343        let mut wb = Workbook::new();
1344        wb.set_cell_hyperlink(
1345            "Sheet1",
1346            "C3",
1347            HyperlinkType::Email("mailto:user@example.com".to_string()),
1348            None,
1349            None,
1350        )
1351        .unwrap();
1352
1353        let info = wb.get_cell_hyperlink("Sheet1", "C3").unwrap().unwrap();
1354        assert_eq!(
1355            info.link_type,
1356            HyperlinkType::Email("mailto:user@example.com".to_string())
1357        );
1358    }
1359
1360    #[test]
1361    fn test_delete_hyperlink_via_workbook() {
1362        use crate::hyperlink::HyperlinkType;
1363
1364        let mut wb = Workbook::new();
1365        wb.set_cell_hyperlink(
1366            "Sheet1",
1367            "A1",
1368            HyperlinkType::External("https://example.com".to_string()),
1369            None,
1370            None,
1371        )
1372        .unwrap();
1373
1374        wb.delete_cell_hyperlink("Sheet1", "A1").unwrap();
1375
1376        let info = wb.get_cell_hyperlink("Sheet1", "A1").unwrap();
1377        assert!(info.is_none());
1378    }
1379
1380    #[test]
1381    fn test_hyperlink_roundtrip_save_open() {
1382        use crate::hyperlink::HyperlinkType;
1383
1384        let dir = TempDir::new().unwrap();
1385        let path = dir.path().join("hyperlink.xlsx");
1386
1387        let mut wb = Workbook::new();
1388        wb.set_cell_hyperlink(
1389            "Sheet1",
1390            "A1",
1391            HyperlinkType::External("https://rust-lang.org".to_string()),
1392            Some("Rust"),
1393            Some("Rust Homepage"),
1394        )
1395        .unwrap();
1396        wb.set_cell_hyperlink(
1397            "Sheet1",
1398            "B1",
1399            HyperlinkType::Internal("Sheet1!C1".to_string()),
1400            Some("Go to C1"),
1401            None,
1402        )
1403        .unwrap();
1404        wb.set_cell_hyperlink(
1405            "Sheet1",
1406            "C1",
1407            HyperlinkType::Email("mailto:hello@example.com".to_string()),
1408            Some("Email"),
1409            None,
1410        )
1411        .unwrap();
1412        wb.save(&path).unwrap();
1413
1414        let wb2 = Workbook::open(&path).unwrap();
1415
1416        // External link roundtrip.
1417        let a1 = wb2.get_cell_hyperlink("Sheet1", "A1").unwrap().unwrap();
1418        assert_eq!(
1419            a1.link_type,
1420            HyperlinkType::External("https://rust-lang.org".to_string())
1421        );
1422        assert_eq!(a1.display, Some("Rust".to_string()));
1423        assert_eq!(a1.tooltip, Some("Rust Homepage".to_string()));
1424
1425        // Internal link roundtrip.
1426        let b1 = wb2.get_cell_hyperlink("Sheet1", "B1").unwrap().unwrap();
1427        assert_eq!(
1428            b1.link_type,
1429            HyperlinkType::Internal("Sheet1!C1".to_string())
1430        );
1431        assert_eq!(b1.display, Some("Go to C1".to_string()));
1432
1433        // Email link roundtrip.
1434        let c1 = wb2.get_cell_hyperlink("Sheet1", "C1").unwrap().unwrap();
1435        assert_eq!(
1436            c1.link_type,
1437            HyperlinkType::Email("mailto:hello@example.com".to_string())
1438        );
1439        assert_eq!(c1.display, Some("Email".to_string()));
1440    }
1441
1442    #[test]
1443    fn test_hyperlink_on_nonexistent_sheet() {
1444        use crate::hyperlink::HyperlinkType;
1445
1446        let mut wb = Workbook::new();
1447        let result = wb.set_cell_hyperlink(
1448            "NoSheet",
1449            "A1",
1450            HyperlinkType::External("https://example.com".to_string()),
1451            None,
1452            None,
1453        );
1454        assert!(result.is_err());
1455    }
1456
1457    #[test]
1458    fn test_set_defined_name_workbook_scope() {
1459        let mut wb = Workbook::new();
1460        wb.set_defined_name("SalesData", "Sheet1!$A$1:$D$10", None, None)
1461            .unwrap();
1462
1463        let info = wb.get_defined_name("SalesData", None).unwrap().unwrap();
1464        assert_eq!(info.name, "SalesData");
1465        assert_eq!(info.value, "Sheet1!$A$1:$D$10");
1466        assert_eq!(info.scope, crate::defined_names::DefinedNameScope::Workbook);
1467        assert!(info.comment.is_none());
1468    }
1469
1470    #[test]
1471    fn test_set_defined_name_sheet_scope() {
1472        let mut wb = Workbook::new();
1473        wb.set_defined_name("LocalRange", "Sheet1!$B$2:$C$5", Some("Sheet1"), None)
1474            .unwrap();
1475
1476        let info = wb
1477            .get_defined_name("LocalRange", Some("Sheet1"))
1478            .unwrap()
1479            .unwrap();
1480        assert_eq!(info.name, "LocalRange");
1481        assert_eq!(info.value, "Sheet1!$B$2:$C$5");
1482        assert_eq!(info.scope, crate::defined_names::DefinedNameScope::Sheet(0));
1483    }
1484
1485    #[test]
1486    fn test_update_existing_defined_name() {
1487        let mut wb = Workbook::new();
1488        wb.set_defined_name("DataRange", "Sheet1!$A$1:$A$10", None, None)
1489            .unwrap();
1490
1491        wb.set_defined_name("DataRange", "Sheet1!$A$1:$A$50", None, Some("Updated"))
1492            .unwrap();
1493
1494        let all = wb.get_all_defined_names();
1495        assert_eq!(all.len(), 1, "should not duplicate the entry");
1496        assert_eq!(all[0].value, "Sheet1!$A$1:$A$50");
1497        assert_eq!(all[0].comment, Some("Updated".to_string()));
1498    }
1499
1500    #[test]
1501    fn test_get_all_defined_names() {
1502        let mut wb = Workbook::new();
1503        wb.new_sheet("Sheet2").unwrap();
1504
1505        wb.set_defined_name("Alpha", "Sheet1!$A$1", None, None)
1506            .unwrap();
1507        wb.set_defined_name("Beta", "Sheet1!$B$1", Some("Sheet1"), None)
1508            .unwrap();
1509        wb.set_defined_name("Gamma", "Sheet2!$C$1", Some("Sheet2"), None)
1510            .unwrap();
1511
1512        let all = wb.get_all_defined_names();
1513        assert_eq!(all.len(), 3);
1514        assert_eq!(all[0].name, "Alpha");
1515        assert_eq!(all[1].name, "Beta");
1516        assert_eq!(all[2].name, "Gamma");
1517    }
1518
1519    #[test]
1520    fn test_delete_defined_name() {
1521        let mut wb = Workbook::new();
1522        wb.set_defined_name("ToDelete", "Sheet1!$A$1", None, None)
1523            .unwrap();
1524        assert!(wb.get_defined_name("ToDelete", None).unwrap().is_some());
1525
1526        wb.delete_defined_name("ToDelete", None).unwrap();
1527        assert!(wb.get_defined_name("ToDelete", None).unwrap().is_none());
1528    }
1529
1530    #[test]
1531    fn test_delete_nonexistent_defined_name_returns_error() {
1532        let mut wb = Workbook::new();
1533        let result = wb.delete_defined_name("Ghost", None);
1534        assert!(result.is_err());
1535        assert!(result.unwrap_err().to_string().contains("Ghost"));
1536    }
1537
1538    #[test]
1539    fn test_defined_name_sheet_scope_requires_existing_sheet() {
1540        let mut wb = Workbook::new();
1541        let result = wb.set_defined_name("TestName", "Sheet1!$A$1", Some("NonExistent"), None);
1542        assert!(result.is_err());
1543        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1544    }
1545
1546    #[test]
1547    fn test_defined_name_roundtrip() {
1548        let dir = TempDir::new().unwrap();
1549        let path = dir.path().join("defined_names.xlsx");
1550
1551        let mut wb = Workbook::new();
1552        wb.set_defined_name("Revenue", "Sheet1!$E$1:$E$100", None, Some("Total revenue"))
1553            .unwrap();
1554        wb.set_defined_name("LocalName", "Sheet1!$A$1", Some("Sheet1"), None)
1555            .unwrap();
1556        wb.save(&path).unwrap();
1557
1558        let wb2 = Workbook::open(&path).unwrap();
1559        let all = wb2.get_all_defined_names();
1560        assert_eq!(all.len(), 2);
1561        assert_eq!(all[0].name, "Revenue");
1562        assert_eq!(all[0].value, "Sheet1!$E$1:$E$100");
1563        assert_eq!(all[0].comment, Some("Total revenue".to_string()));
1564        assert_eq!(all[1].name, "LocalName");
1565        assert_eq!(all[1].value, "Sheet1!$A$1");
1566        assert_eq!(
1567            all[1].scope,
1568            crate::defined_names::DefinedNameScope::Sheet(0)
1569        );
1570    }
1571
1572    #[test]
1573    fn test_protect_sheet_via_workbook() {
1574        let mut wb = Workbook::new();
1575        let config = crate::sheet::SheetProtectionConfig::default();
1576        wb.protect_sheet("Sheet1", &config).unwrap();
1577
1578        assert!(wb.is_sheet_protected("Sheet1").unwrap());
1579    }
1580
1581    #[test]
1582    fn test_unprotect_sheet_via_workbook() {
1583        let mut wb = Workbook::new();
1584        let config = crate::sheet::SheetProtectionConfig::default();
1585        wb.protect_sheet("Sheet1", &config).unwrap();
1586        assert!(wb.is_sheet_protected("Sheet1").unwrap());
1587
1588        wb.unprotect_sheet("Sheet1").unwrap();
1589        assert!(!wb.is_sheet_protected("Sheet1").unwrap());
1590    }
1591
1592    #[test]
1593    fn test_protect_sheet_nonexistent_returns_error() {
1594        let mut wb = Workbook::new();
1595        let config = crate::sheet::SheetProtectionConfig::default();
1596        let result = wb.protect_sheet("NoSuchSheet", &config);
1597        assert!(result.is_err());
1598        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1599    }
1600
1601    #[test]
1602    fn test_is_sheet_protected_nonexistent_returns_error() {
1603        let wb = Workbook::new();
1604        let result = wb.is_sheet_protected("NoSuchSheet");
1605        assert!(result.is_err());
1606    }
1607
1608    #[test]
1609    fn test_protect_sheet_with_password_and_permissions() {
1610        let mut wb = Workbook::new();
1611        let config = crate::sheet::SheetProtectionConfig {
1612            password: Some("secret".to_string()),
1613            format_cells: true,
1614            insert_rows: true,
1615            sort: true,
1616            ..crate::sheet::SheetProtectionConfig::default()
1617        };
1618        wb.protect_sheet("Sheet1", &config).unwrap();
1619        assert!(wb.is_sheet_protected("Sheet1").unwrap());
1620    }
1621
1622    #[test]
1623    fn test_sheet_protection_roundtrip() {
1624        let dir = TempDir::new().unwrap();
1625        let path = dir.path().join("sheet_protection.xlsx");
1626
1627        let mut wb = Workbook::new();
1628        let config = crate::sheet::SheetProtectionConfig {
1629            password: Some("pass".to_string()),
1630            format_cells: true,
1631            ..crate::sheet::SheetProtectionConfig::default()
1632        };
1633        wb.protect_sheet("Sheet1", &config).unwrap();
1634        wb.save(&path).unwrap();
1635
1636        let wb2 = Workbook::open(&path).unwrap();
1637        assert!(wb2.is_sheet_protected("Sheet1").unwrap());
1638    }
1639
1640    #[test]
1641    fn test_add_table() {
1642        use crate::table::{TableColumn, TableConfig};
1643
1644        let mut wb = Workbook::new();
1645        let config = TableConfig {
1646            name: "Sales".to_string(),
1647            display_name: "Sales".to_string(),
1648            range: "A1:C5".to_string(),
1649            columns: vec![
1650                TableColumn {
1651                    name: "Product".to_string(),
1652                    totals_row_function: None,
1653                    totals_row_label: None,
1654                },
1655                TableColumn {
1656                    name: "Quantity".to_string(),
1657                    totals_row_function: None,
1658                    totals_row_label: None,
1659                },
1660                TableColumn {
1661                    name: "Price".to_string(),
1662                    totals_row_function: None,
1663                    totals_row_label: None,
1664                },
1665            ],
1666            show_header_row: true,
1667            style_name: Some("TableStyleMedium2".to_string()),
1668            auto_filter: true,
1669            ..TableConfig::default()
1670        };
1671        wb.add_table("Sheet1", &config).unwrap();
1672
1673        let tables = wb.get_tables("Sheet1").unwrap();
1674        assert_eq!(tables.len(), 1);
1675        assert_eq!(tables[0].name, "Sales");
1676        assert_eq!(tables[0].display_name, "Sales");
1677        assert_eq!(tables[0].range, "A1:C5");
1678        assert_eq!(tables[0].columns, vec!["Product", "Quantity", "Price"]);
1679        assert!(tables[0].auto_filter);
1680        assert!(tables[0].show_header_row);
1681        assert_eq!(tables[0].style_name, Some("TableStyleMedium2".to_string()));
1682    }
1683
1684    #[test]
1685    fn test_add_table_duplicate_name_error() {
1686        use crate::table::{TableColumn, TableConfig};
1687
1688        let mut wb = Workbook::new();
1689        let config = TableConfig {
1690            name: "T1".to_string(),
1691            display_name: "T1".to_string(),
1692            range: "A1:B5".to_string(),
1693            columns: vec![TableColumn {
1694                name: "Col".to_string(),
1695                totals_row_function: None,
1696                totals_row_label: None,
1697            }],
1698            ..TableConfig::default()
1699        };
1700        wb.add_table("Sheet1", &config).unwrap();
1701        let result = wb.add_table("Sheet1", &config);
1702        assert!(matches!(
1703            result.unwrap_err(),
1704            Error::TableAlreadyExists { .. }
1705        ));
1706    }
1707
1708    #[test]
1709    fn test_add_table_invalid_config() {
1710        use crate::table::TableConfig;
1711
1712        let mut wb = Workbook::new();
1713        let config = TableConfig {
1714            name: String::new(),
1715            range: "A1:B5".to_string(),
1716            ..TableConfig::default()
1717        };
1718        assert!(wb.add_table("Sheet1", &config).is_err());
1719    }
1720
1721    #[test]
1722    fn test_add_table_sheet_not_found() {
1723        use crate::table::{TableColumn, TableConfig};
1724
1725        let mut wb = Workbook::new();
1726        let config = TableConfig {
1727            name: "T1".to_string(),
1728            display_name: "T1".to_string(),
1729            range: "A1:B5".to_string(),
1730            columns: vec![TableColumn {
1731                name: "Col".to_string(),
1732                totals_row_function: None,
1733                totals_row_label: None,
1734            }],
1735            ..TableConfig::default()
1736        };
1737        let result = wb.add_table("NoSheet", &config);
1738        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1739    }
1740
1741    #[test]
1742    fn test_get_tables_empty() {
1743        let wb = Workbook::new();
1744        let tables = wb.get_tables("Sheet1").unwrap();
1745        assert!(tables.is_empty());
1746    }
1747
1748    #[test]
1749    fn test_get_tables_sheet_not_found() {
1750        let wb = Workbook::new();
1751        let result = wb.get_tables("NoSheet");
1752        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1753    }
1754
1755    #[test]
1756    fn test_delete_table() {
1757        use crate::table::{TableColumn, TableConfig};
1758
1759        let mut wb = Workbook::new();
1760        let config = TableConfig {
1761            name: "T1".to_string(),
1762            display_name: "T1".to_string(),
1763            range: "A1:B5".to_string(),
1764            columns: vec![TableColumn {
1765                name: "Col".to_string(),
1766                totals_row_function: None,
1767                totals_row_label: None,
1768            }],
1769            ..TableConfig::default()
1770        };
1771        wb.add_table("Sheet1", &config).unwrap();
1772        assert_eq!(wb.get_tables("Sheet1").unwrap().len(), 1);
1773
1774        wb.delete_table("Sheet1", "T1").unwrap();
1775        assert!(wb.get_tables("Sheet1").unwrap().is_empty());
1776    }
1777
1778    #[test]
1779    fn test_delete_table_not_found() {
1780        let mut wb = Workbook::new();
1781        let result = wb.delete_table("Sheet1", "NoTable");
1782        assert!(matches!(result.unwrap_err(), Error::TableNotFound { .. }));
1783    }
1784
1785    #[test]
1786    fn test_delete_table_wrong_sheet() {
1787        use crate::table::{TableColumn, TableConfig};
1788
1789        let mut wb = Workbook::new();
1790        wb.new_sheet("Sheet2").unwrap();
1791        let config = TableConfig {
1792            name: "T1".to_string(),
1793            display_name: "T1".to_string(),
1794            range: "A1:B5".to_string(),
1795            columns: vec![TableColumn {
1796                name: "Col".to_string(),
1797                totals_row_function: None,
1798                totals_row_label: None,
1799            }],
1800            ..TableConfig::default()
1801        };
1802        wb.add_table("Sheet1", &config).unwrap();
1803
1804        let result = wb.delete_table("Sheet2", "T1");
1805        assert!(matches!(result.unwrap_err(), Error::TableNotFound { .. }));
1806        // Table should still exist on Sheet1.
1807        assert_eq!(wb.get_tables("Sheet1").unwrap().len(), 1);
1808    }
1809
1810    #[test]
1811    fn test_multiple_tables_on_sheet() {
1812        use crate::table::{TableColumn, TableConfig};
1813
1814        let mut wb = Workbook::new();
1815        let config1 = TableConfig {
1816            name: "T1".to_string(),
1817            display_name: "T1".to_string(),
1818            range: "A1:B5".to_string(),
1819            columns: vec![
1820                TableColumn {
1821                    name: "Name".to_string(),
1822                    totals_row_function: None,
1823                    totals_row_label: None,
1824                },
1825                TableColumn {
1826                    name: "Score".to_string(),
1827                    totals_row_function: None,
1828                    totals_row_label: None,
1829                },
1830            ],
1831            ..TableConfig::default()
1832        };
1833        let config2 = TableConfig {
1834            name: "T2".to_string(),
1835            display_name: "T2".to_string(),
1836            range: "D1:E5".to_string(),
1837            columns: vec![
1838                TableColumn {
1839                    name: "City".to_string(),
1840                    totals_row_function: None,
1841                    totals_row_label: None,
1842                },
1843                TableColumn {
1844                    name: "Population".to_string(),
1845                    totals_row_function: None,
1846                    totals_row_label: None,
1847                },
1848            ],
1849            ..TableConfig::default()
1850        };
1851        wb.add_table("Sheet1", &config1).unwrap();
1852        wb.add_table("Sheet1", &config2).unwrap();
1853
1854        let tables = wb.get_tables("Sheet1").unwrap();
1855        assert_eq!(tables.len(), 2);
1856        assert_eq!(tables[0].name, "T1");
1857        assert_eq!(tables[1].name, "T2");
1858    }
1859
1860    #[test]
1861    fn test_tables_on_different_sheets() {
1862        use crate::table::{TableColumn, TableConfig};
1863
1864        let mut wb = Workbook::new();
1865        wb.new_sheet("Sheet2").unwrap();
1866        let config1 = TableConfig {
1867            name: "T1".to_string(),
1868            display_name: "T1".to_string(),
1869            range: "A1:B5".to_string(),
1870            columns: vec![TableColumn {
1871                name: "Col1".to_string(),
1872                totals_row_function: None,
1873                totals_row_label: None,
1874            }],
1875            ..TableConfig::default()
1876        };
1877        let config2 = TableConfig {
1878            name: "T2".to_string(),
1879            display_name: "T2".to_string(),
1880            range: "A1:B5".to_string(),
1881            columns: vec![TableColumn {
1882                name: "Col2".to_string(),
1883                totals_row_function: None,
1884                totals_row_label: None,
1885            }],
1886            ..TableConfig::default()
1887        };
1888        wb.add_table("Sheet1", &config1).unwrap();
1889        wb.add_table("Sheet2", &config2).unwrap();
1890
1891        assert_eq!(wb.get_tables("Sheet1").unwrap().len(), 1);
1892        assert_eq!(wb.get_tables("Sheet2").unwrap().len(), 1);
1893        assert_eq!(wb.get_tables("Sheet1").unwrap()[0].name, "T1");
1894        assert_eq!(wb.get_tables("Sheet2").unwrap()[0].name, "T2");
1895    }
1896
1897    #[test]
1898    fn test_table_save_produces_zip_parts() {
1899        use crate::table::{TableColumn, TableConfig};
1900
1901        let dir = TempDir::new().unwrap();
1902        let path = dir.path().join("table_parts.xlsx");
1903
1904        let mut wb = Workbook::new();
1905        let config = TableConfig {
1906            name: "Sales".to_string(),
1907            display_name: "Sales".to_string(),
1908            range: "A1:C5".to_string(),
1909            columns: vec![
1910                TableColumn {
1911                    name: "Product".to_string(),
1912                    totals_row_function: None,
1913                    totals_row_label: None,
1914                },
1915                TableColumn {
1916                    name: "Qty".to_string(),
1917                    totals_row_function: None,
1918                    totals_row_label: None,
1919                },
1920                TableColumn {
1921                    name: "Price".to_string(),
1922                    totals_row_function: None,
1923                    totals_row_label: None,
1924                },
1925            ],
1926            style_name: Some("TableStyleMedium2".to_string()),
1927            ..TableConfig::default()
1928        };
1929        wb.add_table("Sheet1", &config).unwrap();
1930        wb.save(&path).unwrap();
1931
1932        let file = std::fs::File::open(&path).unwrap();
1933        let mut archive = zip::ZipArchive::new(file).unwrap();
1934
1935        assert!(
1936            archive.by_name("xl/tables/table1.xml").is_ok(),
1937            "table1.xml should be present in the ZIP"
1938        );
1939        assert!(
1940            archive
1941                .by_name("xl/worksheets/_rels/sheet1.xml.rels")
1942                .is_ok(),
1943            "worksheet rels should be present"
1944        );
1945
1946        // Verify table XML content.
1947        let mut table_data = Vec::new();
1948        archive
1949            .by_name("xl/tables/table1.xml")
1950            .unwrap()
1951            .read_to_end(&mut table_data)
1952            .unwrap();
1953        let table_str = String::from_utf8(table_data).unwrap();
1954        assert!(table_str.contains("Sales"));
1955        assert!(table_str.contains("A1:C5"));
1956        assert!(table_str.contains("TableStyleMedium2"));
1957        assert!(table_str.contains("autoFilter"));
1958        assert!(table_str.contains("tableColumn"));
1959
1960        // Verify worksheet XML has tableParts element.
1961        let mut ws_data = Vec::new();
1962        archive
1963            .by_name("xl/worksheets/sheet1.xml")
1964            .unwrap()
1965            .read_to_end(&mut ws_data)
1966            .unwrap();
1967        let ws_str = String::from_utf8(ws_data).unwrap();
1968        assert!(
1969            ws_str.contains("tableParts"),
1970            "worksheet should contain tableParts element"
1971        );
1972        assert!(
1973            ws_str.contains("tablePart"),
1974            "worksheet should contain tablePart reference"
1975        );
1976
1977        // Verify content types include the table.
1978        let mut ct_data = Vec::new();
1979        archive
1980            .by_name("[Content_Types].xml")
1981            .unwrap()
1982            .read_to_end(&mut ct_data)
1983            .unwrap();
1984        let ct_str = String::from_utf8(ct_data).unwrap();
1985        assert!(
1986            ct_str.contains("table+xml"),
1987            "content types should reference the table"
1988        );
1989
1990        // Verify worksheet rels include a table relationship.
1991        let mut rels_data = Vec::new();
1992        archive
1993            .by_name("xl/worksheets/_rels/sheet1.xml.rels")
1994            .unwrap()
1995            .read_to_end(&mut rels_data)
1996            .unwrap();
1997        let rels_str = String::from_utf8(rels_data).unwrap();
1998        assert!(
1999            rels_str.contains("relationships/table"),
2000            "worksheet rels should reference the table"
2001        );
2002    }
2003
2004    #[test]
2005    fn test_table_roundtrip_save_open() {
2006        use crate::table::{TableColumn, TableConfig};
2007
2008        let dir = TempDir::new().unwrap();
2009        let path = dir.path().join("table_roundtrip.xlsx");
2010
2011        let mut wb = Workbook::new();
2012        let config = TableConfig {
2013            name: "Inventory".to_string(),
2014            display_name: "Inventory".to_string(),
2015            range: "A1:D10".to_string(),
2016            columns: vec![
2017                TableColumn {
2018                    name: "Item".to_string(),
2019                    totals_row_function: None,
2020                    totals_row_label: None,
2021                },
2022                TableColumn {
2023                    name: "Stock".to_string(),
2024                    totals_row_function: None,
2025                    totals_row_label: None,
2026                },
2027                TableColumn {
2028                    name: "Price".to_string(),
2029                    totals_row_function: None,
2030                    totals_row_label: None,
2031                },
2032                TableColumn {
2033                    name: "Supplier".to_string(),
2034                    totals_row_function: None,
2035                    totals_row_label: None,
2036                },
2037            ],
2038            show_header_row: true,
2039            style_name: Some("TableStyleLight1".to_string()),
2040            auto_filter: true,
2041            ..TableConfig::default()
2042        };
2043        wb.add_table("Sheet1", &config).unwrap();
2044        wb.save(&path).unwrap();
2045
2046        let wb2 = Workbook::open(&path).unwrap();
2047        let tables = wb2.get_tables("Sheet1").unwrap();
2048        assert_eq!(tables.len(), 1);
2049        assert_eq!(tables[0].name, "Inventory");
2050        assert_eq!(tables[0].display_name, "Inventory");
2051        assert_eq!(tables[0].range, "A1:D10");
2052        assert_eq!(
2053            tables[0].columns,
2054            vec!["Item", "Stock", "Price", "Supplier"]
2055        );
2056        assert!(tables[0].auto_filter);
2057        assert!(tables[0].show_header_row);
2058        assert_eq!(tables[0].style_name, Some("TableStyleLight1".to_string()));
2059    }
2060
2061    #[test]
2062    fn test_table_roundtrip_multiple_tables() {
2063        use crate::table::{TableColumn, TableConfig};
2064
2065        let dir = TempDir::new().unwrap();
2066        let path = dir.path().join("multi_table_roundtrip.xlsx");
2067
2068        let mut wb = Workbook::new();
2069        wb.new_sheet("Sheet2").unwrap();
2070        wb.add_table(
2071            "Sheet1",
2072            &TableConfig {
2073                name: "T1".to_string(),
2074                display_name: "T1".to_string(),
2075                range: "A1:B5".to_string(),
2076                columns: vec![
2077                    TableColumn {
2078                        name: "Name".to_string(),
2079                        totals_row_function: None,
2080                        totals_row_label: None,
2081                    },
2082                    TableColumn {
2083                        name: "Value".to_string(),
2084                        totals_row_function: None,
2085                        totals_row_label: None,
2086                    },
2087                ],
2088                ..TableConfig::default()
2089            },
2090        )
2091        .unwrap();
2092        wb.add_table(
2093            "Sheet2",
2094            &TableConfig {
2095                name: "T2".to_string(),
2096                display_name: "T2".to_string(),
2097                range: "C1:D8".to_string(),
2098                columns: vec![
2099                    TableColumn {
2100                        name: "Category".to_string(),
2101                        totals_row_function: None,
2102                        totals_row_label: None,
2103                    },
2104                    TableColumn {
2105                        name: "Count".to_string(),
2106                        totals_row_function: None,
2107                        totals_row_label: None,
2108                    },
2109                ],
2110                auto_filter: false,
2111                ..TableConfig::default()
2112            },
2113        )
2114        .unwrap();
2115        wb.save(&path).unwrap();
2116
2117        let wb2 = Workbook::open(&path).unwrap();
2118        let t1 = wb2.get_tables("Sheet1").unwrap();
2119        assert_eq!(t1.len(), 1);
2120        assert_eq!(t1[0].name, "T1");
2121        assert_eq!(t1[0].range, "A1:B5");
2122
2123        let t2 = wb2.get_tables("Sheet2").unwrap();
2124        assert_eq!(t2.len(), 1);
2125        assert_eq!(t2[0].name, "T2");
2126        assert_eq!(t2[0].range, "C1:D8");
2127        assert!(!t2[0].auto_filter);
2128    }
2129
2130    #[test]
2131    fn test_table_roundtrip_resave() {
2132        use crate::table::{TableColumn, TableConfig};
2133
2134        let dir = TempDir::new().unwrap();
2135        let path1 = dir.path().join("table_resave1.xlsx");
2136        let path2 = dir.path().join("table_resave2.xlsx");
2137
2138        let mut wb = Workbook::new();
2139        wb.add_table(
2140            "Sheet1",
2141            &TableConfig {
2142                name: "T1".to_string(),
2143                display_name: "T1".to_string(),
2144                range: "A1:B3".to_string(),
2145                columns: vec![
2146                    TableColumn {
2147                        name: "X".to_string(),
2148                        totals_row_function: None,
2149                        totals_row_label: None,
2150                    },
2151                    TableColumn {
2152                        name: "Y".to_string(),
2153                        totals_row_function: None,
2154                        totals_row_label: None,
2155                    },
2156                ],
2157                ..TableConfig::default()
2158            },
2159        )
2160        .unwrap();
2161        wb.save(&path1).unwrap();
2162
2163        let wb2 = Workbook::open(&path1).unwrap();
2164        wb2.save(&path2).unwrap();
2165
2166        let wb3 = Workbook::open(&path2).unwrap();
2167        let tables = wb3.get_tables("Sheet1").unwrap();
2168        assert_eq!(tables.len(), 1);
2169        assert_eq!(tables[0].name, "T1");
2170        assert_eq!(tables[0].columns, vec!["X", "Y"]);
2171    }
2172
2173    #[test]
2174    fn test_auto_filter_not_regressed_by_tables() {
2175        let dir = TempDir::new().unwrap();
2176        let path = dir.path().join("autofilter_with_table.xlsx");
2177
2178        let mut wb = Workbook::new();
2179        wb.set_auto_filter("Sheet1", "A1:C50").unwrap();
2180        wb.save(&path).unwrap();
2181
2182        let wb2 = Workbook::open(&path).unwrap();
2183        let ws = wb2.worksheet_ref("Sheet1").unwrap();
2184        assert!(ws.auto_filter.is_some());
2185        assert_eq!(ws.auto_filter.as_ref().unwrap().reference, "A1:C50");
2186    }
2187
2188    #[test]
2189    fn test_delete_sheet_removes_tables() {
2190        use crate::table::{TableColumn, TableConfig};
2191
2192        let mut wb = Workbook::new();
2193        wb.new_sheet("Sheet2").unwrap();
2194        wb.add_table(
2195            "Sheet1",
2196            &TableConfig {
2197                name: "T1".to_string(),
2198                display_name: "T1".to_string(),
2199                range: "A1:B5".to_string(),
2200                columns: vec![TableColumn {
2201                    name: "Col".to_string(),
2202                    totals_row_function: None,
2203                    totals_row_label: None,
2204                }],
2205                ..TableConfig::default()
2206            },
2207        )
2208        .unwrap();
2209        wb.add_table(
2210            "Sheet2",
2211            &TableConfig {
2212                name: "T2".to_string(),
2213                display_name: "T2".to_string(),
2214                range: "A1:B5".to_string(),
2215                columns: vec![TableColumn {
2216                    name: "Col".to_string(),
2217                    totals_row_function: None,
2218                    totals_row_label: None,
2219                }],
2220                ..TableConfig::default()
2221            },
2222        )
2223        .unwrap();
2224
2225        wb.delete_sheet("Sheet1").unwrap();
2226        // T1 should be gone, T2 should still exist on Sheet2.
2227        let tables = wb.get_tables("Sheet2").unwrap();
2228        assert_eq!(tables.len(), 1);
2229        assert_eq!(tables[0].name, "T2");
2230    }
2231
2232    #[test]
2233    fn test_table_with_no_auto_filter() {
2234        use crate::table::{TableColumn, TableConfig};
2235
2236        let dir = TempDir::new().unwrap();
2237        let path = dir.path().join("table_no_filter.xlsx");
2238
2239        let mut wb = Workbook::new();
2240        wb.add_table(
2241            "Sheet1",
2242            &TableConfig {
2243                name: "Plain".to_string(),
2244                display_name: "Plain".to_string(),
2245                range: "A1:B3".to_string(),
2246                columns: vec![
2247                    TableColumn {
2248                        name: "A".to_string(),
2249                        totals_row_function: None,
2250                        totals_row_label: None,
2251                    },
2252                    TableColumn {
2253                        name: "B".to_string(),
2254                        totals_row_function: None,
2255                        totals_row_label: None,
2256                    },
2257                ],
2258                auto_filter: false,
2259                ..TableConfig::default()
2260            },
2261        )
2262        .unwrap();
2263        wb.save(&path).unwrap();
2264
2265        let wb2 = Workbook::open(&path).unwrap();
2266        let tables = wb2.get_tables("Sheet1").unwrap();
2267        assert_eq!(tables.len(), 1);
2268        assert!(!tables[0].auto_filter);
2269    }
2270
2271    #[test]
2272    fn test_set_and_get_sheet_view_options_defaults() {
2273        let wb = Workbook::new();
2274        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2275        assert_eq!(opts.show_gridlines, Some(true));
2276        assert_eq!(opts.show_formulas, Some(false));
2277        assert_eq!(opts.show_row_col_headers, Some(true));
2278        assert_eq!(opts.zoom_scale, Some(100));
2279        assert_eq!(opts.view_mode, Some(crate::sheet::ViewMode::Normal));
2280        assert!(opts.top_left_cell.is_none());
2281    }
2282
2283    #[test]
2284    fn test_set_sheet_view_options_gridlines_off() {
2285        let mut wb = Workbook::new();
2286        wb.set_sheet_view_options(
2287            "Sheet1",
2288            &crate::sheet::SheetViewOptions {
2289                show_gridlines: Some(false),
2290                ..Default::default()
2291            },
2292        )
2293        .unwrap();
2294
2295        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2296        assert_eq!(opts.show_gridlines, Some(false));
2297    }
2298
2299    #[test]
2300    fn test_set_sheet_view_options_zoom() {
2301        let mut wb = Workbook::new();
2302        wb.set_sheet_view_options(
2303            "Sheet1",
2304            &crate::sheet::SheetViewOptions {
2305                zoom_scale: Some(150),
2306                ..Default::default()
2307            },
2308        )
2309        .unwrap();
2310
2311        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2312        assert_eq!(opts.zoom_scale, Some(150));
2313    }
2314
2315    #[test]
2316    fn test_set_sheet_view_options_zoom_invalid_low() {
2317        let mut wb = Workbook::new();
2318        let result = wb.set_sheet_view_options(
2319            "Sheet1",
2320            &crate::sheet::SheetViewOptions {
2321                zoom_scale: Some(5),
2322                ..Default::default()
2323            },
2324        );
2325        assert!(result.is_err());
2326    }
2327
2328    #[test]
2329    fn test_set_sheet_view_options_zoom_invalid_high() {
2330        let mut wb = Workbook::new();
2331        let result = wb.set_sheet_view_options(
2332            "Sheet1",
2333            &crate::sheet::SheetViewOptions {
2334                zoom_scale: Some(500),
2335                ..Default::default()
2336            },
2337        );
2338        assert!(result.is_err());
2339    }
2340
2341    #[test]
2342    fn test_set_sheet_view_options_view_mode() {
2343        let mut wb = Workbook::new();
2344        wb.set_sheet_view_options(
2345            "Sheet1",
2346            &crate::sheet::SheetViewOptions {
2347                view_mode: Some(crate::sheet::ViewMode::PageBreak),
2348                ..Default::default()
2349            },
2350        )
2351        .unwrap();
2352
2353        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2354        assert_eq!(opts.view_mode, Some(crate::sheet::ViewMode::PageBreak));
2355    }
2356
2357    #[test]
2358    fn test_set_sheet_view_options_page_layout() {
2359        let mut wb = Workbook::new();
2360        wb.set_sheet_view_options(
2361            "Sheet1",
2362            &crate::sheet::SheetViewOptions {
2363                view_mode: Some(crate::sheet::ViewMode::PageLayout),
2364                ..Default::default()
2365            },
2366        )
2367        .unwrap();
2368
2369        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2370        assert_eq!(opts.view_mode, Some(crate::sheet::ViewMode::PageLayout));
2371    }
2372
2373    #[test]
2374    fn test_set_sheet_view_options_show_formulas() {
2375        let mut wb = Workbook::new();
2376        wb.set_sheet_view_options(
2377            "Sheet1",
2378            &crate::sheet::SheetViewOptions {
2379                show_formulas: Some(true),
2380                ..Default::default()
2381            },
2382        )
2383        .unwrap();
2384
2385        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2386        assert_eq!(opts.show_formulas, Some(true));
2387    }
2388
2389    #[test]
2390    fn test_set_sheet_view_options_top_left_cell() {
2391        let mut wb = Workbook::new();
2392        wb.set_sheet_view_options(
2393            "Sheet1",
2394            &crate::sheet::SheetViewOptions {
2395                top_left_cell: Some("C10".to_string()),
2396                ..Default::default()
2397            },
2398        )
2399        .unwrap();
2400
2401        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2402        assert_eq!(opts.top_left_cell, Some("C10".to_string()));
2403    }
2404
2405    #[test]
2406    fn test_sheet_view_options_roundtrip() {
2407        let dir = TempDir::new().unwrap();
2408        let path = dir.path().join("view_opts.xlsx");
2409
2410        let mut wb = Workbook::new();
2411        wb.set_sheet_view_options(
2412            "Sheet1",
2413            &crate::sheet::SheetViewOptions {
2414                show_gridlines: Some(false),
2415                show_formulas: Some(true),
2416                zoom_scale: Some(200),
2417                view_mode: Some(crate::sheet::ViewMode::PageBreak),
2418                top_left_cell: Some("B5".to_string()),
2419                ..Default::default()
2420            },
2421        )
2422        .unwrap();
2423        wb.save(&path).unwrap();
2424
2425        let wb2 = Workbook::open(&path).unwrap();
2426        let opts = wb2.get_sheet_view_options("Sheet1").unwrap();
2427        assert_eq!(opts.show_gridlines, Some(false));
2428        assert_eq!(opts.show_formulas, Some(true));
2429        assert_eq!(opts.zoom_scale, Some(200));
2430        assert_eq!(opts.view_mode, Some(crate::sheet::ViewMode::PageBreak));
2431        assert_eq!(opts.top_left_cell, Some("B5".to_string()));
2432    }
2433
2434    #[test]
2435    fn test_sheet_view_options_nonexistent_sheet() {
2436        let wb = Workbook::new();
2437        let result = wb.get_sheet_view_options("NoSheet");
2438        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
2439    }
2440
2441    #[test]
2442    fn test_set_sheet_view_options_preserves_panes() {
2443        let mut wb = Workbook::new();
2444        wb.set_panes("Sheet1", "B2").unwrap();
2445        wb.set_sheet_view_options(
2446            "Sheet1",
2447            &crate::sheet::SheetViewOptions {
2448                zoom_scale: Some(150),
2449                ..Default::default()
2450            },
2451        )
2452        .unwrap();
2453
2454        assert_eq!(wb.get_panes("Sheet1").unwrap(), Some("B2".to_string()));
2455        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2456        assert_eq!(opts.zoom_scale, Some(150));
2457    }
2458
2459    #[test]
2460    fn test_get_sheet_visibility_default() {
2461        let wb = Workbook::new();
2462        let vis = wb.get_sheet_visibility("Sheet1").unwrap();
2463        assert_eq!(vis, crate::sheet::SheetVisibility::Visible);
2464    }
2465
2466    #[test]
2467    fn test_set_sheet_visibility_hidden() {
2468        let mut wb = Workbook::new();
2469        wb.new_sheet("Sheet2").unwrap();
2470        wb.set_sheet_visibility("Sheet1", crate::sheet::SheetVisibility::Hidden)
2471            .unwrap();
2472
2473        let vis = wb.get_sheet_visibility("Sheet1").unwrap();
2474        assert_eq!(vis, crate::sheet::SheetVisibility::Hidden);
2475        let vis2 = wb.get_sheet_visibility("Sheet2").unwrap();
2476        assert_eq!(vis2, crate::sheet::SheetVisibility::Visible);
2477    }
2478
2479    #[test]
2480    fn test_set_sheet_visibility_very_hidden() {
2481        let mut wb = Workbook::new();
2482        wb.new_sheet("Sheet2").unwrap();
2483        wb.set_sheet_visibility("Sheet1", crate::sheet::SheetVisibility::VeryHidden)
2484            .unwrap();
2485
2486        let vis = wb.get_sheet_visibility("Sheet1").unwrap();
2487        assert_eq!(vis, crate::sheet::SheetVisibility::VeryHidden);
2488    }
2489
2490    #[test]
2491    fn test_set_sheet_visibility_back_to_visible() {
2492        let mut wb = Workbook::new();
2493        wb.new_sheet("Sheet2").unwrap();
2494        wb.set_sheet_visibility("Sheet1", crate::sheet::SheetVisibility::Hidden)
2495            .unwrap();
2496        wb.set_sheet_visibility("Sheet1", crate::sheet::SheetVisibility::Visible)
2497            .unwrap();
2498
2499        let vis = wb.get_sheet_visibility("Sheet1").unwrap();
2500        assert_eq!(vis, crate::sheet::SheetVisibility::Visible);
2501    }
2502
2503    #[test]
2504    fn test_set_sheet_visibility_cannot_hide_last_visible() {
2505        let mut wb = Workbook::new();
2506        let result = wb.set_sheet_visibility("Sheet1", crate::sheet::SheetVisibility::Hidden);
2507        assert!(result.is_err());
2508        assert!(result.unwrap_err().to_string().contains("last visible"));
2509    }
2510
2511    #[test]
2512    fn test_set_sheet_visibility_cannot_hide_all() {
2513        let mut wb = Workbook::new();
2514        wb.new_sheet("Sheet2").unwrap();
2515        wb.set_sheet_visibility("Sheet1", crate::sheet::SheetVisibility::Hidden)
2516            .unwrap();
2517
2518        let result = wb.set_sheet_visibility("Sheet2", crate::sheet::SheetVisibility::Hidden);
2519        assert!(result.is_err());
2520    }
2521
2522    #[test]
2523    fn test_sheet_visibility_nonexistent_sheet() {
2524        let wb = Workbook::new();
2525        let result = wb.get_sheet_visibility("NoSheet");
2526        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
2527    }
2528
2529    #[test]
2530    fn test_sheet_visibility_roundtrip() {
2531        let dir = TempDir::new().unwrap();
2532        let path = dir.path().join("visibility.xlsx");
2533
2534        let mut wb = Workbook::new();
2535        wb.new_sheet("Sheet2").unwrap();
2536        wb.new_sheet("Sheet3").unwrap();
2537        wb.set_sheet_visibility("Sheet2", crate::sheet::SheetVisibility::Hidden)
2538            .unwrap();
2539        wb.set_sheet_visibility("Sheet3", crate::sheet::SheetVisibility::VeryHidden)
2540            .unwrap();
2541        wb.save(&path).unwrap();
2542
2543        let wb2 = Workbook::open(&path).unwrap();
2544        assert_eq!(
2545            wb2.get_sheet_visibility("Sheet1").unwrap(),
2546            crate::sheet::SheetVisibility::Visible
2547        );
2548        assert_eq!(
2549            wb2.get_sheet_visibility("Sheet2").unwrap(),
2550            crate::sheet::SheetVisibility::Hidden
2551        );
2552        assert_eq!(
2553            wb2.get_sheet_visibility("Sheet3").unwrap(),
2554            crate::sheet::SheetVisibility::VeryHidden
2555        );
2556    }
2557
2558    #[test]
2559    fn test_sheet_view_options_show_row_col_headers_off() {
2560        let mut wb = Workbook::new();
2561        wb.set_sheet_view_options(
2562            "Sheet1",
2563            &crate::sheet::SheetViewOptions {
2564                show_row_col_headers: Some(false),
2565                ..Default::default()
2566            },
2567        )
2568        .unwrap();
2569
2570        let opts = wb.get_sheet_view_options("Sheet1").unwrap();
2571        assert_eq!(opts.show_row_col_headers, Some(false));
2572    }
2573
2574    #[test]
2575    fn test_table_path_no_collision_after_delete() {
2576        use crate::table::{TableColumn, TableConfig};
2577
2578        let dir = TempDir::new().unwrap();
2579        let path = dir.path().join("table_path_collision.xlsx");
2580
2581        let mut wb = Workbook::new();
2582        let make_config = |name: &str, range: &str| TableConfig {
2583            name: name.to_string(),
2584            display_name: name.to_string(),
2585            range: range.to_string(),
2586            columns: vec![
2587                TableColumn {
2588                    name: "A".to_string(),
2589                    totals_row_function: None,
2590                    totals_row_label: None,
2591                },
2592                TableColumn {
2593                    name: "B".to_string(),
2594                    totals_row_function: None,
2595                    totals_row_label: None,
2596                },
2597            ],
2598            ..TableConfig::default()
2599        };
2600
2601        wb.add_table("Sheet1", &make_config("T1", "A1:B5")).unwrap();
2602        wb.add_table("Sheet1", &make_config("T2", "D1:E5")).unwrap();
2603        wb.delete_table("Sheet1", "T1").unwrap();
2604        wb.add_table("Sheet1", &make_config("T3", "G1:H5")).unwrap();
2605
2606        let paths: Vec<&str> = wb.tables.iter().map(|(p, _, _)| p.as_str()).collect();
2607        let mut unique_paths = paths.clone();
2608        unique_paths.sort();
2609        unique_paths.dedup();
2610        assert_eq!(
2611            paths.len(),
2612            unique_paths.len(),
2613            "table paths must be unique: {:?}",
2614            paths
2615        );
2616
2617        wb.save(&path).unwrap();
2618        let wb2 = Workbook::open(&path).unwrap();
2619        let tables = wb2.get_tables("Sheet1").unwrap();
2620        assert_eq!(tables.len(), 2);
2621        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
2622        assert!(names.contains(&"T2"));
2623        assert!(names.contains(&"T3"));
2624    }
2625
2626    #[test]
2627    fn test_dangling_table_parts_after_reopen_delete_save() {
2628        use crate::table::{TableColumn, TableConfig};
2629
2630        let dir = TempDir::new().unwrap();
2631        let path1 = dir.path().join("dangling_tp_step1.xlsx");
2632        let path2 = dir.path().join("dangling_tp_step2.xlsx");
2633
2634        let mut wb = Workbook::new();
2635        wb.add_table(
2636            "Sheet1",
2637            &TableConfig {
2638                name: "T1".to_string(),
2639                display_name: "T1".to_string(),
2640                range: "A1:B5".to_string(),
2641                columns: vec![
2642                    TableColumn {
2643                        name: "X".to_string(),
2644                        totals_row_function: None,
2645                        totals_row_label: None,
2646                    },
2647                    TableColumn {
2648                        name: "Y".to_string(),
2649                        totals_row_function: None,
2650                        totals_row_label: None,
2651                    },
2652                ],
2653                ..TableConfig::default()
2654            },
2655        )
2656        .unwrap();
2657        wb.save(&path1).unwrap();
2658
2659        let mut wb2 = Workbook::open(&path1).unwrap();
2660        assert_eq!(wb2.get_tables("Sheet1").unwrap().len(), 1);
2661        wb2.delete_table("Sheet1", "T1").unwrap();
2662        wb2.save(&path2).unwrap();
2663
2664        let file = std::fs::File::open(&path2).unwrap();
2665        let mut archive = zip::ZipArchive::new(file).unwrap();
2666        let mut ws_data = Vec::new();
2667        archive
2668            .by_name("xl/worksheets/sheet1.xml")
2669            .unwrap()
2670            .read_to_end(&mut ws_data)
2671            .unwrap();
2672        let ws_str = String::from_utf8(ws_data).unwrap();
2673        assert!(
2674            !ws_str.contains("tableParts"),
2675            "worksheet XML must not contain tableParts after all tables are deleted"
2676        );
2677
2678        assert!(
2679            archive.by_name("xl/tables/table1.xml").is_err(),
2680            "table1.xml must not be present after deletion"
2681        );
2682
2683        let wb3 = Workbook::open(&path2).unwrap();
2684        assert!(wb3.get_tables("Sheet1").unwrap().is_empty());
2685    }
2686
2687    #[test]
2688    fn test_workbook_add_threaded_comment() {
2689        let mut wb = Workbook::new();
2690        let input = crate::threaded_comment::ThreadedCommentInput {
2691            author: "Alice".to_string(),
2692            text: "Hello thread".to_string(),
2693            parent_id: None,
2694        };
2695        let id = wb.add_threaded_comment("Sheet1", "A1", &input).unwrap();
2696        assert!(!id.is_empty());
2697
2698        let comments = wb.get_threaded_comments("Sheet1").unwrap();
2699        assert_eq!(comments.len(), 1);
2700        assert_eq!(comments[0].cell_ref, "A1");
2701        assert_eq!(comments[0].text, "Hello thread");
2702        assert_eq!(comments[0].author, "Alice");
2703    }
2704
2705    #[test]
2706    fn test_workbook_threaded_comment_reply() {
2707        let mut wb = Workbook::new();
2708        let parent_id = wb
2709            .add_threaded_comment(
2710                "Sheet1",
2711                "A1",
2712                &crate::threaded_comment::ThreadedCommentInput {
2713                    author: "Alice".to_string(),
2714                    text: "Initial".to_string(),
2715                    parent_id: None,
2716                },
2717            )
2718            .unwrap();
2719
2720        wb.add_threaded_comment(
2721            "Sheet1",
2722            "A1",
2723            &crate::threaded_comment::ThreadedCommentInput {
2724                author: "Bob".to_string(),
2725                text: "Reply".to_string(),
2726                parent_id: Some(parent_id.clone()),
2727            },
2728        )
2729        .unwrap();
2730
2731        let comments = wb.get_threaded_comments("Sheet1").unwrap();
2732        assert_eq!(comments.len(), 2);
2733        assert_eq!(comments[1].parent_id, Some(parent_id));
2734    }
2735
2736    #[test]
2737    fn test_workbook_threaded_comments_by_cell() {
2738        let mut wb = Workbook::new();
2739        wb.add_threaded_comment(
2740            "Sheet1",
2741            "A1",
2742            &crate::threaded_comment::ThreadedCommentInput {
2743                author: "Alice".to_string(),
2744                text: "On A1".to_string(),
2745                parent_id: None,
2746            },
2747        )
2748        .unwrap();
2749        wb.add_threaded_comment(
2750            "Sheet1",
2751            "B2",
2752            &crate::threaded_comment::ThreadedCommentInput {
2753                author: "Bob".to_string(),
2754                text: "On B2".to_string(),
2755                parent_id: None,
2756            },
2757        )
2758        .unwrap();
2759
2760        let a1 = wb.get_threaded_comments_by_cell("Sheet1", "A1").unwrap();
2761        assert_eq!(a1.len(), 1);
2762        assert_eq!(a1[0].text, "On A1");
2763
2764        let b2 = wb.get_threaded_comments_by_cell("Sheet1", "B2").unwrap();
2765        assert_eq!(b2.len(), 1);
2766        assert_eq!(b2[0].text, "On B2");
2767    }
2768
2769    #[test]
2770    fn test_workbook_delete_threaded_comment() {
2771        let mut wb = Workbook::new();
2772        let id = wb
2773            .add_threaded_comment(
2774                "Sheet1",
2775                "A1",
2776                &crate::threaded_comment::ThreadedCommentInput {
2777                    author: "Alice".to_string(),
2778                    text: "Delete me".to_string(),
2779                    parent_id: None,
2780                },
2781            )
2782            .unwrap();
2783
2784        wb.delete_threaded_comment("Sheet1", &id).unwrap();
2785        let comments = wb.get_threaded_comments("Sheet1").unwrap();
2786        assert!(comments.is_empty());
2787    }
2788
2789    #[test]
2790    fn test_workbook_resolve_threaded_comment() {
2791        let mut wb = Workbook::new();
2792        let id = wb
2793            .add_threaded_comment(
2794                "Sheet1",
2795                "A1",
2796                &crate::threaded_comment::ThreadedCommentInput {
2797                    author: "Alice".to_string(),
2798                    text: "Resolve me".to_string(),
2799                    parent_id: None,
2800                },
2801            )
2802            .unwrap();
2803
2804        wb.resolve_threaded_comment("Sheet1", &id, true).unwrap();
2805        let comments = wb.get_threaded_comments("Sheet1").unwrap();
2806        assert!(comments[0].done);
2807
2808        wb.resolve_threaded_comment("Sheet1", &id, false).unwrap();
2809        let comments = wb.get_threaded_comments("Sheet1").unwrap();
2810        assert!(!comments[0].done);
2811    }
2812
2813    #[test]
2814    fn test_workbook_add_person() {
2815        let mut wb = Workbook::new();
2816        let id = wb.add_person(&crate::threaded_comment::PersonInput {
2817            display_name: "Alice".to_string(),
2818            user_id: Some("alice@example.com".to_string()),
2819            provider_id: Some("ADAL".to_string()),
2820        });
2821        assert!(!id.is_empty());
2822
2823        let persons = wb.get_persons();
2824        assert_eq!(persons.len(), 1);
2825        assert_eq!(persons[0].display_name, "Alice");
2826    }
2827
2828    #[test]
2829    fn test_workbook_threaded_comment_roundtrip() {
2830        let dir = TempDir::new().unwrap();
2831        let path = dir.path().join("threaded_comment_roundtrip.xlsx");
2832
2833        let mut wb = Workbook::new();
2834        let id = wb
2835            .add_threaded_comment(
2836                "Sheet1",
2837                "A1",
2838                &crate::threaded_comment::ThreadedCommentInput {
2839                    author: "Alice".to_string(),
2840                    text: "Persisted comment".to_string(),
2841                    parent_id: None,
2842                },
2843            )
2844            .unwrap();
2845        wb.resolve_threaded_comment("Sheet1", &id, true).unwrap();
2846        wb.save(&path).unwrap();
2847
2848        let wb2 = Workbook::open(&path).unwrap();
2849        let comments = wb2.get_threaded_comments("Sheet1").unwrap();
2850        assert_eq!(comments.len(), 1);
2851        assert_eq!(comments[0].cell_ref, "A1");
2852        assert_eq!(comments[0].text, "Persisted comment");
2853        assert_eq!(comments[0].author, "Alice");
2854        assert!(comments[0].done);
2855
2856        let persons = wb2.get_persons();
2857        assert_eq!(persons.len(), 1);
2858        assert_eq!(persons[0].display_name, "Alice");
2859    }
2860
2861    #[test]
2862    fn test_workbook_threaded_comment_buffer_roundtrip() {
2863        let mut wb = Workbook::new();
2864        let parent_id = wb
2865            .add_threaded_comment(
2866                "Sheet1",
2867                "B2",
2868                &crate::threaded_comment::ThreadedCommentInput {
2869                    author: "Bob".to_string(),
2870                    text: "Buffer test".to_string(),
2871                    parent_id: None,
2872                },
2873            )
2874            .unwrap();
2875        wb.add_threaded_comment(
2876            "Sheet1",
2877            "B2",
2878            &crate::threaded_comment::ThreadedCommentInput {
2879                author: "Alice".to_string(),
2880                text: "Buffer reply".to_string(),
2881                parent_id: Some(parent_id.clone()),
2882            },
2883        )
2884        .unwrap();
2885
2886        let buf = wb.save_to_buffer().unwrap();
2887        let wb2 = Workbook::open_from_buffer(&buf).unwrap();
2888
2889        let comments = wb2.get_threaded_comments("Sheet1").unwrap();
2890        assert_eq!(comments.len(), 2);
2891        assert_eq!(comments[0].text, "Buffer test");
2892        assert_eq!(comments[1].text, "Buffer reply");
2893        assert_eq!(comments[1].parent_id, Some(parent_id));
2894    }
2895
2896    #[test]
2897    fn test_workbook_threaded_comment_multiple_sheets() {
2898        let dir = TempDir::new().unwrap();
2899        let path = dir.path().join("tc_multi_sheet.xlsx");
2900
2901        let mut wb = Workbook::new();
2902        wb.new_sheet("Sheet2").unwrap();
2903
2904        wb.add_threaded_comment(
2905            "Sheet1",
2906            "A1",
2907            &crate::threaded_comment::ThreadedCommentInput {
2908                author: "Alice".to_string(),
2909                text: "Sheet1 comment".to_string(),
2910                parent_id: None,
2911            },
2912        )
2913        .unwrap();
2914        wb.add_threaded_comment(
2915            "Sheet2",
2916            "C3",
2917            &crate::threaded_comment::ThreadedCommentInput {
2918                author: "Bob".to_string(),
2919                text: "Sheet2 comment".to_string(),
2920                parent_id: None,
2921            },
2922        )
2923        .unwrap();
2924        wb.save(&path).unwrap();
2925
2926        let wb2 = Workbook::open(&path).unwrap();
2927        let s1 = wb2.get_threaded_comments("Sheet1").unwrap();
2928        assert_eq!(s1.len(), 1);
2929        assert_eq!(s1[0].text, "Sheet1 comment");
2930
2931        let s2 = wb2.get_threaded_comments("Sheet2").unwrap();
2932        assert_eq!(s2.len(), 1);
2933        assert_eq!(s2[0].text, "Sheet2 comment");
2934
2935        let persons = wb2.get_persons();
2936        assert_eq!(persons.len(), 2);
2937    }
2938
2939    #[test]
2940    fn test_workbook_threaded_comment_sheet_not_found() {
2941        let wb = Workbook::new();
2942        let result = wb.get_threaded_comments("NoSheet");
2943        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
2944    }
2945}