Skip to main content

sheetkit_core/workbook/
data.rs

1use super::*;
2
3impl Workbook {
4    /// Add a pivot table to the workbook.
5    ///
6    /// The pivot table summarizes data from `config.source_sheet` /
7    /// `config.source_range` and places its output on `config.target_sheet`
8    /// starting at `config.target_cell`.
9    pub fn add_pivot_table(&mut self, config: &PivotTableConfig) -> Result<()> {
10        // Validate source sheet exists.
11        let _src_idx = self.sheet_index(&config.source_sheet)?;
12
13        // Validate target sheet exists.
14        let target_idx = self.sheet_index(&config.target_sheet)?;
15
16        // Check for duplicate name.
17        if self
18            .pivot_tables
19            .iter()
20            .any(|(_, pt)| pt.name == config.name)
21        {
22            return Err(Error::PivotTableAlreadyExists {
23                name: config.name.clone(),
24            });
25        }
26
27        // Read header row from the source data.
28        let field_names = self.read_header_row(&config.source_sheet, &config.source_range)?;
29        if field_names.is_empty() {
30            return Err(Error::InvalidSourceRange(
31                "source range header row is empty".to_string(),
32            ));
33        }
34
35        // Assign a cache ID (next available).
36        let cache_id = self
37            .pivot_tables
38            .iter()
39            .map(|(_, pt)| pt.cache_id)
40            .max()
41            .map(|m| m + 1)
42            .unwrap_or(0);
43
44        // Build XML structures.
45        let pt_def = crate::pivot::build_pivot_table_xml(config, cache_id, &field_names)?;
46        let pcd = crate::pivot::build_pivot_cache_definition(
47            &config.source_sheet,
48            &config.source_range,
49            &field_names,
50        );
51        let pcr = sheetkit_xml::pivot_cache::PivotCacheRecords {
52            xmlns: sheetkit_xml::namespaces::SPREADSHEET_ML.to_string(),
53            xmlns_r: sheetkit_xml::namespaces::RELATIONSHIPS.to_string(),
54            count: Some(0),
55            records: vec![],
56        };
57
58        // Determine part numbers.
59        let pt_num = self.pivot_tables.len() + 1;
60        let cache_num = self.pivot_cache_defs.len() + 1;
61
62        let pt_path = format!("xl/pivotTables/pivotTable{}.xml", pt_num);
63        let pcd_path = format!("xl/pivotCache/pivotCacheDefinition{}.xml", cache_num);
64        let pcr_path = format!("xl/pivotCache/pivotCacheRecords{}.xml", cache_num);
65
66        // Store parts.
67        self.pivot_tables.push((pt_path.clone(), pt_def));
68        self.pivot_cache_defs.push((pcd_path.clone(), pcd));
69        self.pivot_cache_records.push((pcr_path.clone(), pcr));
70
71        // Add content type overrides.
72        self.content_types.overrides.push(ContentTypeOverride {
73            part_name: format!("/{}", pt_path),
74            content_type: mime_types::PIVOT_TABLE.to_string(),
75        });
76        self.content_types.overrides.push(ContentTypeOverride {
77            part_name: format!("/{}", pcd_path),
78            content_type: mime_types::PIVOT_CACHE_DEFINITION.to_string(),
79        });
80        self.content_types.overrides.push(ContentTypeOverride {
81            part_name: format!("/{}", pcr_path),
82            content_type: mime_types::PIVOT_CACHE_RECORDS.to_string(),
83        });
84
85        // Add workbook relationship for pivot cache definition.
86        let wb_rid = crate::sheet::next_rid(&self.workbook_rels.relationships);
87        self.workbook_rels.relationships.push(Relationship {
88            id: wb_rid.clone(),
89            rel_type: rel_types::PIVOT_CACHE_DEF.to_string(),
90            target: format!("pivotCache/pivotCacheDefinition{}.xml", cache_num),
91            target_mode: None,
92        });
93
94        // Update workbook_xml.pivot_caches.
95        let pivot_caches = self
96            .workbook_xml
97            .pivot_caches
98            .get_or_insert_with(|| sheetkit_xml::workbook::PivotCaches { caches: vec![] });
99        pivot_caches
100            .caches
101            .push(sheetkit_xml::workbook::PivotCacheEntry {
102                cache_id,
103                r_id: wb_rid,
104            });
105
106        // Add worksheet relationship for pivot table on the target sheet.
107        let ws_rid = self.next_worksheet_rid(target_idx);
108        let ws_rels = self
109            .worksheet_rels
110            .entry(target_idx)
111            .or_insert_with(|| Relationships {
112                xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
113                relationships: vec![],
114            });
115        ws_rels.relationships.push(Relationship {
116            id: ws_rid,
117            rel_type: rel_types::PIVOT_TABLE.to_string(),
118            target: format!("../pivotTables/pivotTable{}.xml", pt_num),
119            target_mode: None,
120        });
121
122        Ok(())
123    }
124
125    /// Get information about all pivot tables in the workbook.
126    pub fn get_pivot_tables(&self) -> Vec<PivotTableInfo> {
127        self.pivot_tables
128            .iter()
129            .map(|(_path, pt)| {
130                // Find the matching cache definition by cache_id.
131                let (source_sheet, source_range) = self
132                    .pivot_cache_defs
133                    .iter()
134                    .enumerate()
135                    .find(|(i, _)| {
136                        self.workbook_xml
137                            .pivot_caches
138                            .as_ref()
139                            .and_then(|pc| pc.caches.iter().find(|e| e.cache_id == pt.cache_id))
140                            .is_some()
141                            || *i == pt.cache_id as usize
142                    })
143                    .and_then(|(_, (_, pcd))| {
144                        pcd.cache_source
145                            .worksheet_source
146                            .as_ref()
147                            .map(|ws| (ws.sheet.clone(), ws.reference.clone()))
148                    })
149                    .unwrap_or_default();
150
151                // Determine target sheet from the pivot table path.
152                let target_sheet = self.find_pivot_table_target_sheet(pt).unwrap_or_default();
153
154                PivotTableInfo {
155                    name: pt.name.clone(),
156                    source_sheet,
157                    source_range,
158                    target_sheet,
159                    location: pt.location.reference.clone(),
160                }
161            })
162            .collect()
163    }
164
165    /// Delete a pivot table by name.
166    pub fn delete_pivot_table(&mut self, name: &str) -> Result<()> {
167        // Find the pivot table.
168        let pt_idx = self
169            .pivot_tables
170            .iter()
171            .position(|(_, pt)| pt.name == name)
172            .ok_or_else(|| Error::PivotTableNotFound {
173                name: name.to_string(),
174            })?;
175
176        let (pt_path, pt_def) = self.pivot_tables.remove(pt_idx);
177        let cache_id = pt_def.cache_id;
178
179        // Remove the matching pivot cache definition and records.
180        // Find the workbook_xml pivot cache entry for this cache_id.
181        let mut wb_cache_rid = None;
182        if let Some(ref mut pivot_caches) = self.workbook_xml.pivot_caches {
183            if let Some(pos) = pivot_caches
184                .caches
185                .iter()
186                .position(|e| e.cache_id == cache_id)
187            {
188                wb_cache_rid = Some(pivot_caches.caches[pos].r_id.clone());
189                pivot_caches.caches.remove(pos);
190            }
191            if pivot_caches.caches.is_empty() {
192                self.workbook_xml.pivot_caches = None;
193            }
194        }
195
196        // Remove the workbook relationship for this cache.
197        if let Some(ref rid) = wb_cache_rid {
198            // Find the target to determine which cache def to remove.
199            if let Some(rel) = self
200                .workbook_rels
201                .relationships
202                .iter()
203                .find(|r| r.id == *rid)
204            {
205                let target_path = format!("xl/{}", rel.target);
206                self.pivot_cache_defs.retain(|(p, _)| *p != target_path);
207
208                // Remove matching cache records (same numbering).
209                let records_path = target_path.replace("pivotCacheDefinition", "pivotCacheRecords");
210                self.pivot_cache_records.retain(|(p, _)| *p != records_path);
211            }
212            self.workbook_rels.relationships.retain(|r| r.id != *rid);
213        }
214
215        // Remove content type overrides for the removed parts.
216        let pt_part = format!("/{}", pt_path);
217        self.content_types
218            .overrides
219            .retain(|o| o.part_name != pt_part);
220
221        // Also remove cache def and records content types if the paths were removed.
222        self.content_types.overrides.retain(|o| {
223            let p = o.part_name.trim_start_matches('/');
224            // Keep if it is still in our live lists.
225            if o.content_type == mime_types::PIVOT_CACHE_DEFINITION {
226                return self.pivot_cache_defs.iter().any(|(path, _)| path == p);
227            }
228            if o.content_type == mime_types::PIVOT_CACHE_RECORDS {
229                return self.pivot_cache_records.iter().any(|(path, _)| path == p);
230            }
231            if o.content_type == mime_types::PIVOT_TABLE {
232                return self.pivot_tables.iter().any(|(path, _)| path == p);
233            }
234            true
235        });
236
237        // Remove worksheet relationship for this pivot table.
238        for (_idx, rels) in self.worksheet_rels.iter_mut() {
239            rels.relationships.retain(|r| {
240                if r.rel_type != rel_types::PIVOT_TABLE {
241                    return true;
242                }
243                // Check if the target matches the removed pivot table.
244                let full_target = format!(
245                    "xl/pivotTables/{}",
246                    r.target.trim_start_matches("../pivotTables/")
247                );
248                full_target != pt_path
249            });
250        }
251
252        Ok(())
253    }
254
255    /// Add a sparkline to a worksheet.
256    pub fn add_sparkline(
257        &mut self,
258        sheet: &str,
259        config: &crate::sparkline::SparklineConfig,
260    ) -> Result<()> {
261        let idx = self.sheet_index(sheet)?;
262        crate::sparkline::validate_sparkline_config(config)?;
263        while self.sheet_sparklines.len() <= idx {
264            self.sheet_sparklines.push(vec![]);
265        }
266        self.sheet_sparklines[idx].push(config.clone());
267        Ok(())
268    }
269
270    /// Get all sparklines for a worksheet.
271    pub fn get_sparklines(&self, sheet: &str) -> Result<Vec<crate::sparkline::SparklineConfig>> {
272        let idx = self.sheet_index(sheet)?;
273        Ok(self.sheet_sparklines.get(idx).cloned().unwrap_or_default())
274    }
275
276    /// Remove a sparkline by its location cell reference.
277    pub fn remove_sparkline(&mut self, sheet: &str, location: &str) -> Result<()> {
278        let idx = self.sheet_index(sheet)?;
279        let sparklines = self
280            .sheet_sparklines
281            .get_mut(idx)
282            .ok_or_else(|| Error::Internal(format!("no sparkline data for sheet '{sheet}'")))?;
283        let pos = sparklines
284            .iter()
285            .position(|s| s.location == location)
286            .ok_or_else(|| {
287                Error::Internal(format!(
288                    "sparkline at location '{location}' not found on sheet '{sheet}'"
289                ))
290            })?;
291        sparklines.remove(pos);
292        Ok(())
293    }
294
295    /// Evaluate a single formula string in the context of `sheet`.
296    ///
297    /// A [`CellSnapshot`] is built from the current workbook state so
298    /// that cell references within the formula can be resolved.
299    pub fn evaluate_formula(&self, sheet: &str, formula: &str) -> Result<CellValue> {
300        // Validate the sheet exists.
301        let _ = self.sheet_index(sheet)?;
302        let parsed = crate::formula::parser::parse_formula(formula)?;
303        let snapshot = self.build_cell_snapshot(sheet)?;
304        crate::formula::eval::evaluate(&parsed, &snapshot)
305    }
306
307    /// Recalculate every formula cell across all sheets and store the
308    /// computed result back into each cell. Uses a dependency graph and
309    /// topological sort so formulas are evaluated after their dependencies.
310    pub fn calculate_all(&mut self) -> Result<()> {
311        use crate::formula::eval::{build_dependency_graph, topological_sort, CellCoord};
312
313        let sheet_names: Vec<String> = self.sheet_names().iter().map(|s| s.to_string()).collect();
314
315        // Collect all formula cells with their coordinates and formula strings.
316        let mut formula_cells: Vec<(CellCoord, String)> = Vec::new();
317        for sn in &sheet_names {
318            let ws = self
319                .worksheets
320                .iter()
321                .find(|(name, _)| name == sn)
322                .map(|(_, ws)| ws)
323                .ok_or_else(|| Error::SheetNotFound {
324                    name: sn.to_string(),
325                })?;
326            for row in &ws.sheet_data.rows {
327                for cell in &row.cells {
328                    if let Some(ref f) = cell.f {
329                        let formula_str = f.value.clone().unwrap_or_default();
330                        if !formula_str.is_empty() {
331                            if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
332                                formula_cells.push((
333                                    CellCoord {
334                                        sheet: sn.clone(),
335                                        col: c,
336                                        row: r,
337                                    },
338                                    formula_str,
339                                ));
340                            }
341                        }
342                    }
343                }
344            }
345        }
346
347        if formula_cells.is_empty() {
348            return Ok(());
349        }
350
351        // Build dependency graph and determine evaluation order.
352        let deps = build_dependency_graph(&formula_cells)?;
353        let coords: Vec<CellCoord> = formula_cells.iter().map(|(c, _)| c.clone()).collect();
354        let eval_order = topological_sort(&coords, &deps)?;
355
356        // Build a lookup from coord to formula string.
357        let formula_map: HashMap<CellCoord, String> = formula_cells.into_iter().collect();
358
359        // Build a snapshot of all cell data.
360        let first_sheet = sheet_names.first().cloned().unwrap_or_default();
361        let mut snapshot = self.build_cell_snapshot(&first_sheet)?;
362
363        // Evaluate in dependency order, updating the snapshot progressively
364        // so later formulas see already-computed results.
365        let mut results: Vec<(CellCoord, String, CellValue)> = Vec::new();
366        for coord in &eval_order {
367            if let Some(formula_str) = formula_map.get(coord) {
368                snapshot.set_current_sheet(&coord.sheet);
369                let parsed = crate::formula::parser::parse_formula(formula_str)?;
370                let mut evaluator = crate::formula::eval::Evaluator::new(&snapshot);
371                let result = evaluator.eval_expr(&parsed)?;
372                snapshot.set_cell(&coord.sheet, coord.col, coord.row, result.clone());
373                results.push((coord.clone(), formula_str.clone(), result));
374            }
375        }
376
377        // Write results back directly to the XML cells, preserving the
378        // formula element and storing the computed value in the v/t fields.
379        for (coord, _formula_str, result) in results {
380            let cell_ref = crate::utils::cell_ref::coordinates_to_cell_name(coord.col, coord.row)?;
381            if let Some((_, ws)) = self.worksheets.iter_mut().find(|(n, _)| *n == coord.sheet) {
382                if let Some(row) = ws.sheet_data.rows.iter_mut().find(|r| r.r == coord.row) {
383                    if let Some(cell) = row.cells.iter_mut().find(|c| c.r == *cell_ref) {
384                        match &result {
385                            CellValue::Number(n) => {
386                                cell.v = Some(n.to_string());
387                                cell.t = CellTypeTag::None;
388                            }
389                            CellValue::String(s) => {
390                                cell.v = Some(s.clone());
391                                cell.t = CellTypeTag::FormulaString;
392                            }
393                            CellValue::Bool(b) => {
394                                cell.v = Some(if *b { "1".to_string() } else { "0".to_string() });
395                                cell.t = CellTypeTag::Boolean;
396                            }
397                            CellValue::Error(e) => {
398                                cell.v = Some(e.clone());
399                                cell.t = CellTypeTag::Error;
400                            }
401                            CellValue::Date(n) => {
402                                cell.v = Some(n.to_string());
403                                cell.t = CellTypeTag::None;
404                            }
405                            _ => {}
406                        }
407                    }
408                }
409            }
410        }
411
412        Ok(())
413    }
414
415    /// Build a [`CellSnapshot`] for formula evaluation, with the given
416    /// sheet as the current-sheet context.
417    fn build_cell_snapshot(
418        &self,
419        current_sheet: &str,
420    ) -> Result<crate::formula::eval::CellSnapshot> {
421        let mut snapshot = crate::formula::eval::CellSnapshot::new(current_sheet.to_string());
422        for (sn, ws) in &self.worksheets {
423            for row in &ws.sheet_data.rows {
424                for cell in &row.cells {
425                    if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
426                        let cv = self.xml_cell_to_value(cell)?;
427                        snapshot.set_cell(sn, c, r, cv);
428                    }
429                }
430            }
431        }
432        Ok(snapshot)
433    }
434
435    /// Return `(col, row)` pairs for all occupied cells on the named sheet.
436    pub fn get_occupied_cells(&self, sheet: &str) -> Result<Vec<(u32, u32)>> {
437        let ws = self
438            .worksheets
439            .iter()
440            .find(|(name, _)| name == sheet)
441            .map(|(_, ws)| ws)
442            .ok_or_else(|| Error::SheetNotFound {
443                name: sheet.to_string(),
444            })?;
445        let mut cells = Vec::new();
446        for row in &ws.sheet_data.rows {
447            for cell in &row.cells {
448                if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
449                    cells.push((c, r));
450                }
451            }
452        }
453        Ok(cells)
454    }
455
456    /// Read the header row (first row) of a range from a sheet, returning cell
457    /// values as strings.
458    fn read_header_row(&self, sheet: &str, range: &str) -> Result<Vec<String>> {
459        let parts: Vec<&str> = range.split(':').collect();
460        if parts.len() != 2 {
461            return Err(Error::InvalidSourceRange(range.to_string()));
462        }
463        let (start_col, start_row) = cell_name_to_coordinates(parts[0])
464            .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
465        let (end_col, _end_row) = cell_name_to_coordinates(parts[1])
466            .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
467
468        let mut headers = Vec::new();
469        for col in start_col..=end_col {
470            let cell_name = crate::utils::cell_ref::coordinates_to_cell_name(col, start_row)?;
471            let val = self.get_cell_value(sheet, &cell_name)?;
472            let s = match val {
473                CellValue::String(s) => s,
474                CellValue::Number(n) => n.to_string(),
475                CellValue::Bool(b) => b.to_string(),
476                CellValue::RichString(runs) => crate::rich_text::rich_text_to_plain(&runs),
477                _ => String::new(),
478            };
479            headers.push(s);
480        }
481        Ok(headers)
482    }
483
484    /// Find the target sheet name for a pivot table by looking at worksheet
485    /// relationships that reference its path.
486    fn find_pivot_table_target_sheet(
487        &self,
488        pt: &sheetkit_xml::pivot_table::PivotTableDefinition,
489    ) -> Option<String> {
490        // Find the pivot table path.
491        let pt_path = self
492            .pivot_tables
493            .iter()
494            .find(|(_, p)| p.name == pt.name)
495            .map(|(path, _)| path.as_str())?;
496
497        // Find which worksheet has a relationship pointing to this pivot table.
498        for (sheet_idx, rels) in &self.worksheet_rels {
499            for r in &rels.relationships {
500                if r.rel_type == rel_types::PIVOT_TABLE {
501                    let full_target = format!(
502                        "xl/pivotTables/{}",
503                        r.target.trim_start_matches("../pivotTables/")
504                    );
505                    if full_target == pt_path {
506                        return self
507                            .worksheets
508                            .get(*sheet_idx)
509                            .map(|(name, _)| name.clone());
510                    }
511                }
512            }
513        }
514        None
515    }
516
517    /// Set the core document properties (title, author, etc.).
518    pub fn set_doc_props(&mut self, props: crate::doc_props::DocProperties) {
519        self.core_properties = Some(props.to_core_properties());
520        self.ensure_doc_props_content_types();
521    }
522
523    /// Get the core document properties.
524    pub fn get_doc_props(&self) -> crate::doc_props::DocProperties {
525        self.core_properties
526            .as_ref()
527            .map(crate::doc_props::DocProperties::from)
528            .unwrap_or_default()
529    }
530
531    /// Set the application properties (company, app version, etc.).
532    pub fn set_app_props(&mut self, props: crate::doc_props::AppProperties) {
533        self.app_properties = Some(props.to_extended_properties());
534        self.ensure_doc_props_content_types();
535    }
536
537    /// Get the application properties.
538    pub fn get_app_props(&self) -> crate::doc_props::AppProperties {
539        self.app_properties
540            .as_ref()
541            .map(crate::doc_props::AppProperties::from)
542            .unwrap_or_default()
543    }
544
545    /// Set a custom property by name. If a property with the same name already
546    /// exists, its value is replaced.
547    pub fn set_custom_property(
548        &mut self,
549        name: &str,
550        value: crate::doc_props::CustomPropertyValue,
551    ) {
552        let props = self
553            .custom_properties
554            .get_or_insert_with(sheetkit_xml::doc_props::CustomProperties::default);
555        crate::doc_props::set_custom_property(props, name, value);
556        self.ensure_custom_props_content_types();
557    }
558
559    /// Get a custom property value by name, or `None` if it does not exist.
560    pub fn get_custom_property(&self, name: &str) -> Option<crate::doc_props::CustomPropertyValue> {
561        self.custom_properties
562            .as_ref()
563            .and_then(|p| crate::doc_props::find_custom_property(p, name))
564    }
565
566    /// Remove a custom property by name. Returns `true` if a property was
567    /// found and removed.
568    pub fn delete_custom_property(&mut self, name: &str) -> bool {
569        if let Some(ref mut props) = self.custom_properties {
570            crate::doc_props::delete_custom_property(props, name)
571        } else {
572            false
573        }
574    }
575
576    /// Ensure content types contains entries for core and extended properties.
577    fn ensure_doc_props_content_types(&mut self) {
578        let core_part = "/docProps/core.xml";
579        let app_part = "/docProps/app.xml";
580
581        let has_core = self
582            .content_types
583            .overrides
584            .iter()
585            .any(|o| o.part_name == core_part);
586        if !has_core {
587            self.content_types.overrides.push(ContentTypeOverride {
588                part_name: core_part.to_string(),
589                content_type: mime_types::CORE_PROPERTIES.to_string(),
590            });
591        }
592
593        let has_app = self
594            .content_types
595            .overrides
596            .iter()
597            .any(|o| o.part_name == app_part);
598        if !has_app {
599            self.content_types.overrides.push(ContentTypeOverride {
600                part_name: app_part.to_string(),
601                content_type: mime_types::EXTENDED_PROPERTIES.to_string(),
602            });
603        }
604    }
605
606    /// Ensure content types and package rels contain entries for custom properties.
607    fn ensure_custom_props_content_types(&mut self) {
608        self.ensure_doc_props_content_types();
609
610        let custom_part = "/docProps/custom.xml";
611        let has_custom = self
612            .content_types
613            .overrides
614            .iter()
615            .any(|o| o.part_name == custom_part);
616        if !has_custom {
617            self.content_types.overrides.push(ContentTypeOverride {
618                part_name: custom_part.to_string(),
619                content_type: mime_types::CUSTOM_PROPERTIES.to_string(),
620            });
621        }
622
623        let has_custom_rel = self
624            .package_rels
625            .relationships
626            .iter()
627            .any(|r| r.rel_type == rel_types::CUSTOM_PROPERTIES);
628        if !has_custom_rel {
629            let next_id = self.package_rels.relationships.len() + 1;
630            self.package_rels.relationships.push(Relationship {
631                id: format!("rId{next_id}"),
632                rel_type: rel_types::CUSTOM_PROPERTIES.to_string(),
633                target: "docProps/custom.xml".to_string(),
634                target_mode: None,
635            });
636        }
637    }
638
639    /// Look up a table by name across all sheets. Returns a reference to the
640    /// table XML, path, and sheet index from the main table storage.
641    fn find_table_by_name(
642        &self,
643        name: &str,
644    ) -> Option<(&String, &sheetkit_xml::table::TableXml, usize)> {
645        self.tables
646            .iter()
647            .find(|(_, t, _)| t.name == name)
648            .map(|(path, t, idx)| (path, t, *idx))
649    }
650
651    /// Look up the table name for a given table ID.
652    fn table_name_by_id(&self, table_id: u32) -> Option<&str> {
653        self.tables
654            .iter()
655            .find(|(_, t, _)| t.id == table_id)
656            .map(|(_, t, _)| t.name.as_str())
657    }
658
659    /// Look up the column name by 1-based column index and table ID.
660    fn table_column_name_by_index(&self, table_id: u32, column_index: u32) -> Option<&str> {
661        self.tables
662            .iter()
663            .find(|(_, t, _)| t.id == table_id)
664            .and_then(|(_, t, _)| {
665                t.table_columns
666                    .columns
667                    .get((column_index - 1) as usize)
668                    .map(|c| c.name.as_str())
669            })
670    }
671
672    /// Add a slicer to a sheet targeting a table column.
673    ///
674    /// Creates the slicer definition, slicer cache, content type overrides,
675    /// and worksheet relationships needed for Excel to render the slicer.
676    pub fn add_slicer(&mut self, sheet: &str, config: &crate::slicer::SlicerConfig) -> Result<()> {
677        use sheetkit_xml::content_types::ContentTypeOverride;
678        use sheetkit_xml::slicer::{
679            SlicerCacheDefinition, SlicerDefinition, SlicerDefinitions, TableSlicerCache,
680        };
681
682        crate::slicer::validate_slicer_config(config)?;
683
684        let sheet_idx = self.sheet_index(sheet)?;
685
686        // Check for duplicate name across all slicer definitions.
687        for (_, sd) in &self.slicer_defs {
688            for s in &sd.slicers {
689                if s.name == config.name {
690                    return Err(Error::SlicerAlreadyExists {
691                        name: config.name.clone(),
692                    });
693                }
694            }
695        }
696
697        let cache_name = crate::slicer::slicer_cache_name(&config.name);
698        let caption = config
699            .caption
700            .clone()
701            .unwrap_or_else(|| config.column_name.clone());
702
703        // Determine part numbers.
704        let slicer_num = self.slicer_defs.len() + 1;
705        let cache_num = self.slicer_caches.len() + 1;
706
707        let slicer_path = format!("xl/slicers/slicer{}.xml", slicer_num);
708        let cache_path = format!("xl/slicerCaches/slicerCache{}.xml", cache_num);
709
710        // Build the slicer definition.
711        let slicer_def = SlicerDefinition {
712            name: config.name.clone(),
713            cache: cache_name.clone(),
714            caption: Some(caption),
715            start_item: None,
716            column_count: config.column_count,
717            show_caption: config.show_caption,
718            style: config.style.clone(),
719            locked_position: None,
720            row_height: crate::slicer::DEFAULT_ROW_HEIGHT_EMU,
721        };
722
723        let slicer_defs = SlicerDefinitions {
724            xmlns: sheetkit_xml::namespaces::SLICER_2009.to_string(),
725            xmlns_mc: Some(sheetkit_xml::namespaces::MC.to_string()),
726            slicers: vec![slicer_def],
727        };
728
729        // Look up the actual table by name and validate it exists on this sheet.
730        let (_path, table_xml, table_sheet_idx) = self
731            .find_table_by_name(&config.table_name)
732            .ok_or_else(|| Error::TableNotFound {
733                name: config.table_name.clone(),
734            })?;
735
736        if table_sheet_idx != sheet_idx {
737            return Err(Error::TableNotFound {
738                name: config.table_name.clone(),
739            });
740        }
741
742        // Validate the column exists in the table and get its 1-based index.
743        let column_index = table_xml
744            .table_columns
745            .columns
746            .iter()
747            .position(|c| c.name == config.column_name)
748            .ok_or_else(|| Error::TableColumnNotFound {
749                table: config.table_name.clone(),
750                column: config.column_name.clone(),
751            })?;
752
753        let real_table_id = table_xml.id;
754        let real_column = (column_index + 1) as u32;
755
756        // Build the slicer cache definition with real table metadata.
757        let slicer_cache = SlicerCacheDefinition {
758            name: cache_name.clone(),
759            source_name: config.column_name.clone(),
760            table_slicer_cache: Some(TableSlicerCache {
761                table_id: real_table_id,
762                column: real_column,
763            }),
764        };
765
766        // Store parts.
767        self.slicer_defs.push((slicer_path.clone(), slicer_defs));
768        self.slicer_caches.push((cache_path.clone(), slicer_cache));
769
770        // Add content type overrides.
771        self.content_types.overrides.push(ContentTypeOverride {
772            part_name: format!("/{}", slicer_path),
773            content_type: mime_types::SLICER.to_string(),
774        });
775        self.content_types.overrides.push(ContentTypeOverride {
776            part_name: format!("/{}", cache_path),
777            content_type: mime_types::SLICER_CACHE.to_string(),
778        });
779
780        // Add workbook relationship for slicer cache.
781        let wb_rid = crate::sheet::next_rid(&self.workbook_rels.relationships);
782        self.workbook_rels.relationships.push(Relationship {
783            id: wb_rid,
784            rel_type: rel_types::SLICER_CACHE.to_string(),
785            target: format!("slicerCaches/slicerCache{}.xml", cache_num),
786            target_mode: None,
787        });
788
789        // Add worksheet relationship for slicer part.
790        let ws_rid = self.next_worksheet_rid(sheet_idx);
791        let ws_rels = self
792            .worksheet_rels
793            .entry(sheet_idx)
794            .or_insert_with(|| Relationships {
795                xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
796                relationships: vec![],
797            });
798        ws_rels.relationships.push(Relationship {
799            id: ws_rid,
800            rel_type: rel_types::SLICER.to_string(),
801            target: format!("../slicers/slicer{}.xml", slicer_num),
802            target_mode: None,
803        });
804
805        Ok(())
806    }
807
808    /// Get information about all slicers on a sheet.
809    pub fn get_slicers(&self, sheet: &str) -> Result<Vec<crate::slicer::SlicerInfo>> {
810        let sheet_idx = self.sheet_index(sheet)?;
811        let mut result = Vec::new();
812
813        // Find slicer parts referenced by this sheet's relationships.
814        let empty_rels = Relationships {
815            xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
816            relationships: vec![],
817        };
818        let rels = self.worksheet_rels.get(&sheet_idx).unwrap_or(&empty_rels);
819
820        let slicer_targets: Vec<String> = rels
821            .relationships
822            .iter()
823            .filter(|r| r.rel_type == rel_types::SLICER)
824            .map(|r| {
825                let sheet_path = self.sheet_part_path(sheet_idx);
826                crate::workbook_paths::resolve_relationship_target(&sheet_path, &r.target)
827            })
828            .collect();
829
830        for (path, sd) in &self.slicer_defs {
831            if !slicer_targets.contains(path) {
832                continue;
833            }
834            for slicer in &sd.slicers {
835                // Find the matching cache to get source info.
836                let cache = self
837                    .slicer_caches
838                    .iter()
839                    .find(|(_, sc)| sc.name == slicer.cache);
840
841                let (table_name, column_name) = if let Some((_, sc)) = cache {
842                    let tname = sc
843                        .table_slicer_cache
844                        .as_ref()
845                        .and_then(|tsc| self.table_name_by_id(tsc.table_id))
846                        .unwrap_or("")
847                        .to_string();
848                    let cname = sc
849                        .table_slicer_cache
850                        .as_ref()
851                        .and_then(|tsc| self.table_column_name_by_index(tsc.table_id, tsc.column))
852                        .unwrap_or(&sc.source_name)
853                        .to_string();
854                    (tname, cname)
855                } else {
856                    (String::new(), String::new())
857                };
858
859                result.push(crate::slicer::SlicerInfo {
860                    name: slicer.name.clone(),
861                    caption: slicer
862                        .caption
863                        .clone()
864                        .unwrap_or_else(|| slicer.name.clone()),
865                    table_name,
866                    column_name,
867                    style: slicer.style.clone(),
868                });
869            }
870        }
871
872        Ok(result)
873    }
874
875    /// Delete a slicer by name from a sheet.
876    ///
877    /// Removes the slicer definition, cache, content types, and relationships.
878    pub fn delete_slicer(&mut self, sheet: &str, name: &str) -> Result<()> {
879        let sheet_idx = self.sheet_index(sheet)?;
880
881        // Find the slicer definition containing this slicer name.
882        let sd_idx = self
883            .slicer_defs
884            .iter()
885            .position(|(_, sd)| sd.slicers.iter().any(|s| s.name == name))
886            .ok_or_else(|| Error::SlicerNotFound {
887                name: name.to_string(),
888            })?;
889
890        let (sd_path, sd) = &self.slicer_defs[sd_idx];
891
892        // Find the cache name linked to this slicer.
893        let cache_name = sd
894            .slicers
895            .iter()
896            .find(|s| s.name == name)
897            .map(|s| s.cache.clone())
898            .unwrap_or_default();
899
900        // If this is the only slicer in this definitions part, remove the whole part.
901        let remove_whole_part = sd.slicers.len() == 1;
902
903        if remove_whole_part {
904            let sd_path_clone = sd_path.clone();
905            self.slicer_defs.remove(sd_idx);
906
907            // Remove content type override.
908            let sd_part = format!("/{}", sd_path_clone);
909            self.content_types
910                .overrides
911                .retain(|o| o.part_name != sd_part);
912
913            // Remove worksheet relationship pointing to this slicer part.
914            let ws_path = self.sheet_part_path(sheet_idx);
915            if let Some(rels) = self.worksheet_rels.get_mut(&sheet_idx) {
916                rels.relationships.retain(|r| {
917                    if r.rel_type != rel_types::SLICER {
918                        return true;
919                    }
920                    let target =
921                        crate::workbook_paths::resolve_relationship_target(&ws_path, &r.target);
922                    target != sd_path_clone
923                });
924            }
925        } else {
926            // Remove just this slicer from the definitions.
927            self.slicer_defs[sd_idx]
928                .1
929                .slicers
930                .retain(|s| s.name != name);
931        }
932
933        // Remove the matching slicer cache.
934        if !cache_name.is_empty() {
935            if let Some(sc_idx) = self
936                .slicer_caches
937                .iter()
938                .position(|(_, sc)| sc.name == cache_name)
939            {
940                let (sc_path, _) = self.slicer_caches.remove(sc_idx);
941                let sc_part = format!("/{}", sc_path);
942                self.content_types
943                    .overrides
944                    .retain(|o| o.part_name != sc_part);
945
946                // Remove workbook relationship for this cache.
947                self.workbook_rels.relationships.retain(|r| {
948                    if r.rel_type != rel_types::SLICER_CACHE {
949                        return true;
950                    }
951                    let full_target = format!("xl/{}", r.target);
952                    full_target != sc_path
953                });
954            }
955        }
956
957        Ok(())
958    }
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use tempfile::TempDir;
965
966    fn make_pivot_workbook() -> Workbook {
967        let mut wb = Workbook::new();
968        wb.set_cell_value("Sheet1", "A1", "Name").unwrap();
969        wb.set_cell_value("Sheet1", "B1", "Region").unwrap();
970        wb.set_cell_value("Sheet1", "C1", "Sales").unwrap();
971        wb.set_cell_value("Sheet1", "A2", "Alice").unwrap();
972        wb.set_cell_value("Sheet1", "B2", "North").unwrap();
973        wb.set_cell_value("Sheet1", "C2", 100.0).unwrap();
974        wb.set_cell_value("Sheet1", "A3", "Bob").unwrap();
975        wb.set_cell_value("Sheet1", "B3", "South").unwrap();
976        wb.set_cell_value("Sheet1", "C3", 200.0).unwrap();
977        wb.set_cell_value("Sheet1", "A4", "Carol").unwrap();
978        wb.set_cell_value("Sheet1", "B4", "North").unwrap();
979        wb.set_cell_value("Sheet1", "C4", 150.0).unwrap();
980        wb
981    }
982
983    fn basic_pivot_config() -> PivotTableConfig {
984        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
985        PivotTableConfig {
986            name: "PivotTable1".to_string(),
987            source_sheet: "Sheet1".to_string(),
988            source_range: "A1:C4".to_string(),
989            target_sheet: "Sheet1".to_string(),
990            target_cell: "E1".to_string(),
991            rows: vec![PivotField {
992                name: "Name".to_string(),
993            }],
994            columns: vec![],
995            data: vec![PivotDataField {
996                name: "Sales".to_string(),
997                function: AggregateFunction::Sum,
998                display_name: None,
999            }],
1000        }
1001    }
1002
1003    #[test]
1004    fn test_add_pivot_table_basic() {
1005        let mut wb = make_pivot_workbook();
1006        let config = basic_pivot_config();
1007        wb.add_pivot_table(&config).unwrap();
1008
1009        assert_eq!(wb.pivot_tables.len(), 1);
1010        assert_eq!(wb.pivot_cache_defs.len(), 1);
1011        assert_eq!(wb.pivot_cache_records.len(), 1);
1012        assert_eq!(wb.pivot_tables[0].1.name, "PivotTable1");
1013        assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
1014    }
1015
1016    #[test]
1017    fn test_add_pivot_table_with_columns() {
1018        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1019        let mut wb = make_pivot_workbook();
1020        let config = PivotTableConfig {
1021            name: "PT2".to_string(),
1022            source_sheet: "Sheet1".to_string(),
1023            source_range: "A1:C4".to_string(),
1024            target_sheet: "Sheet1".to_string(),
1025            target_cell: "E1".to_string(),
1026            rows: vec![PivotField {
1027                name: "Name".to_string(),
1028            }],
1029            columns: vec![PivotField {
1030                name: "Region".to_string(),
1031            }],
1032            data: vec![PivotDataField {
1033                name: "Sales".to_string(),
1034                function: AggregateFunction::Average,
1035                display_name: Some("Avg Sales".to_string()),
1036            }],
1037        };
1038        wb.add_pivot_table(&config).unwrap();
1039
1040        let pt = &wb.pivot_tables[0].1;
1041        assert!(pt.row_fields.is_some());
1042        assert!(pt.col_fields.is_some());
1043        assert!(pt.data_fields.is_some());
1044    }
1045
1046    #[test]
1047    fn test_add_pivot_table_source_sheet_not_found() {
1048        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1049        let mut wb = Workbook::new();
1050        let config = PivotTableConfig {
1051            name: "PT".to_string(),
1052            source_sheet: "NonExistent".to_string(),
1053            source_range: "A1:B2".to_string(),
1054            target_sheet: "Sheet1".to_string(),
1055            target_cell: "A1".to_string(),
1056            rows: vec![PivotField {
1057                name: "Col1".to_string(),
1058            }],
1059            columns: vec![],
1060            data: vec![PivotDataField {
1061                name: "Col2".to_string(),
1062                function: AggregateFunction::Sum,
1063                display_name: None,
1064            }],
1065        };
1066        let result = wb.add_pivot_table(&config);
1067        assert!(result.is_err());
1068        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1069    }
1070
1071    #[test]
1072    fn test_add_pivot_table_target_sheet_not_found() {
1073        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1074        let mut wb = make_pivot_workbook();
1075        let config = PivotTableConfig {
1076            name: "PT".to_string(),
1077            source_sheet: "Sheet1".to_string(),
1078            source_range: "A1:C4".to_string(),
1079            target_sheet: "Report".to_string(),
1080            target_cell: "A1".to_string(),
1081            rows: vec![PivotField {
1082                name: "Name".to_string(),
1083            }],
1084            columns: vec![],
1085            data: vec![PivotDataField {
1086                name: "Sales".to_string(),
1087                function: AggregateFunction::Sum,
1088                display_name: None,
1089            }],
1090        };
1091        let result = wb.add_pivot_table(&config);
1092        assert!(result.is_err());
1093        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1094    }
1095
1096    #[test]
1097    fn test_add_pivot_table_duplicate_name() {
1098        let mut wb = make_pivot_workbook();
1099        let config = basic_pivot_config();
1100        wb.add_pivot_table(&config).unwrap();
1101
1102        let result = wb.add_pivot_table(&config);
1103        assert!(result.is_err());
1104        assert!(matches!(
1105            result.unwrap_err(),
1106            Error::PivotTableAlreadyExists { .. }
1107        ));
1108    }
1109
1110    #[test]
1111    fn test_get_pivot_tables_empty() {
1112        let wb = Workbook::new();
1113        let pts = wb.get_pivot_tables();
1114        assert!(pts.is_empty());
1115    }
1116
1117    #[test]
1118    fn test_get_pivot_tables_after_add() {
1119        let mut wb = make_pivot_workbook();
1120        let config = basic_pivot_config();
1121        wb.add_pivot_table(&config).unwrap();
1122
1123        let pts = wb.get_pivot_tables();
1124        assert_eq!(pts.len(), 1);
1125        assert_eq!(pts[0].name, "PivotTable1");
1126        assert_eq!(pts[0].source_sheet, "Sheet1");
1127        assert_eq!(pts[0].source_range, "A1:C4");
1128        assert_eq!(pts[0].target_sheet, "Sheet1");
1129        assert_eq!(pts[0].location, "E1");
1130    }
1131
1132    #[test]
1133    fn test_delete_pivot_table() {
1134        let mut wb = make_pivot_workbook();
1135        let config = basic_pivot_config();
1136        wb.add_pivot_table(&config).unwrap();
1137        assert_eq!(wb.pivot_tables.len(), 1);
1138
1139        wb.delete_pivot_table("PivotTable1").unwrap();
1140        assert!(wb.pivot_tables.is_empty());
1141        assert!(wb.pivot_cache_defs.is_empty());
1142        assert!(wb.pivot_cache_records.is_empty());
1143        assert!(wb.workbook_xml.pivot_caches.is_none());
1144
1145        // Content type overrides for pivot parts should be gone.
1146        let pivot_overrides: Vec<_> = wb
1147            .content_types
1148            .overrides
1149            .iter()
1150            .filter(|o| {
1151                o.content_type == mime_types::PIVOT_TABLE
1152                    || o.content_type == mime_types::PIVOT_CACHE_DEFINITION
1153                    || o.content_type == mime_types::PIVOT_CACHE_RECORDS
1154            })
1155            .collect();
1156        assert!(pivot_overrides.is_empty());
1157    }
1158
1159    #[test]
1160    fn test_delete_pivot_table_not_found() {
1161        let wb_result = Workbook::new().delete_pivot_table("NonExistent");
1162        assert!(wb_result.is_err());
1163        assert!(matches!(
1164            wb_result.unwrap_err(),
1165            Error::PivotTableNotFound { .. }
1166        ));
1167    }
1168
1169    #[test]
1170    fn test_pivot_table_save_open_roundtrip() {
1171        let dir = TempDir::new().unwrap();
1172        let path = dir.path().join("pivot_roundtrip.xlsx");
1173
1174        let mut wb = make_pivot_workbook();
1175        let config = basic_pivot_config();
1176        wb.add_pivot_table(&config).unwrap();
1177
1178        wb.save(&path).unwrap();
1179
1180        // Verify the ZIP contains pivot parts.
1181        let file = std::fs::File::open(&path).unwrap();
1182        let mut archive = zip::ZipArchive::new(file).unwrap();
1183        assert!(archive.by_name("xl/pivotTables/pivotTable1.xml").is_ok());
1184        assert!(archive
1185            .by_name("xl/pivotCache/pivotCacheDefinition1.xml")
1186            .is_ok());
1187        assert!(archive
1188            .by_name("xl/pivotCache/pivotCacheRecords1.xml")
1189            .is_ok());
1190
1191        // Re-open and verify pivot table is parsed.
1192        let wb2 = Workbook::open(&path).unwrap();
1193        assert_eq!(wb2.pivot_tables.len(), 1);
1194        assert_eq!(wb2.pivot_tables[0].1.name, "PivotTable1");
1195        assert_eq!(wb2.pivot_cache_defs.len(), 1);
1196        assert_eq!(wb2.pivot_cache_records.len(), 1);
1197    }
1198
1199    #[test]
1200    fn test_add_multiple_pivot_tables() {
1201        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1202        let mut wb = make_pivot_workbook();
1203
1204        let config1 = basic_pivot_config();
1205        wb.add_pivot_table(&config1).unwrap();
1206
1207        let config2 = PivotTableConfig {
1208            name: "PivotTable2".to_string(),
1209            source_sheet: "Sheet1".to_string(),
1210            source_range: "A1:C4".to_string(),
1211            target_sheet: "Sheet1".to_string(),
1212            target_cell: "H1".to_string(),
1213            rows: vec![PivotField {
1214                name: "Region".to_string(),
1215            }],
1216            columns: vec![],
1217            data: vec![PivotDataField {
1218                name: "Sales".to_string(),
1219                function: AggregateFunction::Count,
1220                display_name: None,
1221            }],
1222        };
1223        wb.add_pivot_table(&config2).unwrap();
1224
1225        assert_eq!(wb.pivot_tables.len(), 2);
1226        assert_eq!(wb.pivot_cache_defs.len(), 2);
1227        assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
1228        assert_eq!(wb.pivot_tables[1].1.cache_id, 1);
1229
1230        let pts = wb.get_pivot_tables();
1231        assert_eq!(pts.len(), 2);
1232        assert_eq!(pts[0].name, "PivotTable1");
1233        assert_eq!(pts[1].name, "PivotTable2");
1234    }
1235
1236    #[test]
1237    fn test_add_pivot_table_content_types_added() {
1238        let mut wb = make_pivot_workbook();
1239        let config = basic_pivot_config();
1240        wb.add_pivot_table(&config).unwrap();
1241
1242        let has_pt_ct = wb.content_types.overrides.iter().any(|o| {
1243            o.content_type == mime_types::PIVOT_TABLE
1244                && o.part_name == "/xl/pivotTables/pivotTable1.xml"
1245        });
1246        assert!(has_pt_ct);
1247
1248        let has_pcd_ct = wb.content_types.overrides.iter().any(|o| {
1249            o.content_type == mime_types::PIVOT_CACHE_DEFINITION
1250                && o.part_name == "/xl/pivotCache/pivotCacheDefinition1.xml"
1251        });
1252        assert!(has_pcd_ct);
1253
1254        let has_pcr_ct = wb.content_types.overrides.iter().any(|o| {
1255            o.content_type == mime_types::PIVOT_CACHE_RECORDS
1256                && o.part_name == "/xl/pivotCache/pivotCacheRecords1.xml"
1257        });
1258        assert!(has_pcr_ct);
1259    }
1260
1261    #[test]
1262    fn test_add_pivot_table_workbook_rels_and_pivot_caches() {
1263        let mut wb = make_pivot_workbook();
1264        let config = basic_pivot_config();
1265        wb.add_pivot_table(&config).unwrap();
1266
1267        // Workbook rels should have a pivot cache definition relationship.
1268        let cache_rel = wb
1269            .workbook_rels
1270            .relationships
1271            .iter()
1272            .find(|r| r.rel_type == rel_types::PIVOT_CACHE_DEF);
1273        assert!(cache_rel.is_some());
1274        let cache_rel = cache_rel.unwrap();
1275        assert_eq!(cache_rel.target, "pivotCache/pivotCacheDefinition1.xml");
1276
1277        // Workbook XML should have pivot caches.
1278        let pivot_caches = wb.workbook_xml.pivot_caches.as_ref().unwrap();
1279        assert_eq!(pivot_caches.caches.len(), 1);
1280        assert_eq!(pivot_caches.caches[0].cache_id, 0);
1281        assert_eq!(pivot_caches.caches[0].r_id, cache_rel.id);
1282    }
1283
1284    #[test]
1285    fn test_add_pivot_table_worksheet_rels_added() {
1286        let mut wb = make_pivot_workbook();
1287        let config = basic_pivot_config();
1288        wb.add_pivot_table(&config).unwrap();
1289
1290        // Sheet1 is index 0; its rels should have a pivot table relationship.
1291        let ws_rels = wb.worksheet_rels.get(&0).unwrap();
1292        let pt_rel = ws_rels
1293            .relationships
1294            .iter()
1295            .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
1296        assert!(pt_rel.is_some());
1297        assert_eq!(pt_rel.unwrap().target, "../pivotTables/pivotTable1.xml");
1298    }
1299
1300    #[test]
1301    fn test_add_pivot_table_on_separate_target_sheet() {
1302        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1303        let mut wb = make_pivot_workbook();
1304        wb.new_sheet("Report").unwrap();
1305
1306        let config = PivotTableConfig {
1307            name: "CrossSheet".to_string(),
1308            source_sheet: "Sheet1".to_string(),
1309            source_range: "A1:C4".to_string(),
1310            target_sheet: "Report".to_string(),
1311            target_cell: "A1".to_string(),
1312            rows: vec![PivotField {
1313                name: "Name".to_string(),
1314            }],
1315            columns: vec![],
1316            data: vec![PivotDataField {
1317                name: "Sales".to_string(),
1318                function: AggregateFunction::Sum,
1319                display_name: None,
1320            }],
1321        };
1322        wb.add_pivot_table(&config).unwrap();
1323
1324        let pts = wb.get_pivot_tables();
1325        assert_eq!(pts.len(), 1);
1326        assert_eq!(pts[0].target_sheet, "Report");
1327        assert_eq!(pts[0].source_sheet, "Sheet1");
1328
1329        // Worksheet rels should be on the Report sheet (index 1).
1330        let ws_rels = wb.worksheet_rels.get(&1).unwrap();
1331        let pt_rel = ws_rels
1332            .relationships
1333            .iter()
1334            .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
1335        assert!(pt_rel.is_some());
1336    }
1337
1338    #[test]
1339    fn test_pivot_table_invalid_source_range() {
1340        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1341        let mut wb = make_pivot_workbook();
1342        let config = PivotTableConfig {
1343            name: "BadRange".to_string(),
1344            source_sheet: "Sheet1".to_string(),
1345            source_range: "INVALID".to_string(),
1346            target_sheet: "Sheet1".to_string(),
1347            target_cell: "E1".to_string(),
1348            rows: vec![PivotField {
1349                name: "Name".to_string(),
1350            }],
1351            columns: vec![],
1352            data: vec![PivotDataField {
1353                name: "Sales".to_string(),
1354                function: AggregateFunction::Sum,
1355                display_name: None,
1356            }],
1357        };
1358        let result = wb.add_pivot_table(&config);
1359        assert!(result.is_err());
1360    }
1361
1362    #[test]
1363    fn test_delete_pivot_table_then_add_another() {
1364        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1365        let mut wb = make_pivot_workbook();
1366        let config1 = basic_pivot_config();
1367        wb.add_pivot_table(&config1).unwrap();
1368        wb.delete_pivot_table("PivotTable1").unwrap();
1369
1370        let config2 = PivotTableConfig {
1371            name: "PivotTable2".to_string(),
1372            source_sheet: "Sheet1".to_string(),
1373            source_range: "A1:C4".to_string(),
1374            target_sheet: "Sheet1".to_string(),
1375            target_cell: "E1".to_string(),
1376            rows: vec![PivotField {
1377                name: "Region".to_string(),
1378            }],
1379            columns: vec![],
1380            data: vec![PivotDataField {
1381                name: "Sales".to_string(),
1382                function: AggregateFunction::Max,
1383                display_name: None,
1384            }],
1385        };
1386        wb.add_pivot_table(&config2).unwrap();
1387
1388        assert_eq!(wb.pivot_tables.len(), 1);
1389        assert_eq!(wb.pivot_tables[0].1.name, "PivotTable2");
1390    }
1391
1392    #[test]
1393    fn test_pivot_table_cache_definition_stores_source_info() {
1394        let mut wb = make_pivot_workbook();
1395        let config = basic_pivot_config();
1396        wb.add_pivot_table(&config).unwrap();
1397
1398        let pcd = &wb.pivot_cache_defs[0].1;
1399        let ws_source = pcd.cache_source.worksheet_source.as_ref().unwrap();
1400        assert_eq!(ws_source.sheet, "Sheet1");
1401        assert_eq!(ws_source.reference, "A1:C4");
1402        assert_eq!(pcd.cache_fields.fields.len(), 3);
1403        assert_eq!(pcd.cache_fields.fields[0].name, "Name");
1404        assert_eq!(pcd.cache_fields.fields[1].name, "Region");
1405        assert_eq!(pcd.cache_fields.fields[2].name, "Sales");
1406    }
1407
1408    #[test]
1409    fn test_pivot_table_field_names_from_data() {
1410        let mut wb = make_pivot_workbook();
1411        let config = basic_pivot_config();
1412        wb.add_pivot_table(&config).unwrap();
1413
1414        let pt = &wb.pivot_tables[0].1;
1415        assert_eq!(pt.pivot_fields.fields.len(), 3);
1416        // Name is a row field.
1417        assert_eq!(pt.pivot_fields.fields[0].axis, Some("axisRow".to_string()));
1418        // Region is not used.
1419        assert_eq!(pt.pivot_fields.fields[1].axis, None);
1420        // Sales is a data field.
1421        assert_eq!(pt.pivot_fields.fields[2].data_field, Some(true));
1422    }
1423
1424    #[test]
1425    fn test_pivot_table_empty_header_row_error() {
1426        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1427        let mut wb = Workbook::new();
1428        // No data set in the sheet.
1429        let config = PivotTableConfig {
1430            name: "Empty".to_string(),
1431            source_sheet: "Sheet1".to_string(),
1432            source_range: "A1:B1".to_string(),
1433            target_sheet: "Sheet1".to_string(),
1434            target_cell: "D1".to_string(),
1435            rows: vec![PivotField {
1436                name: "X".to_string(),
1437            }],
1438            columns: vec![],
1439            data: vec![PivotDataField {
1440                name: "Y".to_string(),
1441                function: AggregateFunction::Sum,
1442                display_name: None,
1443            }],
1444        };
1445        let result = wb.add_pivot_table(&config);
1446        assert!(result.is_err());
1447    }
1448
1449    #[test]
1450    fn test_pivot_table_multiple_save_roundtrip() {
1451        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1452        let dir = TempDir::new().unwrap();
1453        let path = dir.path().join("multi_pivot.xlsx");
1454
1455        let mut wb = make_pivot_workbook();
1456        let config1 = basic_pivot_config();
1457        wb.add_pivot_table(&config1).unwrap();
1458
1459        let config2 = PivotTableConfig {
1460            name: "PT2".to_string(),
1461            source_sheet: "Sheet1".to_string(),
1462            source_range: "A1:C4".to_string(),
1463            target_sheet: "Sheet1".to_string(),
1464            target_cell: "H1".to_string(),
1465            rows: vec![PivotField {
1466                name: "Region".to_string(),
1467            }],
1468            columns: vec![],
1469            data: vec![PivotDataField {
1470                name: "Sales".to_string(),
1471                function: AggregateFunction::Min,
1472                display_name: None,
1473            }],
1474        };
1475        wb.add_pivot_table(&config2).unwrap();
1476        wb.save(&path).unwrap();
1477
1478        let wb2 = Workbook::open(&path).unwrap();
1479        assert_eq!(wb2.pivot_tables.len(), 2);
1480        let names: Vec<&str> = wb2
1481            .pivot_tables
1482            .iter()
1483            .map(|(_, pt)| pt.name.as_str())
1484            .collect();
1485        assert!(names.contains(&"PivotTable1"));
1486        assert!(names.contains(&"PT2"));
1487    }
1488
1489    #[test]
1490    fn test_calculate_all_with_dependency_order() {
1491        let mut wb = Workbook::new();
1492        // A1 = 10 (value)
1493        wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1494        // A2 = A1 * 2 (formula depends on A1)
1495        wb.set_cell_value(
1496            "Sheet1",
1497            "A2",
1498            CellValue::Formula {
1499                expr: "A1*2".to_string(),
1500                result: None,
1501            },
1502        )
1503        .unwrap();
1504        // A3 = A2 + A1 (formula depends on A2 and A1)
1505        wb.set_cell_value(
1506            "Sheet1",
1507            "A3",
1508            CellValue::Formula {
1509                expr: "A2+A1".to_string(),
1510                result: None,
1511            },
1512        )
1513        .unwrap();
1514
1515        wb.calculate_all().unwrap();
1516
1517        // A2 should be 20 (10 * 2)
1518        let a2 = wb.get_cell_value("Sheet1", "A2").unwrap();
1519        match a2 {
1520            CellValue::Formula { result, .. } => {
1521                assert_eq!(*result.unwrap(), CellValue::Number(20.0));
1522            }
1523            _ => panic!("A2 should be a formula cell"),
1524        }
1525
1526        // A3 should be 30 (20 + 10)
1527        let a3 = wb.get_cell_value("Sheet1", "A3").unwrap();
1528        match a3 {
1529            CellValue::Formula { result, .. } => {
1530                assert_eq!(*result.unwrap(), CellValue::Number(30.0));
1531            }
1532            _ => panic!("A3 should be a formula cell"),
1533        }
1534    }
1535
1536    #[test]
1537    fn test_calculate_all_no_formulas() {
1538        let mut wb = Workbook::new();
1539        wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1540        wb.set_cell_value("Sheet1", "B1", 20.0).unwrap();
1541        // Should succeed without error when there are no formulas.
1542        wb.calculate_all().unwrap();
1543    }
1544
1545    #[test]
1546    fn test_calculate_all_cycle_detection() {
1547        let mut wb = Workbook::new();
1548        // A1 = B1, B1 = A1
1549        wb.set_cell_value(
1550            "Sheet1",
1551            "A1",
1552            CellValue::Formula {
1553                expr: "B1".to_string(),
1554                result: None,
1555            },
1556        )
1557        .unwrap();
1558        wb.set_cell_value(
1559            "Sheet1",
1560            "B1",
1561            CellValue::Formula {
1562                expr: "A1".to_string(),
1563                result: None,
1564            },
1565        )
1566        .unwrap();
1567
1568        let result = wb.calculate_all();
1569        assert!(result.is_err());
1570        let err_str = result.unwrap_err().to_string();
1571        assert!(
1572            err_str.contains("circular reference"),
1573            "expected circular reference error, got: {err_str}"
1574        );
1575    }
1576
1577    #[test]
1578    fn test_set_get_doc_props() {
1579        let mut wb = Workbook::new();
1580        let props = crate::doc_props::DocProperties {
1581            title: Some("My Title".to_string()),
1582            subject: Some("My Subject".to_string()),
1583            creator: Some("Author".to_string()),
1584            keywords: Some("rust, excel".to_string()),
1585            description: Some("A test workbook".to_string()),
1586            last_modified_by: Some("Editor".to_string()),
1587            revision: Some("2".to_string()),
1588            created: Some("2024-01-01T00:00:00Z".to_string()),
1589            modified: Some("2024-06-01T12:00:00Z".to_string()),
1590            category: Some("Testing".to_string()),
1591            content_status: Some("Draft".to_string()),
1592        };
1593        wb.set_doc_props(props);
1594
1595        let got = wb.get_doc_props();
1596        assert_eq!(got.title.as_deref(), Some("My Title"));
1597        assert_eq!(got.subject.as_deref(), Some("My Subject"));
1598        assert_eq!(got.creator.as_deref(), Some("Author"));
1599        assert_eq!(got.keywords.as_deref(), Some("rust, excel"));
1600        assert_eq!(got.description.as_deref(), Some("A test workbook"));
1601        assert_eq!(got.last_modified_by.as_deref(), Some("Editor"));
1602        assert_eq!(got.revision.as_deref(), Some("2"));
1603        assert_eq!(got.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1604        assert_eq!(got.modified.as_deref(), Some("2024-06-01T12:00:00Z"));
1605        assert_eq!(got.category.as_deref(), Some("Testing"));
1606        assert_eq!(got.content_status.as_deref(), Some("Draft"));
1607    }
1608
1609    #[test]
1610    fn test_set_get_app_props() {
1611        let mut wb = Workbook::new();
1612        let props = crate::doc_props::AppProperties {
1613            application: Some("SheetKit".to_string()),
1614            doc_security: Some(0),
1615            company: Some("Acme Corp".to_string()),
1616            app_version: Some("1.0.0".to_string()),
1617            manager: Some("Boss".to_string()),
1618            template: Some("default.xltx".to_string()),
1619        };
1620        wb.set_app_props(props);
1621
1622        let got = wb.get_app_props();
1623        assert_eq!(got.application.as_deref(), Some("SheetKit"));
1624        assert_eq!(got.doc_security, Some(0));
1625        assert_eq!(got.company.as_deref(), Some("Acme Corp"));
1626        assert_eq!(got.app_version.as_deref(), Some("1.0.0"));
1627        assert_eq!(got.manager.as_deref(), Some("Boss"));
1628        assert_eq!(got.template.as_deref(), Some("default.xltx"));
1629    }
1630
1631    #[test]
1632    fn test_custom_property_crud() {
1633        let mut wb = Workbook::new();
1634
1635        // Set
1636        wb.set_custom_property(
1637            "Project",
1638            crate::doc_props::CustomPropertyValue::String("SheetKit".to_string()),
1639        );
1640
1641        // Get
1642        let val = wb.get_custom_property("Project");
1643        assert_eq!(
1644            val,
1645            Some(crate::doc_props::CustomPropertyValue::String(
1646                "SheetKit".to_string()
1647            ))
1648        );
1649
1650        // Update
1651        wb.set_custom_property(
1652            "Project",
1653            crate::doc_props::CustomPropertyValue::String("Updated".to_string()),
1654        );
1655        let val = wb.get_custom_property("Project");
1656        assert_eq!(
1657            val,
1658            Some(crate::doc_props::CustomPropertyValue::String(
1659                "Updated".to_string()
1660            ))
1661        );
1662
1663        // Delete
1664        assert!(wb.delete_custom_property("Project"));
1665        assert!(wb.get_custom_property("Project").is_none());
1666        assert!(!wb.delete_custom_property("Project")); // already gone
1667    }
1668
1669    #[test]
1670    fn test_doc_props_save_open_roundtrip() {
1671        let dir = TempDir::new().unwrap();
1672        let path = dir.path().join("doc_props.xlsx");
1673
1674        let mut wb = Workbook::new();
1675        wb.set_doc_props(crate::doc_props::DocProperties {
1676            title: Some("Test Title".to_string()),
1677            creator: Some("Test Author".to_string()),
1678            created: Some("2024-01-01T00:00:00Z".to_string()),
1679            ..Default::default()
1680        });
1681        wb.set_app_props(crate::doc_props::AppProperties {
1682            application: Some("SheetKit".to_string()),
1683            company: Some("TestCorp".to_string()),
1684            ..Default::default()
1685        });
1686        wb.set_custom_property("Version", crate::doc_props::CustomPropertyValue::Int(42));
1687        wb.save(&path).unwrap();
1688
1689        let wb2 = Workbook::open(&path).unwrap();
1690        let doc = wb2.get_doc_props();
1691        assert_eq!(doc.title.as_deref(), Some("Test Title"));
1692        assert_eq!(doc.creator.as_deref(), Some("Test Author"));
1693        assert_eq!(doc.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1694
1695        let app = wb2.get_app_props();
1696        assert_eq!(app.application.as_deref(), Some("SheetKit"));
1697        assert_eq!(app.company.as_deref(), Some("TestCorp"));
1698
1699        let custom = wb2.get_custom_property("Version");
1700        assert_eq!(custom, Some(crate::doc_props::CustomPropertyValue::Int(42)));
1701    }
1702
1703    #[test]
1704    fn test_open_without_doc_props() {
1705        // A newly created workbook saved without setting doc props should
1706        // still open gracefully (core/app/custom properties are all None).
1707        let dir = TempDir::new().unwrap();
1708        let path = dir.path().join("no_props.xlsx");
1709
1710        let wb = Workbook::new();
1711        wb.save(&path).unwrap();
1712
1713        let wb2 = Workbook::open(&path).unwrap();
1714        let doc = wb2.get_doc_props();
1715        assert!(doc.title.is_none());
1716        assert!(doc.creator.is_none());
1717
1718        let app = wb2.get_app_props();
1719        assert!(app.application.is_none());
1720
1721        assert!(wb2.get_custom_property("anything").is_none());
1722    }
1723
1724    #[test]
1725    fn test_custom_property_multiple_types() {
1726        let mut wb = Workbook::new();
1727
1728        wb.set_custom_property(
1729            "StringProp",
1730            crate::doc_props::CustomPropertyValue::String("hello".to_string()),
1731        );
1732        wb.set_custom_property("IntProp", crate::doc_props::CustomPropertyValue::Int(-7));
1733        wb.set_custom_property(
1734            "FloatProp",
1735            crate::doc_props::CustomPropertyValue::Float(3.15),
1736        );
1737        wb.set_custom_property(
1738            "BoolProp",
1739            crate::doc_props::CustomPropertyValue::Bool(true),
1740        );
1741        wb.set_custom_property(
1742            "DateProp",
1743            crate::doc_props::CustomPropertyValue::DateTime("2024-01-01T00:00:00Z".to_string()),
1744        );
1745
1746        assert_eq!(
1747            wb.get_custom_property("StringProp"),
1748            Some(crate::doc_props::CustomPropertyValue::String(
1749                "hello".to_string()
1750            ))
1751        );
1752        assert_eq!(
1753            wb.get_custom_property("IntProp"),
1754            Some(crate::doc_props::CustomPropertyValue::Int(-7))
1755        );
1756        assert_eq!(
1757            wb.get_custom_property("FloatProp"),
1758            Some(crate::doc_props::CustomPropertyValue::Float(3.15))
1759        );
1760        assert_eq!(
1761            wb.get_custom_property("BoolProp"),
1762            Some(crate::doc_props::CustomPropertyValue::Bool(true))
1763        );
1764        assert_eq!(
1765            wb.get_custom_property("DateProp"),
1766            Some(crate::doc_props::CustomPropertyValue::DateTime(
1767                "2024-01-01T00:00:00Z".to_string()
1768            ))
1769        );
1770    }
1771
1772    #[test]
1773    fn test_doc_props_default_values() {
1774        let wb = Workbook::new();
1775        let doc = wb.get_doc_props();
1776        assert!(doc.title.is_none());
1777        assert!(doc.subject.is_none());
1778        assert!(doc.creator.is_none());
1779        assert!(doc.keywords.is_none());
1780        assert!(doc.description.is_none());
1781        assert!(doc.last_modified_by.is_none());
1782        assert!(doc.revision.is_none());
1783        assert!(doc.created.is_none());
1784        assert!(doc.modified.is_none());
1785        assert!(doc.category.is_none());
1786        assert!(doc.content_status.is_none());
1787
1788        let app = wb.get_app_props();
1789        assert!(app.application.is_none());
1790        assert!(app.doc_security.is_none());
1791        assert!(app.company.is_none());
1792        assert!(app.app_version.is_none());
1793        assert!(app.manager.is_none());
1794        assert!(app.template.is_none());
1795    }
1796
1797    #[test]
1798    fn test_add_sparkline_and_get_sparklines() {
1799        let mut wb = Workbook::new();
1800        let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1801        wb.add_sparkline("Sheet1", &config).unwrap();
1802
1803        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1804        assert_eq!(sparklines.len(), 1);
1805        assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
1806        assert_eq!(sparklines[0].location, "B1");
1807    }
1808
1809    #[test]
1810    fn test_add_multiple_sparklines_to_same_sheet() {
1811        let mut wb = Workbook::new();
1812        let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1813        let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
1814        let mut config3 = crate::sparkline::SparklineConfig::new("Sheet1!C1:C10", "D1");
1815        config3.sparkline_type = crate::sparkline::SparklineType::Column;
1816
1817        wb.add_sparkline("Sheet1", &config1).unwrap();
1818        wb.add_sparkline("Sheet1", &config2).unwrap();
1819        wb.add_sparkline("Sheet1", &config3).unwrap();
1820
1821        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1822        assert_eq!(sparklines.len(), 3);
1823        assert_eq!(
1824            sparklines[2].sparkline_type,
1825            crate::sparkline::SparklineType::Column
1826        );
1827    }
1828
1829    #[test]
1830    fn test_remove_sparkline_by_location() {
1831        let mut wb = Workbook::new();
1832        let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1833        let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
1834        wb.add_sparkline("Sheet1", &config1).unwrap();
1835        wb.add_sparkline("Sheet1", &config2).unwrap();
1836
1837        wb.remove_sparkline("Sheet1", "B1").unwrap();
1838
1839        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1840        assert_eq!(sparklines.len(), 1);
1841        assert_eq!(sparklines[0].location, "B2");
1842    }
1843
1844    #[test]
1845    fn test_remove_nonexistent_sparkline_returns_error() {
1846        let mut wb = Workbook::new();
1847        let result = wb.remove_sparkline("Sheet1", "Z99");
1848        assert!(result.is_err());
1849    }
1850
1851    #[test]
1852    fn test_sparkline_on_nonexistent_sheet_returns_error() {
1853        let mut wb = Workbook::new();
1854        let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1855        let result = wb.add_sparkline("NoSuchSheet", &config);
1856        assert!(result.is_err());
1857        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1858
1859        let result = wb.get_sparklines("NoSuchSheet");
1860        assert!(result.is_err());
1861    }
1862
1863    #[test]
1864    fn test_sparkline_save_open_roundtrip() {
1865        let dir = TempDir::new().unwrap();
1866        let path = dir.path().join("sparkline_roundtrip.xlsx");
1867
1868        let mut wb = Workbook::new();
1869        for i in 1..=10 {
1870            wb.set_cell_value(
1871                "Sheet1",
1872                &format!("A{i}"),
1873                CellValue::Number(i as f64 * 10.0),
1874            )
1875            .unwrap();
1876        }
1877
1878        let mut config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1879        config.sparkline_type = crate::sparkline::SparklineType::Column;
1880        config.markers = true;
1881        config.high_point = true;
1882        config.line_weight = Some(1.5);
1883
1884        wb.add_sparkline("Sheet1", &config).unwrap();
1885
1886        let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A5", "C1");
1887        wb.add_sparkline("Sheet1", &config2).unwrap();
1888
1889        wb.save(&path).unwrap();
1890
1891        let wb2 = Workbook::open(&path).unwrap();
1892        let sparklines = wb2.get_sparklines("Sheet1").unwrap();
1893        assert_eq!(sparklines.len(), 2);
1894        assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
1895        assert_eq!(sparklines[0].location, "B1");
1896        assert_eq!(
1897            sparklines[0].sparkline_type,
1898            crate::sparkline::SparklineType::Column
1899        );
1900        assert!(sparklines[0].markers);
1901        assert!(sparklines[0].high_point);
1902        assert_eq!(sparklines[0].line_weight, Some(1.5));
1903        assert_eq!(sparklines[1].data_range, "Sheet1!A1:A5");
1904        assert_eq!(sparklines[1].location, "C1");
1905    }
1906
1907    #[test]
1908    fn test_sparkline_empty_sheet_returns_empty_vec() {
1909        let wb = Workbook::new();
1910        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1911        assert!(sparklines.is_empty());
1912    }
1913
1914    fn make_table_config(cols: &[&str]) -> crate::table::TableConfig {
1915        crate::table::TableConfig {
1916            name: "Table1".to_string(),
1917            display_name: "Table1".to_string(),
1918            range: "A1:D10".to_string(),
1919            columns: cols
1920                .iter()
1921                .map(|c| crate::table::TableColumn {
1922                    name: c.to_string(),
1923                    totals_row_function: None,
1924                    totals_row_label: None,
1925                })
1926                .collect(),
1927            ..crate::table::TableConfig::default()
1928        }
1929    }
1930
1931    fn make_slicer_workbook() -> Workbook {
1932        let mut wb = Workbook::new();
1933        let table = make_table_config(&["Status", "Region", "Category", "Col1", "Col2"]);
1934        wb.add_table("Sheet1", &table).unwrap();
1935        wb
1936    }
1937
1938    fn make_slicer_config(name: &str, col: &str) -> crate::slicer::SlicerConfig {
1939        crate::slicer::SlicerConfig {
1940            name: name.to_string(),
1941            cell: "F1".to_string(),
1942            table_name: "Table1".to_string(),
1943            column_name: col.to_string(),
1944            caption: None,
1945            style: None,
1946            width: None,
1947            height: None,
1948            show_caption: None,
1949            column_count: None,
1950        }
1951    }
1952
1953    #[test]
1954    fn test_add_slicer_basic() {
1955        let mut wb = make_slicer_workbook();
1956        let config = make_slicer_config("StatusFilter", "Status");
1957        wb.add_slicer("Sheet1", &config).unwrap();
1958
1959        let slicers = wb.get_slicers("Sheet1").unwrap();
1960        assert_eq!(slicers.len(), 1);
1961        assert_eq!(slicers[0].name, "StatusFilter");
1962        assert_eq!(slicers[0].column_name, "Status");
1963        assert_eq!(slicers[0].table_name, "Table1");
1964    }
1965
1966    #[test]
1967    fn test_add_slicer_with_options() {
1968        let mut wb = make_slicer_workbook();
1969        let config = crate::slicer::SlicerConfig {
1970            name: "RegionSlicer".to_string(),
1971            cell: "G2".to_string(),
1972            table_name: "Table1".to_string(),
1973            column_name: "Region".to_string(),
1974            caption: Some("Filter by Region".to_string()),
1975            style: Some("SlicerStyleLight1".to_string()),
1976            width: Some(300),
1977            height: Some(250),
1978            show_caption: Some(true),
1979            column_count: Some(2),
1980        };
1981        wb.add_slicer("Sheet1", &config).unwrap();
1982
1983        let slicers = wb.get_slicers("Sheet1").unwrap();
1984        assert_eq!(slicers.len(), 1);
1985        assert_eq!(slicers[0].caption, "Filter by Region");
1986        assert_eq!(slicers[0].style, Some("SlicerStyleLight1".to_string()));
1987    }
1988
1989    #[test]
1990    fn test_add_slicer_duplicate_name() {
1991        let mut wb = make_slicer_workbook();
1992        let config = make_slicer_config("MySlicer", "Status");
1993        wb.add_slicer("Sheet1", &config).unwrap();
1994
1995        let result = wb.add_slicer("Sheet1", &config);
1996        assert!(result.is_err());
1997        assert!(result.unwrap_err().to_string().contains("already exists"));
1998    }
1999
2000    #[test]
2001    fn test_add_slicer_invalid_sheet() {
2002        let mut wb = make_slicer_workbook();
2003        let config = make_slicer_config("S1", "Status");
2004        let result = wb.add_slicer("NoSuchSheet", &config);
2005        assert!(result.is_err());
2006    }
2007
2008    #[test]
2009    fn test_add_slicer_table_not_found() {
2010        let mut wb = Workbook::new();
2011        let config = crate::slicer::SlicerConfig {
2012            name: "S1".to_string(),
2013            cell: "F1".to_string(),
2014            table_name: "NonExistent".to_string(),
2015            column_name: "Col".to_string(),
2016            caption: None,
2017            style: None,
2018            width: None,
2019            height: None,
2020            show_caption: None,
2021            column_count: None,
2022        };
2023        let result = wb.add_slicer("Sheet1", &config);
2024        assert!(result.is_err());
2025        assert!(matches!(result.unwrap_err(), Error::TableNotFound { .. }));
2026    }
2027
2028    #[test]
2029    fn test_add_slicer_column_not_found() {
2030        let mut wb = make_slicer_workbook();
2031        let config = crate::slicer::SlicerConfig {
2032            name: "S1".to_string(),
2033            cell: "F1".to_string(),
2034            table_name: "Table1".to_string(),
2035            column_name: "NonExistentColumn".to_string(),
2036            caption: None,
2037            style: None,
2038            width: None,
2039            height: None,
2040            show_caption: None,
2041            column_count: None,
2042        };
2043        let result = wb.add_slicer("Sheet1", &config);
2044        assert!(result.is_err());
2045        assert!(matches!(
2046            result.unwrap_err(),
2047            Error::TableColumnNotFound { .. }
2048        ));
2049    }
2050
2051    #[test]
2052    fn test_add_slicer_correct_table_id_and_column() {
2053        let mut wb = make_slicer_workbook();
2054        let config = make_slicer_config("RegFilter", "Region");
2055        wb.add_slicer("Sheet1", &config).unwrap();
2056
2057        // Region is at index 1 (0-based), so column should be 2 (1-based).
2058        let cache = &wb.slicer_caches[0].1;
2059        let tsc = cache.table_slicer_cache.as_ref().unwrap();
2060        assert_eq!(tsc.table_id, 1);
2061        assert_eq!(tsc.column, 2);
2062    }
2063
2064    #[test]
2065    fn test_get_slicers_resolves_table_name() {
2066        let mut wb = make_slicer_workbook();
2067        wb.add_slicer("Sheet1", &make_slicer_config("S1", "Category"))
2068            .unwrap();
2069
2070        let slicers = wb.get_slicers("Sheet1").unwrap();
2071        assert_eq!(slicers.len(), 1);
2072        assert_eq!(slicers[0].table_name, "Table1");
2073        assert_eq!(slicers[0].column_name, "Category");
2074    }
2075
2076    #[test]
2077    fn test_get_slicers_empty() {
2078        let wb = Workbook::new();
2079        let slicers = wb.get_slicers("Sheet1").unwrap();
2080        assert!(slicers.is_empty());
2081    }
2082
2083    #[test]
2084    fn test_delete_slicer() {
2085        let mut wb = make_slicer_workbook();
2086        let config = make_slicer_config("S1", "Status");
2087        wb.add_slicer("Sheet1", &config).unwrap();
2088
2089        assert_eq!(wb.get_slicers("Sheet1").unwrap().len(), 1);
2090
2091        wb.delete_slicer("Sheet1", "S1").unwrap();
2092        assert_eq!(wb.get_slicers("Sheet1").unwrap().len(), 0);
2093    }
2094
2095    #[test]
2096    fn test_delete_slicer_not_found() {
2097        let mut wb = Workbook::new();
2098        let result = wb.delete_slicer("Sheet1", "NonExistent");
2099        assert!(result.is_err());
2100        assert!(result.unwrap_err().to_string().contains("not found"));
2101    }
2102
2103    #[test]
2104    fn test_delete_slicer_cleans_content_types() {
2105        let mut wb = make_slicer_workbook();
2106        let config = make_slicer_config("S1", "Status");
2107        wb.add_slicer("Sheet1", &config).unwrap();
2108
2109        let ct_before = wb.content_types.overrides.len();
2110        wb.delete_slicer("Sheet1", "S1").unwrap();
2111        let ct_after = wb.content_types.overrides.len();
2112
2113        // Two content type overrides (slicer + cache) should be removed.
2114        assert_eq!(ct_before - ct_after, 2);
2115    }
2116
2117    #[test]
2118    fn test_delete_slicer_cleans_workbook_rels() {
2119        let mut wb = make_slicer_workbook();
2120        let config = make_slicer_config("S1", "Status");
2121        wb.add_slicer("Sheet1", &config).unwrap();
2122
2123        let has_cache_rel = wb
2124            .workbook_rels
2125            .relationships
2126            .iter()
2127            .any(|r| r.rel_type == rel_types::SLICER_CACHE);
2128        assert!(has_cache_rel);
2129
2130        wb.delete_slicer("Sheet1", "S1").unwrap();
2131
2132        let has_cache_rel = wb
2133            .workbook_rels
2134            .relationships
2135            .iter()
2136            .any(|r| r.rel_type == rel_types::SLICER_CACHE);
2137        assert!(!has_cache_rel);
2138    }
2139
2140    #[test]
2141    fn test_multiple_slicers_on_same_sheet() {
2142        let mut wb = make_slicer_workbook();
2143        wb.add_slicer("Sheet1", &make_slicer_config("S1", "Col1"))
2144            .unwrap();
2145        wb.add_slicer("Sheet1", &make_slicer_config("S2", "Col2"))
2146            .unwrap();
2147
2148        let slicers = wb.get_slicers("Sheet1").unwrap();
2149        assert_eq!(slicers.len(), 2);
2150    }
2151
2152    #[test]
2153    fn test_slicer_roundtrip() {
2154        let tmp = TempDir::new().unwrap();
2155        let path = tmp.path().join("slicer_rt.xlsx");
2156
2157        let mut wb = make_slicer_workbook();
2158        wb.add_slicer("Sheet1", &make_slicer_config("MySlicer", "Category"))
2159            .unwrap();
2160        wb.save(&path).unwrap();
2161
2162        let wb2 = Workbook::open(&path).unwrap();
2163        let slicers = wb2.get_slicers("Sheet1").unwrap();
2164        assert_eq!(slicers.len(), 1);
2165        assert_eq!(slicers[0].name, "MySlicer");
2166        assert_eq!(slicers[0].column_name, "Category");
2167        assert_eq!(slicers[0].table_name, "Table1");
2168    }
2169
2170    #[test]
2171    fn test_slicer_content_types_added() {
2172        let mut wb = make_slicer_workbook();
2173        wb.add_slicer("Sheet1", &make_slicer_config("S1", "Status"))
2174            .unwrap();
2175
2176        let has_slicer_ct = wb
2177            .content_types
2178            .overrides
2179            .iter()
2180            .any(|o| o.content_type == mime_types::SLICER);
2181        let has_cache_ct = wb
2182            .content_types
2183            .overrides
2184            .iter()
2185            .any(|o| o.content_type == mime_types::SLICER_CACHE);
2186
2187        assert!(has_slicer_ct);
2188        assert!(has_cache_ct);
2189    }
2190
2191    #[test]
2192    fn test_slicer_worksheet_rels_added() {
2193        let mut wb = make_slicer_workbook();
2194        wb.add_slicer("Sheet1", &make_slicer_config("S1", "Status"))
2195            .unwrap();
2196
2197        let rels = wb.worksheet_rels.get(&0).unwrap();
2198        let has_slicer_rel = rels
2199            .relationships
2200            .iter()
2201            .any(|r| r.rel_type == rel_types::SLICER);
2202        assert!(has_slicer_rel);
2203    }
2204
2205    #[test]
2206    fn test_slicer_error_display() {
2207        let err = Error::SlicerNotFound {
2208            name: "Missing".to_string(),
2209        };
2210        assert_eq!(err.to_string(), "slicer 'Missing' not found");
2211
2212        let err = Error::SlicerAlreadyExists {
2213            name: "Dup".to_string(),
2214        };
2215        assert_eq!(err.to_string(), "slicer 'Dup' already exists");
2216    }
2217
2218    #[test]
2219    fn test_add_table_and_get_tables() {
2220        let mut wb = Workbook::new();
2221        let table = make_table_config(&["Name", "Age", "City"]);
2222        wb.add_table("Sheet1", &table).unwrap();
2223
2224        let tables = wb.get_tables("Sheet1").unwrap();
2225        assert_eq!(tables.len(), 1);
2226        assert_eq!(tables[0].name, "Table1");
2227        assert_eq!(tables[0].columns, vec!["Name", "Age", "City"]);
2228    }
2229
2230    #[test]
2231    fn test_add_table_duplicate_name() {
2232        let mut wb = Workbook::new();
2233        let table = make_table_config(&["Col"]);
2234        wb.add_table("Sheet1", &table).unwrap();
2235
2236        let result = wb.add_table("Sheet1", &table);
2237        assert!(result.is_err());
2238        assert!(result.unwrap_err().to_string().contains("already exists"));
2239    }
2240
2241    #[test]
2242    fn test_slicer_table_on_wrong_sheet() {
2243        let mut wb = Workbook::new();
2244        wb.new_sheet("Sheet2").unwrap();
2245        let table = make_table_config(&["Status"]);
2246        wb.add_table("Sheet2", &table).unwrap();
2247
2248        let config = make_slicer_config("S1", "Status");
2249        let result = wb.add_slicer("Sheet1", &config);
2250        assert!(result.is_err());
2251        assert!(matches!(result.unwrap_err(), Error::TableNotFound { .. }));
2252    }
2253}