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