Skip to main content

sheetkit_core/workbook/
data.rs

1use super::*;
2
3impl Workbook {
4    /// Add a pivot table to the workbook.
5    ///
6    /// The pivot table summarizes data from `config.source_sheet` /
7    /// `config.source_range` and places its output on `config.target_sheet`
8    /// starting at `config.target_cell`.
9    pub fn add_pivot_table(&mut self, config: &PivotTableConfig) -> Result<()> {
10        // Validate source sheet exists.
11        let _src_idx = self.sheet_index(&config.source_sheet)?;
12
13        // Validate target sheet exists.
14        let target_idx = self.sheet_index(&config.target_sheet)?;
15
16        // Check for duplicate name.
17        if self
18            .pivot_tables
19            .iter()
20            .any(|(_, pt)| pt.name == config.name)
21        {
22            return Err(Error::PivotTableAlreadyExists {
23                name: config.name.clone(),
24            });
25        }
26
27        // Read header row from the source data.
28        let field_names = self.read_header_row(&config.source_sheet, &config.source_range)?;
29        if field_names.is_empty() {
30            return Err(Error::InvalidSourceRange(
31                "source range header row is empty".to_string(),
32            ));
33        }
34
35        // Assign a cache ID (next available).
36        let cache_id = self
37            .pivot_tables
38            .iter()
39            .map(|(_, pt)| pt.cache_id)
40            .max()
41            .map(|m| m + 1)
42            .unwrap_or(0);
43
44        // Build XML structures.
45        let pt_def = crate::pivot::build_pivot_table_xml(config, cache_id, &field_names)?;
46        let pcd = crate::pivot::build_pivot_cache_definition(
47            &config.source_sheet,
48            &config.source_range,
49            &field_names,
50        );
51        let pcr = sheetkit_xml::pivot_cache::PivotCacheRecords {
52            xmlns: sheetkit_xml::namespaces::SPREADSHEET_ML.to_string(),
53            xmlns_r: sheetkit_xml::namespaces::RELATIONSHIPS.to_string(),
54            count: Some(0),
55            records: vec![],
56        };
57
58        // Determine part numbers.
59        let pt_num = self.pivot_tables.len() + 1;
60        let cache_num = self.pivot_cache_defs.len() + 1;
61
62        let pt_path = format!("xl/pivotTables/pivotTable{}.xml", pt_num);
63        let pcd_path = format!("xl/pivotCache/pivotCacheDefinition{}.xml", cache_num);
64        let pcr_path = format!("xl/pivotCache/pivotCacheRecords{}.xml", cache_num);
65
66        // Store parts.
67        self.pivot_tables.push((pt_path.clone(), pt_def));
68        self.pivot_cache_defs.push((pcd_path.clone(), pcd));
69        self.pivot_cache_records.push((pcr_path.clone(), pcr));
70
71        // Add content type overrides.
72        self.content_types.overrides.push(ContentTypeOverride {
73            part_name: format!("/{}", pt_path),
74            content_type: mime_types::PIVOT_TABLE.to_string(),
75        });
76        self.content_types.overrides.push(ContentTypeOverride {
77            part_name: format!("/{}", pcd_path),
78            content_type: mime_types::PIVOT_CACHE_DEFINITION.to_string(),
79        });
80        self.content_types.overrides.push(ContentTypeOverride {
81            part_name: format!("/{}", pcr_path),
82            content_type: mime_types::PIVOT_CACHE_RECORDS.to_string(),
83        });
84
85        // Add workbook relationship for pivot cache definition.
86        let wb_rid = crate::sheet::next_rid(&self.workbook_rels.relationships);
87        self.workbook_rels.relationships.push(Relationship {
88            id: wb_rid.clone(),
89            rel_type: rel_types::PIVOT_CACHE_DEF.to_string(),
90            target: format!("pivotCache/pivotCacheDefinition{}.xml", cache_num),
91            target_mode: None,
92        });
93
94        // Update workbook_xml.pivot_caches.
95        let pivot_caches = self
96            .workbook_xml
97            .pivot_caches
98            .get_or_insert_with(|| sheetkit_xml::workbook::PivotCaches { caches: vec![] });
99        pivot_caches
100            .caches
101            .push(sheetkit_xml::workbook::PivotCacheEntry {
102                cache_id,
103                r_id: wb_rid,
104            });
105
106        // Add worksheet relationship for pivot table on the target sheet.
107        let ws_rid = self.next_worksheet_rid(target_idx);
108        let ws_rels = self
109            .worksheet_rels
110            .entry(target_idx)
111            .or_insert_with(|| Relationships {
112                xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
113                relationships: vec![],
114            });
115        ws_rels.relationships.push(Relationship {
116            id: ws_rid,
117            rel_type: rel_types::PIVOT_TABLE.to_string(),
118            target: format!("../pivotTables/pivotTable{}.xml", pt_num),
119            target_mode: None,
120        });
121
122        Ok(())
123    }
124
125    /// Get information about all pivot tables in the workbook.
126    pub fn get_pivot_tables(&self) -> Vec<PivotTableInfo> {
127        self.pivot_tables
128            .iter()
129            .map(|(_path, pt)| {
130                // Find the matching cache definition by cache_id.
131                let (source_sheet, source_range) = self
132                    .pivot_cache_defs
133                    .iter()
134                    .enumerate()
135                    .find(|(i, _)| {
136                        self.workbook_xml
137                            .pivot_caches
138                            .as_ref()
139                            .and_then(|pc| pc.caches.iter().find(|e| e.cache_id == pt.cache_id))
140                            .is_some()
141                            || *i == pt.cache_id as usize
142                    })
143                    .and_then(|(_, (_, pcd))| {
144                        pcd.cache_source
145                            .worksheet_source
146                            .as_ref()
147                            .map(|ws| (ws.sheet.clone(), ws.reference.clone()))
148                    })
149                    .unwrap_or_default();
150
151                // Determine target sheet from the pivot table path.
152                let target_sheet = self.find_pivot_table_target_sheet(pt).unwrap_or_default();
153
154                PivotTableInfo {
155                    name: pt.name.clone(),
156                    source_sheet,
157                    source_range,
158                    target_sheet,
159                    location: pt.location.reference.clone(),
160                }
161            })
162            .collect()
163    }
164
165    /// Delete a pivot table by name.
166    pub fn delete_pivot_table(&mut self, name: &str) -> Result<()> {
167        // Find the pivot table.
168        let pt_idx = self
169            .pivot_tables
170            .iter()
171            .position(|(_, pt)| pt.name == name)
172            .ok_or_else(|| Error::PivotTableNotFound {
173                name: name.to_string(),
174            })?;
175
176        let (pt_path, pt_def) = self.pivot_tables.remove(pt_idx);
177        let cache_id = pt_def.cache_id;
178
179        // Remove the matching pivot cache definition and records.
180        // Find the workbook_xml pivot cache entry for this cache_id.
181        let mut wb_cache_rid = None;
182        if let Some(ref mut pivot_caches) = self.workbook_xml.pivot_caches {
183            if let Some(pos) = pivot_caches
184                .caches
185                .iter()
186                .position(|e| e.cache_id == cache_id)
187            {
188                wb_cache_rid = Some(pivot_caches.caches[pos].r_id.clone());
189                pivot_caches.caches.remove(pos);
190            }
191            if pivot_caches.caches.is_empty() {
192                self.workbook_xml.pivot_caches = None;
193            }
194        }
195
196        // Remove the workbook relationship for this cache.
197        if let Some(ref rid) = wb_cache_rid {
198            // Find the target to determine which cache def to remove.
199            if let Some(rel) = self
200                .workbook_rels
201                .relationships
202                .iter()
203                .find(|r| r.id == *rid)
204            {
205                let target_path = format!("xl/{}", rel.target);
206                self.pivot_cache_defs.retain(|(p, _)| *p != target_path);
207
208                // Remove matching cache records (same numbering).
209                let records_path = target_path.replace("pivotCacheDefinition", "pivotCacheRecords");
210                self.pivot_cache_records.retain(|(p, _)| *p != records_path);
211            }
212            self.workbook_rels.relationships.retain(|r| r.id != *rid);
213        }
214
215        // Remove content type overrides for the removed parts.
216        let pt_part = format!("/{}", pt_path);
217        self.content_types
218            .overrides
219            .retain(|o| o.part_name != pt_part);
220
221        // Also remove cache def and records content types if the paths were removed.
222        self.content_types.overrides.retain(|o| {
223            let p = o.part_name.trim_start_matches('/');
224            // Keep if it is still in our live lists.
225            if o.content_type == mime_types::PIVOT_CACHE_DEFINITION {
226                return self.pivot_cache_defs.iter().any(|(path, _)| path == p);
227            }
228            if o.content_type == mime_types::PIVOT_CACHE_RECORDS {
229                return self.pivot_cache_records.iter().any(|(path, _)| path == p);
230            }
231            if o.content_type == mime_types::PIVOT_TABLE {
232                return self.pivot_tables.iter().any(|(path, _)| path == p);
233            }
234            true
235        });
236
237        // Remove worksheet relationship for this pivot table.
238        for (_idx, rels) in self.worksheet_rels.iter_mut() {
239            rels.relationships.retain(|r| {
240                if r.rel_type != rel_types::PIVOT_TABLE {
241                    return true;
242                }
243                // Check if the target matches the removed pivot table.
244                let full_target = format!(
245                    "xl/pivotTables/{}",
246                    r.target.trim_start_matches("../pivotTables/")
247                );
248                full_target != pt_path
249            });
250        }
251
252        Ok(())
253    }
254
255    /// Add a sparkline to a worksheet.
256    pub fn add_sparkline(
257        &mut self,
258        sheet: &str,
259        config: &crate::sparkline::SparklineConfig,
260    ) -> Result<()> {
261        let idx = self.sheet_index(sheet)?;
262        crate::sparkline::validate_sparkline_config(config)?;
263        while self.sheet_sparklines.len() <= idx {
264            self.sheet_sparklines.push(vec![]);
265        }
266        self.sheet_sparklines[idx].push(config.clone());
267        Ok(())
268    }
269
270    /// Get all sparklines for a worksheet.
271    pub fn get_sparklines(&self, sheet: &str) -> Result<Vec<crate::sparkline::SparklineConfig>> {
272        let idx = self.sheet_index(sheet)?;
273        Ok(self.sheet_sparklines.get(idx).cloned().unwrap_or_default())
274    }
275
276    /// Remove a sparkline by its location cell reference.
277    pub fn remove_sparkline(&mut self, sheet: &str, location: &str) -> Result<()> {
278        let idx = self.sheet_index(sheet)?;
279        let sparklines = self
280            .sheet_sparklines
281            .get_mut(idx)
282            .ok_or_else(|| Error::Internal(format!("no sparkline data for sheet '{sheet}'")))?;
283        let pos = sparklines
284            .iter()
285            .position(|s| s.location == location)
286            .ok_or_else(|| {
287                Error::Internal(format!(
288                    "sparkline at location '{location}' not found on sheet '{sheet}'"
289                ))
290            })?;
291        sparklines.remove(pos);
292        Ok(())
293    }
294
295    /// Evaluate a single formula string in the context of `sheet`.
296    ///
297    /// A [`CellSnapshot`] is built from the current workbook state so
298    /// that cell references within the formula can be resolved.
299    pub fn evaluate_formula(&self, sheet: &str, formula: &str) -> Result<CellValue> {
300        // Validate the sheet exists.
301        let _ = self.sheet_index(sheet)?;
302        let parsed = crate::formula::parser::parse_formula(formula)?;
303        let snapshot = self.build_cell_snapshot(sheet)?;
304        crate::formula::eval::evaluate(&parsed, &snapshot)
305    }
306
307    /// Recalculate every formula cell across all sheets and store the
308    /// computed result back into each cell. Uses a dependency graph and
309    /// topological sort so formulas are evaluated after their dependencies.
310    pub fn calculate_all(&mut self) -> Result<()> {
311        use crate::formula::eval::{build_dependency_graph, topological_sort, CellCoord};
312
313        let sheet_names: Vec<String> = self.sheet_names().iter().map(|s| s.to_string()).collect();
314
315        // Collect all formula cells with their coordinates and formula strings.
316        let mut formula_cells: Vec<(CellCoord, String)> = Vec::new();
317        for sn in &sheet_names {
318            let ws = self
319                .worksheets
320                .iter()
321                .find(|(name, _)| name == sn)
322                .map(|(_, ws)| ws)
323                .ok_or_else(|| Error::SheetNotFound {
324                    name: sn.to_string(),
325                })?;
326            for row in &ws.sheet_data.rows {
327                for cell in &row.cells {
328                    if let Some(ref f) = cell.f {
329                        let formula_str = f.value.clone().unwrap_or_default();
330                        if !formula_str.is_empty() {
331                            if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
332                                formula_cells.push((
333                                    CellCoord {
334                                        sheet: sn.clone(),
335                                        col: c,
336                                        row: r,
337                                    },
338                                    formula_str,
339                                ));
340                            }
341                        }
342                    }
343                }
344            }
345        }
346
347        if formula_cells.is_empty() {
348            return Ok(());
349        }
350
351        // Build dependency graph and determine evaluation order.
352        let deps = build_dependency_graph(&formula_cells)?;
353        let coords: Vec<CellCoord> = formula_cells.iter().map(|(c, _)| c.clone()).collect();
354        let eval_order = topological_sort(&coords, &deps)?;
355
356        // Build a lookup from coord to formula string.
357        let formula_map: HashMap<CellCoord, String> = formula_cells.into_iter().collect();
358
359        // Build a snapshot of all cell data.
360        let first_sheet = sheet_names.first().cloned().unwrap_or_default();
361        let mut snapshot = self.build_cell_snapshot(&first_sheet)?;
362
363        // Evaluate in dependency order, updating the snapshot progressively
364        // so later formulas see already-computed results.
365        let mut results: Vec<(CellCoord, String, CellValue)> = Vec::new();
366        for coord in &eval_order {
367            if let Some(formula_str) = formula_map.get(coord) {
368                snapshot.set_current_sheet(&coord.sheet);
369                let parsed = crate::formula::parser::parse_formula(formula_str)?;
370                let mut evaluator = crate::formula::eval::Evaluator::new(&snapshot);
371                let result = evaluator.eval_expr(&parsed)?;
372                snapshot.set_cell(&coord.sheet, coord.col, coord.row, result.clone());
373                results.push((coord.clone(), formula_str.clone(), result));
374            }
375        }
376
377        // Write results back directly to the XML cells, preserving the
378        // formula element and storing the computed value in the v/t fields.
379        for (coord, _formula_str, result) in results {
380            let cell_ref = crate::utils::cell_ref::coordinates_to_cell_name(coord.col, coord.row)?;
381            if let Some((_, ws)) = self.worksheets.iter_mut().find(|(n, _)| *n == coord.sheet) {
382                if let Some(row) = ws.sheet_data.rows.iter_mut().find(|r| r.r == coord.row) {
383                    if let Some(cell) = row.cells.iter_mut().find(|c| c.r == *cell_ref) {
384                        match &result {
385                            CellValue::Number(n) => {
386                                cell.v = Some(n.to_string());
387                                cell.t = CellTypeTag::None;
388                            }
389                            CellValue::String(s) => {
390                                cell.v = Some(s.clone());
391                                cell.t = CellTypeTag::FormulaString;
392                            }
393                            CellValue::Bool(b) => {
394                                cell.v = Some(if *b { "1".to_string() } else { "0".to_string() });
395                                cell.t = CellTypeTag::Boolean;
396                            }
397                            CellValue::Error(e) => {
398                                cell.v = Some(e.clone());
399                                cell.t = CellTypeTag::Error;
400                            }
401                            CellValue::Date(n) => {
402                                cell.v = Some(n.to_string());
403                                cell.t = CellTypeTag::None;
404                            }
405                            _ => {}
406                        }
407                    }
408                }
409            }
410        }
411
412        Ok(())
413    }
414
415    /// Build a [`CellSnapshot`] for formula evaluation, with the given
416    /// sheet as the current-sheet context.
417    fn build_cell_snapshot(
418        &self,
419        current_sheet: &str,
420    ) -> Result<crate::formula::eval::CellSnapshot> {
421        let mut snapshot = crate::formula::eval::CellSnapshot::new(current_sheet.to_string());
422        for (sn, ws) in &self.worksheets {
423            for row in &ws.sheet_data.rows {
424                for cell in &row.cells {
425                    if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
426                        let cv = self.xml_cell_to_value(cell)?;
427                        snapshot.set_cell(sn, c, r, cv);
428                    }
429                }
430            }
431        }
432        Ok(snapshot)
433    }
434
435    /// Return `(col, row)` pairs for all occupied cells on the named sheet.
436    pub fn get_occupied_cells(&self, sheet: &str) -> Result<Vec<(u32, u32)>> {
437        let ws = self
438            .worksheets
439            .iter()
440            .find(|(name, _)| name == sheet)
441            .map(|(_, ws)| ws)
442            .ok_or_else(|| Error::SheetNotFound {
443                name: sheet.to_string(),
444            })?;
445        let mut cells = Vec::new();
446        for row in &ws.sheet_data.rows {
447            for cell in &row.cells {
448                if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
449                    cells.push((c, r));
450                }
451            }
452        }
453        Ok(cells)
454    }
455
456    /// Read the header row (first row) of a range from a sheet, returning cell
457    /// values as strings.
458    fn read_header_row(&self, sheet: &str, range: &str) -> Result<Vec<String>> {
459        let parts: Vec<&str> = range.split(':').collect();
460        if parts.len() != 2 {
461            return Err(Error::InvalidSourceRange(range.to_string()));
462        }
463        let (start_col, start_row) = cell_name_to_coordinates(parts[0])
464            .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
465        let (end_col, _end_row) = cell_name_to_coordinates(parts[1])
466            .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
467
468        let mut headers = Vec::new();
469        for col in start_col..=end_col {
470            let cell_name = crate::utils::cell_ref::coordinates_to_cell_name(col, start_row)?;
471            let val = self.get_cell_value(sheet, &cell_name)?;
472            let s = match val {
473                CellValue::String(s) => s,
474                CellValue::Number(n) => n.to_string(),
475                CellValue::Bool(b) => b.to_string(),
476                CellValue::RichString(runs) => crate::rich_text::rich_text_to_plain(&runs),
477                _ => String::new(),
478            };
479            headers.push(s);
480        }
481        Ok(headers)
482    }
483
484    /// Find the target sheet name for a pivot table by looking at worksheet
485    /// relationships that reference its path.
486    fn find_pivot_table_target_sheet(
487        &self,
488        pt: &sheetkit_xml::pivot_table::PivotTableDefinition,
489    ) -> Option<String> {
490        // Find the pivot table path.
491        let pt_path = self
492            .pivot_tables
493            .iter()
494            .find(|(_, p)| p.name == pt.name)
495            .map(|(path, _)| path.as_str())?;
496
497        // Find which worksheet has a relationship pointing to this pivot table.
498        for (sheet_idx, rels) in &self.worksheet_rels {
499            for r in &rels.relationships {
500                if r.rel_type == rel_types::PIVOT_TABLE {
501                    let full_target = format!(
502                        "xl/pivotTables/{}",
503                        r.target.trim_start_matches("../pivotTables/")
504                    );
505                    if full_target == pt_path {
506                        return self
507                            .worksheets
508                            .get(*sheet_idx)
509                            .map(|(name, _)| name.clone());
510                    }
511                }
512            }
513        }
514        None
515    }
516
517    /// Set the core document properties (title, author, etc.).
518    pub fn set_doc_props(&mut self, props: crate::doc_props::DocProperties) {
519        self.core_properties = Some(props.to_core_properties());
520        self.ensure_doc_props_content_types();
521    }
522
523    /// Get the core document properties.
524    pub fn get_doc_props(&self) -> crate::doc_props::DocProperties {
525        self.core_properties
526            .as_ref()
527            .map(crate::doc_props::DocProperties::from)
528            .unwrap_or_default()
529    }
530
531    /// Set the application properties (company, app version, etc.).
532    pub fn set_app_props(&mut self, props: crate::doc_props::AppProperties) {
533        self.app_properties = Some(props.to_extended_properties());
534        self.ensure_doc_props_content_types();
535    }
536
537    /// Get the application properties.
538    pub fn get_app_props(&self) -> crate::doc_props::AppProperties {
539        self.app_properties
540            .as_ref()
541            .map(crate::doc_props::AppProperties::from)
542            .unwrap_or_default()
543    }
544
545    /// Set a custom property by name. If a property with the same name already
546    /// exists, its value is replaced.
547    pub fn set_custom_property(
548        &mut self,
549        name: &str,
550        value: crate::doc_props::CustomPropertyValue,
551    ) {
552        let props = self
553            .custom_properties
554            .get_or_insert_with(sheetkit_xml::doc_props::CustomProperties::default);
555        crate::doc_props::set_custom_property(props, name, value);
556        self.ensure_custom_props_content_types();
557    }
558
559    /// Get a custom property value by name, or `None` if it does not exist.
560    pub fn get_custom_property(&self, name: &str) -> Option<crate::doc_props::CustomPropertyValue> {
561        self.custom_properties
562            .as_ref()
563            .and_then(|p| crate::doc_props::find_custom_property(p, name))
564    }
565
566    /// Remove a custom property by name. Returns `true` if a property was
567    /// found and removed.
568    pub fn delete_custom_property(&mut self, name: &str) -> bool {
569        if let Some(ref mut props) = self.custom_properties {
570            crate::doc_props::delete_custom_property(props, name)
571        } else {
572            false
573        }
574    }
575
576    /// Ensure content types contains entries for core and extended properties.
577    fn ensure_doc_props_content_types(&mut self) {
578        let core_part = "/docProps/core.xml";
579        let app_part = "/docProps/app.xml";
580
581        let has_core = self
582            .content_types
583            .overrides
584            .iter()
585            .any(|o| o.part_name == core_part);
586        if !has_core {
587            self.content_types.overrides.push(ContentTypeOverride {
588                part_name: core_part.to_string(),
589                content_type: mime_types::CORE_PROPERTIES.to_string(),
590            });
591        }
592
593        let has_app = self
594            .content_types
595            .overrides
596            .iter()
597            .any(|o| o.part_name == app_part);
598        if !has_app {
599            self.content_types.overrides.push(ContentTypeOverride {
600                part_name: app_part.to_string(),
601                content_type: mime_types::EXTENDED_PROPERTIES.to_string(),
602            });
603        }
604    }
605
606    /// Ensure content types and package rels contain entries for custom properties.
607    fn ensure_custom_props_content_types(&mut self) {
608        self.ensure_doc_props_content_types();
609
610        let custom_part = "/docProps/custom.xml";
611        let has_custom = self
612            .content_types
613            .overrides
614            .iter()
615            .any(|o| o.part_name == custom_part);
616        if !has_custom {
617            self.content_types.overrides.push(ContentTypeOverride {
618                part_name: custom_part.to_string(),
619                content_type: mime_types::CUSTOM_PROPERTIES.to_string(),
620            });
621        }
622
623        let has_custom_rel = self
624            .package_rels
625            .relationships
626            .iter()
627            .any(|r| r.rel_type == rel_types::CUSTOM_PROPERTIES);
628        if !has_custom_rel {
629            let next_id = self.package_rels.relationships.len() + 1;
630            self.package_rels.relationships.push(Relationship {
631                id: format!("rId{next_id}"),
632                rel_type: rel_types::CUSTOM_PROPERTIES.to_string(),
633                target: "docProps/custom.xml".to_string(),
634                target_mode: None,
635            });
636        }
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use tempfile::TempDir;
644
645    fn make_pivot_workbook() -> Workbook {
646        let mut wb = Workbook::new();
647        wb.set_cell_value("Sheet1", "A1", "Name").unwrap();
648        wb.set_cell_value("Sheet1", "B1", "Region").unwrap();
649        wb.set_cell_value("Sheet1", "C1", "Sales").unwrap();
650        wb.set_cell_value("Sheet1", "A2", "Alice").unwrap();
651        wb.set_cell_value("Sheet1", "B2", "North").unwrap();
652        wb.set_cell_value("Sheet1", "C2", 100.0).unwrap();
653        wb.set_cell_value("Sheet1", "A3", "Bob").unwrap();
654        wb.set_cell_value("Sheet1", "B3", "South").unwrap();
655        wb.set_cell_value("Sheet1", "C3", 200.0).unwrap();
656        wb.set_cell_value("Sheet1", "A4", "Carol").unwrap();
657        wb.set_cell_value("Sheet1", "B4", "North").unwrap();
658        wb.set_cell_value("Sheet1", "C4", 150.0).unwrap();
659        wb
660    }
661
662    fn basic_pivot_config() -> PivotTableConfig {
663        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
664        PivotTableConfig {
665            name: "PivotTable1".to_string(),
666            source_sheet: "Sheet1".to_string(),
667            source_range: "A1:C4".to_string(),
668            target_sheet: "Sheet1".to_string(),
669            target_cell: "E1".to_string(),
670            rows: vec![PivotField {
671                name: "Name".to_string(),
672            }],
673            columns: vec![],
674            data: vec![PivotDataField {
675                name: "Sales".to_string(),
676                function: AggregateFunction::Sum,
677                display_name: None,
678            }],
679        }
680    }
681
682    #[test]
683    fn test_add_pivot_table_basic() {
684        let mut wb = make_pivot_workbook();
685        let config = basic_pivot_config();
686        wb.add_pivot_table(&config).unwrap();
687
688        assert_eq!(wb.pivot_tables.len(), 1);
689        assert_eq!(wb.pivot_cache_defs.len(), 1);
690        assert_eq!(wb.pivot_cache_records.len(), 1);
691        assert_eq!(wb.pivot_tables[0].1.name, "PivotTable1");
692        assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
693    }
694
695    #[test]
696    fn test_add_pivot_table_with_columns() {
697        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
698        let mut wb = make_pivot_workbook();
699        let config = PivotTableConfig {
700            name: "PT2".to_string(),
701            source_sheet: "Sheet1".to_string(),
702            source_range: "A1:C4".to_string(),
703            target_sheet: "Sheet1".to_string(),
704            target_cell: "E1".to_string(),
705            rows: vec![PivotField {
706                name: "Name".to_string(),
707            }],
708            columns: vec![PivotField {
709                name: "Region".to_string(),
710            }],
711            data: vec![PivotDataField {
712                name: "Sales".to_string(),
713                function: AggregateFunction::Average,
714                display_name: Some("Avg Sales".to_string()),
715            }],
716        };
717        wb.add_pivot_table(&config).unwrap();
718
719        let pt = &wb.pivot_tables[0].1;
720        assert!(pt.row_fields.is_some());
721        assert!(pt.col_fields.is_some());
722        assert!(pt.data_fields.is_some());
723    }
724
725    #[test]
726    fn test_add_pivot_table_source_sheet_not_found() {
727        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
728        let mut wb = Workbook::new();
729        let config = PivotTableConfig {
730            name: "PT".to_string(),
731            source_sheet: "NonExistent".to_string(),
732            source_range: "A1:B2".to_string(),
733            target_sheet: "Sheet1".to_string(),
734            target_cell: "A1".to_string(),
735            rows: vec![PivotField {
736                name: "Col1".to_string(),
737            }],
738            columns: vec![],
739            data: vec![PivotDataField {
740                name: "Col2".to_string(),
741                function: AggregateFunction::Sum,
742                display_name: None,
743            }],
744        };
745        let result = wb.add_pivot_table(&config);
746        assert!(result.is_err());
747        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
748    }
749
750    #[test]
751    fn test_add_pivot_table_target_sheet_not_found() {
752        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
753        let mut wb = make_pivot_workbook();
754        let config = PivotTableConfig {
755            name: "PT".to_string(),
756            source_sheet: "Sheet1".to_string(),
757            source_range: "A1:C4".to_string(),
758            target_sheet: "Report".to_string(),
759            target_cell: "A1".to_string(),
760            rows: vec![PivotField {
761                name: "Name".to_string(),
762            }],
763            columns: vec![],
764            data: vec![PivotDataField {
765                name: "Sales".to_string(),
766                function: AggregateFunction::Sum,
767                display_name: None,
768            }],
769        };
770        let result = wb.add_pivot_table(&config);
771        assert!(result.is_err());
772        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
773    }
774
775    #[test]
776    fn test_add_pivot_table_duplicate_name() {
777        let mut wb = make_pivot_workbook();
778        let config = basic_pivot_config();
779        wb.add_pivot_table(&config).unwrap();
780
781        let result = wb.add_pivot_table(&config);
782        assert!(result.is_err());
783        assert!(matches!(
784            result.unwrap_err(),
785            Error::PivotTableAlreadyExists { .. }
786        ));
787    }
788
789    #[test]
790    fn test_get_pivot_tables_empty() {
791        let wb = Workbook::new();
792        let pts = wb.get_pivot_tables();
793        assert!(pts.is_empty());
794    }
795
796    #[test]
797    fn test_get_pivot_tables_after_add() {
798        let mut wb = make_pivot_workbook();
799        let config = basic_pivot_config();
800        wb.add_pivot_table(&config).unwrap();
801
802        let pts = wb.get_pivot_tables();
803        assert_eq!(pts.len(), 1);
804        assert_eq!(pts[0].name, "PivotTable1");
805        assert_eq!(pts[0].source_sheet, "Sheet1");
806        assert_eq!(pts[0].source_range, "A1:C4");
807        assert_eq!(pts[0].target_sheet, "Sheet1");
808        assert_eq!(pts[0].location, "E1");
809    }
810
811    #[test]
812    fn test_delete_pivot_table() {
813        let mut wb = make_pivot_workbook();
814        let config = basic_pivot_config();
815        wb.add_pivot_table(&config).unwrap();
816        assert_eq!(wb.pivot_tables.len(), 1);
817
818        wb.delete_pivot_table("PivotTable1").unwrap();
819        assert!(wb.pivot_tables.is_empty());
820        assert!(wb.pivot_cache_defs.is_empty());
821        assert!(wb.pivot_cache_records.is_empty());
822        assert!(wb.workbook_xml.pivot_caches.is_none());
823
824        // Content type overrides for pivot parts should be gone.
825        let pivot_overrides: Vec<_> = wb
826            .content_types
827            .overrides
828            .iter()
829            .filter(|o| {
830                o.content_type == mime_types::PIVOT_TABLE
831                    || o.content_type == mime_types::PIVOT_CACHE_DEFINITION
832                    || o.content_type == mime_types::PIVOT_CACHE_RECORDS
833            })
834            .collect();
835        assert!(pivot_overrides.is_empty());
836    }
837
838    #[test]
839    fn test_delete_pivot_table_not_found() {
840        let wb_result = Workbook::new().delete_pivot_table("NonExistent");
841        assert!(wb_result.is_err());
842        assert!(matches!(
843            wb_result.unwrap_err(),
844            Error::PivotTableNotFound { .. }
845        ));
846    }
847
848    #[test]
849    fn test_pivot_table_save_open_roundtrip() {
850        let dir = TempDir::new().unwrap();
851        let path = dir.path().join("pivot_roundtrip.xlsx");
852
853        let mut wb = make_pivot_workbook();
854        let config = basic_pivot_config();
855        wb.add_pivot_table(&config).unwrap();
856
857        wb.save(&path).unwrap();
858
859        // Verify the ZIP contains pivot parts.
860        let file = std::fs::File::open(&path).unwrap();
861        let mut archive = zip::ZipArchive::new(file).unwrap();
862        assert!(archive.by_name("xl/pivotTables/pivotTable1.xml").is_ok());
863        assert!(archive
864            .by_name("xl/pivotCache/pivotCacheDefinition1.xml")
865            .is_ok());
866        assert!(archive
867            .by_name("xl/pivotCache/pivotCacheRecords1.xml")
868            .is_ok());
869
870        // Re-open and verify pivot table is parsed.
871        let wb2 = Workbook::open(&path).unwrap();
872        assert_eq!(wb2.pivot_tables.len(), 1);
873        assert_eq!(wb2.pivot_tables[0].1.name, "PivotTable1");
874        assert_eq!(wb2.pivot_cache_defs.len(), 1);
875        assert_eq!(wb2.pivot_cache_records.len(), 1);
876    }
877
878    #[test]
879    fn test_add_multiple_pivot_tables() {
880        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
881        let mut wb = make_pivot_workbook();
882
883        let config1 = basic_pivot_config();
884        wb.add_pivot_table(&config1).unwrap();
885
886        let config2 = PivotTableConfig {
887            name: "PivotTable2".to_string(),
888            source_sheet: "Sheet1".to_string(),
889            source_range: "A1:C4".to_string(),
890            target_sheet: "Sheet1".to_string(),
891            target_cell: "H1".to_string(),
892            rows: vec![PivotField {
893                name: "Region".to_string(),
894            }],
895            columns: vec![],
896            data: vec![PivotDataField {
897                name: "Sales".to_string(),
898                function: AggregateFunction::Count,
899                display_name: None,
900            }],
901        };
902        wb.add_pivot_table(&config2).unwrap();
903
904        assert_eq!(wb.pivot_tables.len(), 2);
905        assert_eq!(wb.pivot_cache_defs.len(), 2);
906        assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
907        assert_eq!(wb.pivot_tables[1].1.cache_id, 1);
908
909        let pts = wb.get_pivot_tables();
910        assert_eq!(pts.len(), 2);
911        assert_eq!(pts[0].name, "PivotTable1");
912        assert_eq!(pts[1].name, "PivotTable2");
913    }
914
915    #[test]
916    fn test_add_pivot_table_content_types_added() {
917        let mut wb = make_pivot_workbook();
918        let config = basic_pivot_config();
919        wb.add_pivot_table(&config).unwrap();
920
921        let has_pt_ct = wb.content_types.overrides.iter().any(|o| {
922            o.content_type == mime_types::PIVOT_TABLE
923                && o.part_name == "/xl/pivotTables/pivotTable1.xml"
924        });
925        assert!(has_pt_ct);
926
927        let has_pcd_ct = wb.content_types.overrides.iter().any(|o| {
928            o.content_type == mime_types::PIVOT_CACHE_DEFINITION
929                && o.part_name == "/xl/pivotCache/pivotCacheDefinition1.xml"
930        });
931        assert!(has_pcd_ct);
932
933        let has_pcr_ct = wb.content_types.overrides.iter().any(|o| {
934            o.content_type == mime_types::PIVOT_CACHE_RECORDS
935                && o.part_name == "/xl/pivotCache/pivotCacheRecords1.xml"
936        });
937        assert!(has_pcr_ct);
938    }
939
940    #[test]
941    fn test_add_pivot_table_workbook_rels_and_pivot_caches() {
942        let mut wb = make_pivot_workbook();
943        let config = basic_pivot_config();
944        wb.add_pivot_table(&config).unwrap();
945
946        // Workbook rels should have a pivot cache definition relationship.
947        let cache_rel = wb
948            .workbook_rels
949            .relationships
950            .iter()
951            .find(|r| r.rel_type == rel_types::PIVOT_CACHE_DEF);
952        assert!(cache_rel.is_some());
953        let cache_rel = cache_rel.unwrap();
954        assert_eq!(cache_rel.target, "pivotCache/pivotCacheDefinition1.xml");
955
956        // Workbook XML should have pivot caches.
957        let pivot_caches = wb.workbook_xml.pivot_caches.as_ref().unwrap();
958        assert_eq!(pivot_caches.caches.len(), 1);
959        assert_eq!(pivot_caches.caches[0].cache_id, 0);
960        assert_eq!(pivot_caches.caches[0].r_id, cache_rel.id);
961    }
962
963    #[test]
964    fn test_add_pivot_table_worksheet_rels_added() {
965        let mut wb = make_pivot_workbook();
966        let config = basic_pivot_config();
967        wb.add_pivot_table(&config).unwrap();
968
969        // Sheet1 is index 0; its rels should have a pivot table relationship.
970        let ws_rels = wb.worksheet_rels.get(&0).unwrap();
971        let pt_rel = ws_rels
972            .relationships
973            .iter()
974            .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
975        assert!(pt_rel.is_some());
976        assert_eq!(pt_rel.unwrap().target, "../pivotTables/pivotTable1.xml");
977    }
978
979    #[test]
980    fn test_add_pivot_table_on_separate_target_sheet() {
981        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
982        let mut wb = make_pivot_workbook();
983        wb.new_sheet("Report").unwrap();
984
985        let config = PivotTableConfig {
986            name: "CrossSheet".to_string(),
987            source_sheet: "Sheet1".to_string(),
988            source_range: "A1:C4".to_string(),
989            target_sheet: "Report".to_string(),
990            target_cell: "A1".to_string(),
991            rows: vec![PivotField {
992                name: "Name".to_string(),
993            }],
994            columns: vec![],
995            data: vec![PivotDataField {
996                name: "Sales".to_string(),
997                function: AggregateFunction::Sum,
998                display_name: None,
999            }],
1000        };
1001        wb.add_pivot_table(&config).unwrap();
1002
1003        let pts = wb.get_pivot_tables();
1004        assert_eq!(pts.len(), 1);
1005        assert_eq!(pts[0].target_sheet, "Report");
1006        assert_eq!(pts[0].source_sheet, "Sheet1");
1007
1008        // Worksheet rels should be on the Report sheet (index 1).
1009        let ws_rels = wb.worksheet_rels.get(&1).unwrap();
1010        let pt_rel = ws_rels
1011            .relationships
1012            .iter()
1013            .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
1014        assert!(pt_rel.is_some());
1015    }
1016
1017    #[test]
1018    fn test_pivot_table_invalid_source_range() {
1019        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1020        let mut wb = make_pivot_workbook();
1021        let config = PivotTableConfig {
1022            name: "BadRange".to_string(),
1023            source_sheet: "Sheet1".to_string(),
1024            source_range: "INVALID".to_string(),
1025            target_sheet: "Sheet1".to_string(),
1026            target_cell: "E1".to_string(),
1027            rows: vec![PivotField {
1028                name: "Name".to_string(),
1029            }],
1030            columns: vec![],
1031            data: vec![PivotDataField {
1032                name: "Sales".to_string(),
1033                function: AggregateFunction::Sum,
1034                display_name: None,
1035            }],
1036        };
1037        let result = wb.add_pivot_table(&config);
1038        assert!(result.is_err());
1039    }
1040
1041    #[test]
1042    fn test_delete_pivot_table_then_add_another() {
1043        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1044        let mut wb = make_pivot_workbook();
1045        let config1 = basic_pivot_config();
1046        wb.add_pivot_table(&config1).unwrap();
1047        wb.delete_pivot_table("PivotTable1").unwrap();
1048
1049        let config2 = PivotTableConfig {
1050            name: "PivotTable2".to_string(),
1051            source_sheet: "Sheet1".to_string(),
1052            source_range: "A1:C4".to_string(),
1053            target_sheet: "Sheet1".to_string(),
1054            target_cell: "E1".to_string(),
1055            rows: vec![PivotField {
1056                name: "Region".to_string(),
1057            }],
1058            columns: vec![],
1059            data: vec![PivotDataField {
1060                name: "Sales".to_string(),
1061                function: AggregateFunction::Max,
1062                display_name: None,
1063            }],
1064        };
1065        wb.add_pivot_table(&config2).unwrap();
1066
1067        assert_eq!(wb.pivot_tables.len(), 1);
1068        assert_eq!(wb.pivot_tables[0].1.name, "PivotTable2");
1069    }
1070
1071    #[test]
1072    fn test_pivot_table_cache_definition_stores_source_info() {
1073        let mut wb = make_pivot_workbook();
1074        let config = basic_pivot_config();
1075        wb.add_pivot_table(&config).unwrap();
1076
1077        let pcd = &wb.pivot_cache_defs[0].1;
1078        let ws_source = pcd.cache_source.worksheet_source.as_ref().unwrap();
1079        assert_eq!(ws_source.sheet, "Sheet1");
1080        assert_eq!(ws_source.reference, "A1:C4");
1081        assert_eq!(pcd.cache_fields.fields.len(), 3);
1082        assert_eq!(pcd.cache_fields.fields[0].name, "Name");
1083        assert_eq!(pcd.cache_fields.fields[1].name, "Region");
1084        assert_eq!(pcd.cache_fields.fields[2].name, "Sales");
1085    }
1086
1087    #[test]
1088    fn test_pivot_table_field_names_from_data() {
1089        let mut wb = make_pivot_workbook();
1090        let config = basic_pivot_config();
1091        wb.add_pivot_table(&config).unwrap();
1092
1093        let pt = &wb.pivot_tables[0].1;
1094        assert_eq!(pt.pivot_fields.fields.len(), 3);
1095        // Name is a row field.
1096        assert_eq!(pt.pivot_fields.fields[0].axis, Some("axisRow".to_string()));
1097        // Region is not used.
1098        assert_eq!(pt.pivot_fields.fields[1].axis, None);
1099        // Sales is a data field.
1100        assert_eq!(pt.pivot_fields.fields[2].data_field, Some(true));
1101    }
1102
1103    #[test]
1104    fn test_pivot_table_empty_header_row_error() {
1105        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1106        let mut wb = Workbook::new();
1107        // No data set in the sheet.
1108        let config = PivotTableConfig {
1109            name: "Empty".to_string(),
1110            source_sheet: "Sheet1".to_string(),
1111            source_range: "A1:B1".to_string(),
1112            target_sheet: "Sheet1".to_string(),
1113            target_cell: "D1".to_string(),
1114            rows: vec![PivotField {
1115                name: "X".to_string(),
1116            }],
1117            columns: vec![],
1118            data: vec![PivotDataField {
1119                name: "Y".to_string(),
1120                function: AggregateFunction::Sum,
1121                display_name: None,
1122            }],
1123        };
1124        let result = wb.add_pivot_table(&config);
1125        assert!(result.is_err());
1126    }
1127
1128    #[test]
1129    fn test_pivot_table_multiple_save_roundtrip() {
1130        use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1131        let dir = TempDir::new().unwrap();
1132        let path = dir.path().join("multi_pivot.xlsx");
1133
1134        let mut wb = make_pivot_workbook();
1135        let config1 = basic_pivot_config();
1136        wb.add_pivot_table(&config1).unwrap();
1137
1138        let config2 = PivotTableConfig {
1139            name: "PT2".to_string(),
1140            source_sheet: "Sheet1".to_string(),
1141            source_range: "A1:C4".to_string(),
1142            target_sheet: "Sheet1".to_string(),
1143            target_cell: "H1".to_string(),
1144            rows: vec![PivotField {
1145                name: "Region".to_string(),
1146            }],
1147            columns: vec![],
1148            data: vec![PivotDataField {
1149                name: "Sales".to_string(),
1150                function: AggregateFunction::Min,
1151                display_name: None,
1152            }],
1153        };
1154        wb.add_pivot_table(&config2).unwrap();
1155        wb.save(&path).unwrap();
1156
1157        let wb2 = Workbook::open(&path).unwrap();
1158        assert_eq!(wb2.pivot_tables.len(), 2);
1159        let names: Vec<&str> = wb2
1160            .pivot_tables
1161            .iter()
1162            .map(|(_, pt)| pt.name.as_str())
1163            .collect();
1164        assert!(names.contains(&"PivotTable1"));
1165        assert!(names.contains(&"PT2"));
1166    }
1167
1168    #[test]
1169    fn test_calculate_all_with_dependency_order() {
1170        let mut wb = Workbook::new();
1171        // A1 = 10 (value)
1172        wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1173        // A2 = A1 * 2 (formula depends on A1)
1174        wb.set_cell_value(
1175            "Sheet1",
1176            "A2",
1177            CellValue::Formula {
1178                expr: "A1*2".to_string(),
1179                result: None,
1180            },
1181        )
1182        .unwrap();
1183        // A3 = A2 + A1 (formula depends on A2 and A1)
1184        wb.set_cell_value(
1185            "Sheet1",
1186            "A3",
1187            CellValue::Formula {
1188                expr: "A2+A1".to_string(),
1189                result: None,
1190            },
1191        )
1192        .unwrap();
1193
1194        wb.calculate_all().unwrap();
1195
1196        // A2 should be 20 (10 * 2)
1197        let a2 = wb.get_cell_value("Sheet1", "A2").unwrap();
1198        match a2 {
1199            CellValue::Formula { result, .. } => {
1200                assert_eq!(*result.unwrap(), CellValue::Number(20.0));
1201            }
1202            _ => panic!("A2 should be a formula cell"),
1203        }
1204
1205        // A3 should be 30 (20 + 10)
1206        let a3 = wb.get_cell_value("Sheet1", "A3").unwrap();
1207        match a3 {
1208            CellValue::Formula { result, .. } => {
1209                assert_eq!(*result.unwrap(), CellValue::Number(30.0));
1210            }
1211            _ => panic!("A3 should be a formula cell"),
1212        }
1213    }
1214
1215    #[test]
1216    fn test_calculate_all_no_formulas() {
1217        let mut wb = Workbook::new();
1218        wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1219        wb.set_cell_value("Sheet1", "B1", 20.0).unwrap();
1220        // Should succeed without error when there are no formulas.
1221        wb.calculate_all().unwrap();
1222    }
1223
1224    #[test]
1225    fn test_calculate_all_cycle_detection() {
1226        let mut wb = Workbook::new();
1227        // A1 = B1, B1 = A1
1228        wb.set_cell_value(
1229            "Sheet1",
1230            "A1",
1231            CellValue::Formula {
1232                expr: "B1".to_string(),
1233                result: None,
1234            },
1235        )
1236        .unwrap();
1237        wb.set_cell_value(
1238            "Sheet1",
1239            "B1",
1240            CellValue::Formula {
1241                expr: "A1".to_string(),
1242                result: None,
1243            },
1244        )
1245        .unwrap();
1246
1247        let result = wb.calculate_all();
1248        assert!(result.is_err());
1249        let err_str = result.unwrap_err().to_string();
1250        assert!(
1251            err_str.contains("circular reference"),
1252            "expected circular reference error, got: {err_str}"
1253        );
1254    }
1255
1256    #[test]
1257    fn test_set_get_doc_props() {
1258        let mut wb = Workbook::new();
1259        let props = crate::doc_props::DocProperties {
1260            title: Some("My Title".to_string()),
1261            subject: Some("My Subject".to_string()),
1262            creator: Some("Author".to_string()),
1263            keywords: Some("rust, excel".to_string()),
1264            description: Some("A test workbook".to_string()),
1265            last_modified_by: Some("Editor".to_string()),
1266            revision: Some("2".to_string()),
1267            created: Some("2024-01-01T00:00:00Z".to_string()),
1268            modified: Some("2024-06-01T12:00:00Z".to_string()),
1269            category: Some("Testing".to_string()),
1270            content_status: Some("Draft".to_string()),
1271        };
1272        wb.set_doc_props(props);
1273
1274        let got = wb.get_doc_props();
1275        assert_eq!(got.title.as_deref(), Some("My Title"));
1276        assert_eq!(got.subject.as_deref(), Some("My Subject"));
1277        assert_eq!(got.creator.as_deref(), Some("Author"));
1278        assert_eq!(got.keywords.as_deref(), Some("rust, excel"));
1279        assert_eq!(got.description.as_deref(), Some("A test workbook"));
1280        assert_eq!(got.last_modified_by.as_deref(), Some("Editor"));
1281        assert_eq!(got.revision.as_deref(), Some("2"));
1282        assert_eq!(got.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1283        assert_eq!(got.modified.as_deref(), Some("2024-06-01T12:00:00Z"));
1284        assert_eq!(got.category.as_deref(), Some("Testing"));
1285        assert_eq!(got.content_status.as_deref(), Some("Draft"));
1286    }
1287
1288    #[test]
1289    fn test_set_get_app_props() {
1290        let mut wb = Workbook::new();
1291        let props = crate::doc_props::AppProperties {
1292            application: Some("SheetKit".to_string()),
1293            doc_security: Some(0),
1294            company: Some("Acme Corp".to_string()),
1295            app_version: Some("1.0.0".to_string()),
1296            manager: Some("Boss".to_string()),
1297            template: Some("default.xltx".to_string()),
1298        };
1299        wb.set_app_props(props);
1300
1301        let got = wb.get_app_props();
1302        assert_eq!(got.application.as_deref(), Some("SheetKit"));
1303        assert_eq!(got.doc_security, Some(0));
1304        assert_eq!(got.company.as_deref(), Some("Acme Corp"));
1305        assert_eq!(got.app_version.as_deref(), Some("1.0.0"));
1306        assert_eq!(got.manager.as_deref(), Some("Boss"));
1307        assert_eq!(got.template.as_deref(), Some("default.xltx"));
1308    }
1309
1310    #[test]
1311    fn test_custom_property_crud() {
1312        let mut wb = Workbook::new();
1313
1314        // Set
1315        wb.set_custom_property(
1316            "Project",
1317            crate::doc_props::CustomPropertyValue::String("SheetKit".to_string()),
1318        );
1319
1320        // Get
1321        let val = wb.get_custom_property("Project");
1322        assert_eq!(
1323            val,
1324            Some(crate::doc_props::CustomPropertyValue::String(
1325                "SheetKit".to_string()
1326            ))
1327        );
1328
1329        // Update
1330        wb.set_custom_property(
1331            "Project",
1332            crate::doc_props::CustomPropertyValue::String("Updated".to_string()),
1333        );
1334        let val = wb.get_custom_property("Project");
1335        assert_eq!(
1336            val,
1337            Some(crate::doc_props::CustomPropertyValue::String(
1338                "Updated".to_string()
1339            ))
1340        );
1341
1342        // Delete
1343        assert!(wb.delete_custom_property("Project"));
1344        assert!(wb.get_custom_property("Project").is_none());
1345        assert!(!wb.delete_custom_property("Project")); // already gone
1346    }
1347
1348    #[test]
1349    fn test_doc_props_save_open_roundtrip() {
1350        let dir = TempDir::new().unwrap();
1351        let path = dir.path().join("doc_props.xlsx");
1352
1353        let mut wb = Workbook::new();
1354        wb.set_doc_props(crate::doc_props::DocProperties {
1355            title: Some("Test Title".to_string()),
1356            creator: Some("Test Author".to_string()),
1357            created: Some("2024-01-01T00:00:00Z".to_string()),
1358            ..Default::default()
1359        });
1360        wb.set_app_props(crate::doc_props::AppProperties {
1361            application: Some("SheetKit".to_string()),
1362            company: Some("TestCorp".to_string()),
1363            ..Default::default()
1364        });
1365        wb.set_custom_property("Version", crate::doc_props::CustomPropertyValue::Int(42));
1366        wb.save(&path).unwrap();
1367
1368        let wb2 = Workbook::open(&path).unwrap();
1369        let doc = wb2.get_doc_props();
1370        assert_eq!(doc.title.as_deref(), Some("Test Title"));
1371        assert_eq!(doc.creator.as_deref(), Some("Test Author"));
1372        assert_eq!(doc.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1373
1374        let app = wb2.get_app_props();
1375        assert_eq!(app.application.as_deref(), Some("SheetKit"));
1376        assert_eq!(app.company.as_deref(), Some("TestCorp"));
1377
1378        let custom = wb2.get_custom_property("Version");
1379        assert_eq!(custom, Some(crate::doc_props::CustomPropertyValue::Int(42)));
1380    }
1381
1382    #[test]
1383    fn test_open_without_doc_props() {
1384        // A newly created workbook saved without setting doc props should
1385        // still open gracefully (core/app/custom properties are all None).
1386        let dir = TempDir::new().unwrap();
1387        let path = dir.path().join("no_props.xlsx");
1388
1389        let wb = Workbook::new();
1390        wb.save(&path).unwrap();
1391
1392        let wb2 = Workbook::open(&path).unwrap();
1393        let doc = wb2.get_doc_props();
1394        assert!(doc.title.is_none());
1395        assert!(doc.creator.is_none());
1396
1397        let app = wb2.get_app_props();
1398        assert!(app.application.is_none());
1399
1400        assert!(wb2.get_custom_property("anything").is_none());
1401    }
1402
1403    #[test]
1404    fn test_custom_property_multiple_types() {
1405        let mut wb = Workbook::new();
1406
1407        wb.set_custom_property(
1408            "StringProp",
1409            crate::doc_props::CustomPropertyValue::String("hello".to_string()),
1410        );
1411        wb.set_custom_property("IntProp", crate::doc_props::CustomPropertyValue::Int(-7));
1412        wb.set_custom_property(
1413            "FloatProp",
1414            crate::doc_props::CustomPropertyValue::Float(3.15),
1415        );
1416        wb.set_custom_property(
1417            "BoolProp",
1418            crate::doc_props::CustomPropertyValue::Bool(true),
1419        );
1420        wb.set_custom_property(
1421            "DateProp",
1422            crate::doc_props::CustomPropertyValue::DateTime("2024-01-01T00:00:00Z".to_string()),
1423        );
1424
1425        assert_eq!(
1426            wb.get_custom_property("StringProp"),
1427            Some(crate::doc_props::CustomPropertyValue::String(
1428                "hello".to_string()
1429            ))
1430        );
1431        assert_eq!(
1432            wb.get_custom_property("IntProp"),
1433            Some(crate::doc_props::CustomPropertyValue::Int(-7))
1434        );
1435        assert_eq!(
1436            wb.get_custom_property("FloatProp"),
1437            Some(crate::doc_props::CustomPropertyValue::Float(3.15))
1438        );
1439        assert_eq!(
1440            wb.get_custom_property("BoolProp"),
1441            Some(crate::doc_props::CustomPropertyValue::Bool(true))
1442        );
1443        assert_eq!(
1444            wb.get_custom_property("DateProp"),
1445            Some(crate::doc_props::CustomPropertyValue::DateTime(
1446                "2024-01-01T00:00:00Z".to_string()
1447            ))
1448        );
1449    }
1450
1451    #[test]
1452    fn test_doc_props_default_values() {
1453        let wb = Workbook::new();
1454        let doc = wb.get_doc_props();
1455        assert!(doc.title.is_none());
1456        assert!(doc.subject.is_none());
1457        assert!(doc.creator.is_none());
1458        assert!(doc.keywords.is_none());
1459        assert!(doc.description.is_none());
1460        assert!(doc.last_modified_by.is_none());
1461        assert!(doc.revision.is_none());
1462        assert!(doc.created.is_none());
1463        assert!(doc.modified.is_none());
1464        assert!(doc.category.is_none());
1465        assert!(doc.content_status.is_none());
1466
1467        let app = wb.get_app_props();
1468        assert!(app.application.is_none());
1469        assert!(app.doc_security.is_none());
1470        assert!(app.company.is_none());
1471        assert!(app.app_version.is_none());
1472        assert!(app.manager.is_none());
1473        assert!(app.template.is_none());
1474    }
1475
1476    #[test]
1477    fn test_add_sparkline_and_get_sparklines() {
1478        let mut wb = Workbook::new();
1479        let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1480        wb.add_sparkline("Sheet1", &config).unwrap();
1481
1482        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1483        assert_eq!(sparklines.len(), 1);
1484        assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
1485        assert_eq!(sparklines[0].location, "B1");
1486    }
1487
1488    #[test]
1489    fn test_add_multiple_sparklines_to_same_sheet() {
1490        let mut wb = Workbook::new();
1491        let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1492        let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
1493        let mut config3 = crate::sparkline::SparklineConfig::new("Sheet1!C1:C10", "D1");
1494        config3.sparkline_type = crate::sparkline::SparklineType::Column;
1495
1496        wb.add_sparkline("Sheet1", &config1).unwrap();
1497        wb.add_sparkline("Sheet1", &config2).unwrap();
1498        wb.add_sparkline("Sheet1", &config3).unwrap();
1499
1500        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1501        assert_eq!(sparklines.len(), 3);
1502        assert_eq!(
1503            sparklines[2].sparkline_type,
1504            crate::sparkline::SparklineType::Column
1505        );
1506    }
1507
1508    #[test]
1509    fn test_remove_sparkline_by_location() {
1510        let mut wb = Workbook::new();
1511        let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1512        let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
1513        wb.add_sparkline("Sheet1", &config1).unwrap();
1514        wb.add_sparkline("Sheet1", &config2).unwrap();
1515
1516        wb.remove_sparkline("Sheet1", "B1").unwrap();
1517
1518        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1519        assert_eq!(sparklines.len(), 1);
1520        assert_eq!(sparklines[0].location, "B2");
1521    }
1522
1523    #[test]
1524    fn test_remove_nonexistent_sparkline_returns_error() {
1525        let mut wb = Workbook::new();
1526        let result = wb.remove_sparkline("Sheet1", "Z99");
1527        assert!(result.is_err());
1528    }
1529
1530    #[test]
1531    fn test_sparkline_on_nonexistent_sheet_returns_error() {
1532        let mut wb = Workbook::new();
1533        let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1534        let result = wb.add_sparkline("NoSuchSheet", &config);
1535        assert!(result.is_err());
1536        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1537
1538        let result = wb.get_sparklines("NoSuchSheet");
1539        assert!(result.is_err());
1540    }
1541
1542    #[test]
1543    fn test_sparkline_save_open_roundtrip() {
1544        let dir = TempDir::new().unwrap();
1545        let path = dir.path().join("sparkline_roundtrip.xlsx");
1546
1547        let mut wb = Workbook::new();
1548        for i in 1..=10 {
1549            wb.set_cell_value(
1550                "Sheet1",
1551                &format!("A{i}"),
1552                CellValue::Number(i as f64 * 10.0),
1553            )
1554            .unwrap();
1555        }
1556
1557        let mut config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1558        config.sparkline_type = crate::sparkline::SparklineType::Column;
1559        config.markers = true;
1560        config.high_point = true;
1561        config.line_weight = Some(1.5);
1562
1563        wb.add_sparkline("Sheet1", &config).unwrap();
1564
1565        let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A5", "C1");
1566        wb.add_sparkline("Sheet1", &config2).unwrap();
1567
1568        wb.save(&path).unwrap();
1569
1570        let wb2 = Workbook::open(&path).unwrap();
1571        let sparklines = wb2.get_sparklines("Sheet1").unwrap();
1572        assert_eq!(sparklines.len(), 2);
1573        assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
1574        assert_eq!(sparklines[0].location, "B1");
1575        assert_eq!(
1576            sparklines[0].sparkline_type,
1577            crate::sparkline::SparklineType::Column
1578        );
1579        assert!(sparklines[0].markers);
1580        assert!(sparklines[0].high_point);
1581        assert_eq!(sparklines[0].line_weight, Some(1.5));
1582        assert_eq!(sparklines[1].data_range, "Sheet1!A1:A5");
1583        assert_eq!(sparklines[1].location, "C1");
1584    }
1585
1586    #[test]
1587    fn test_sparkline_empty_sheet_returns_empty_vec() {
1588        let wb = Workbook::new();
1589        let sparklines = wb.get_sparklines("Sheet1").unwrap();
1590        assert!(sparklines.is_empty());
1591    }
1592}