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