Skip to main content

visi_core/core/
workbook.rs

1//! Workbook-level orchestration: the sheets, charts, pivot tables and VBA
2//! project of one `.xlsx` file, plus the CRUD and recalculation operations
3//! that span more than a single [`Sheet`].
4//!
5//! This layer is where cross-sheet behavior lives. [`Sheet::commit`] only
6//! propagates *local* dependencies; [`WorkbookManager::evaluate`] is what
7//! carries values across sheets. Likewise nothing recomputes a pivot table
8//! implicitly -- [`WorkbookManager::refresh_pivot_table`] is the only thing
9//! that writes a computed grid into cells. Embedders should drive the
10//! workbook through this type rather than manipulating [`Sheet`] directly,
11//! or cross-sheet formulas and pivot output will silently go stale.
12//!
13//! Loading and saving are byte-oriented ([`WorkbookManager::load_bytes`] /
14//! [`WorkbookManager::save_bytes`]) so this stays usable off the filesystem,
15//! including on wasm. The `visi` CLI layers path- and stdio-based helpers on
16//! top via its own `WorkbookFile` trait.
17
18use crate::core::parser::col_idx_to_letters;
19use crate::core::xlsx::{export_xlsx_data, import_xlsx_data};
20use crate::core::{
21    ExcelTable, PivotAggregation, PivotArea, PivotField, PivotFilterField, PivotGrid, PivotSource,
22    PivotTable, PivotValueField, VbaModule, VbaModuleKind, VbaProject,
23    chart::{Chart, ChartType},
24    compute_pivot,
25    engine::{Context, DataColumn, ResultData, Sheet, generate_unique_id},
26    validate_vba_module_name,
27};
28use crate::{Error, ObjectKind};
29
30/// A single sheet's line in a [`WorkbookSummary`].
31pub struct SheetSummary {
32    /// The sheet's name.
33    pub name: String,
34    /// Allocated rows.
35    pub row_count: usize,
36    /// Allocated columns.
37    pub col_count: usize,
38    /// How many of its cells hold a formula rather than a literal.
39    pub formula_count: usize,
40}
41
42/// An overview of a workbook's shape, for reporting rather than editing.
43pub struct WorkbookSummary {
44    /// The file the workbook was loaded from, as the caller named it.
45    pub file_name: String,
46    /// How many sheets it has.
47    pub sheet_count: usize,
48    /// How many charts it has.
49    pub chart_count: usize,
50    /// One entry per sheet, in workbook order.
51    pub sheets: Vec<SheetSummary>,
52}
53
54/// A whole workbook: its sheets, charts, pivot tables and VBA project, and the
55/// operations that span more than one of them.
56///
57/// The entry point to this crate, and the layer an embedder should drive.
58/// Two behaviors are only correct at this level:
59///
60/// - **Cross-sheet formulas.** [`Sheet::commit`] propagates local dependencies
61///   only; [`WorkbookManager::evaluate`] is what carries values between
62///   sheets.
63/// - **Pivot tables.** Nothing recomputes one implicitly.
64///   [`WorkbookManager::refresh_pivot_table`] is the only thing that writes a
65///   computed grid into cells.
66///
67/// Editing the [`Sheet`]s directly is allowed -- the fields are public -- but
68/// skips both, so cross-sheet formulas and pivot output go stale silently.
69pub struct WorkbookManager {
70    /// The worksheets, in workbook order. Cell coordinates within them are
71    /// 0-based.
72    pub sheets: Vec<Sheet>,
73    /// The charts. Workbook-level rather than sheet-scoped; which sheet a
74    /// chart is drawn on comes from its `data_range`.
75    pub charts: Vec<Chart>,
76    /// The pivot table definitions. Workbook-level, since a pivot's source and
77    /// destination may be on different sheets.
78    pub pivot_tables: Vec<PivotTable>,
79    /// The VBA project, if the workbook has macros.
80    pub vba_project: Option<VbaProject>,
81}
82
83/// Quotes a materialized pivot label that would otherwise be re-parsed as a
84/// number, boolean, or formula by `Sheet::commit`'s literal-cell parsing
85/// (mirrors `xlsx::text_cell_src`'s treatment of imported text cells).
86fn pivot_label_literal(text: &str) -> String {
87    if text.is_empty() {
88        String::new()
89    } else if text.starts_with('=')
90        || text.parse::<f64>().is_ok()
91        || text.eq_ignore_ascii_case("true")
92        || text.eq_ignore_ascii_case("false")
93    {
94        format!("\"{}\"", text)
95    } else {
96        text.to_string()
97    }
98}
99
100/// Renders one aggregated pivot value as literal cell text; errors (e.g.
101/// `AVERAGE` over zero numeric records) are written as their Excel error
102/// string rather than `ResultData`'s human-readable `"Error: ..."` form.
103fn pivot_value_literal(v: &ResultData) -> String {
104    match v {
105        ResultData::Error(e) => e.clone(),
106        other => other.to_string(),
107    }
108}
109
110fn remove_pivot_field(fields: &mut Vec<PivotField>, column: &str) -> bool {
111    let before = fields.len();
112    fields.retain(|f| !f.column.eq_ignore_ascii_case(column));
113    before != fields.len()
114}
115
116impl WorkbookManager {
117    /// Load Excel workbook from bytes buffer
118    pub fn load_bytes(buffer: &[u8]) -> crate::Result<Self> {
119        let (imported_tables, charts, pivot_tables, vba_project) =
120            import_xlsx_data(buffer, &[], |_, _, _| {})?;
121
122        let sheets = imported_tables.into_iter().map(|it| it.sheet).collect();
123        Ok(Self {
124            sheets,
125            charts,
126            pivot_tables,
127            vba_project,
128        })
129    }
130
131    /// Serialize the workbook to `.xlsx` bytes.
132    ///
133    /// The byte-level counterpart to [`Self::load_bytes`]. Callers that want
134    /// to read or write an actual file supply their own IO -- the `visi` CLI
135    /// does so through its `WorkbookFile` trait.
136    pub fn save_bytes(&self) -> crate::Result<Vec<u8>> {
137        export_xlsx_data(
138            &self.sheets,
139            &self.charts,
140            &self.pivot_tables,
141            self.vba_project.as_ref(),
142        )
143    }
144
145    /// A new workbook containing a single empty sheet named `Sheet1`.
146    pub fn new_empty() -> crate::Result<Self> {
147        let mut wb = Self {
148            sheets: Vec::new(),
149            charts: Vec::new(),
150            pivot_tables: Vec::new(),
151            vba_project: None,
152        };
153        wb.add_sheet("Sheet1")?;
154        Ok(wb)
155    }
156
157    /// Recalculate all formulas in all sheets using visi-core engine
158    pub fn evaluate(&mut self) -> crate::Result<()> {
159        if self.sheets.is_empty() {
160            return Ok(());
161        }
162
163        // `self.sheets` is the one place true workbook order exists --
164        // `Context.sheets` is an unordered `HashMap` -- so `SHEET()` needs
165        // this collected once up front rather than derived from a context.
166        let sheet_order: Vec<String> = self.sheets.iter().map(|s| s.name.clone()).collect();
167
168        // Multi-pass evaluation to resolve cross-sheet formula dependencies.
169        // Every cell is re-marked dirty at the start of *each* pass, not
170        // just once before the loop -- `Sheet::commit` drains and clears a
171        // sheet's dirty queue as it processes it, so without re-marking,
172        // passes 2 and 3 had nothing left dirty and were silent no-ops.
173        // That meant a cross-sheet chain more than one hop deep (sheet A's
174        // formula depends on sheet B's formula depending on sheet A) kept
175        // whatever stale value pass 1 happened to compute before B had a
176        // chance to update -- found via the fuzzer's new cross-sheet
177        // generator block (#26).
178        for _pass in 0..3 {
179            for sheet in &mut self.sheets {
180                sheet.mark_all_dirty();
181            }
182            for i in 0..self.sheets.len() {
183                let (left, right) = self.sheets.split_at_mut(i);
184                let (target_sheet, right_tail) = right.split_first_mut().unwrap();
185
186                let mut context = Context::new();
187                for s in left.iter() {
188                    context.add_table(s.name.clone(), s);
189                }
190                for s in right_tail.iter() {
191                    context.add_table(s.name.clone(), s);
192                }
193                context.pivot_tables = &self.pivot_tables;
194                context.sheet_order = sheet_order.clone();
195
196                let _ = target_sheet.commit(Some(&context));
197            }
198        }
199
200        Ok(())
201    }
202
203    /// Find index of sheet by name, or return default index 0 if name is None.
204    pub fn find_sheet_index(&self, name_opt: Option<&str>) -> crate::Result<usize> {
205        if self.sheets.is_empty() {
206            return Err(Error::EmptyWorkbook);
207        }
208
209        match name_opt {
210            Some(name) => {
211                if let Some(idx) = self
212                    .sheets
213                    .iter()
214                    .position(|s| s.name.eq_ignore_ascii_case(name))
215                {
216                    Ok(idx)
217                } else {
218                    let available: Vec<String> =
219                        self.sheets.iter().map(|s| s.name.clone()).collect();
220                    Err(Error::not_found_among(
221                        ObjectKind::Sheet,
222                        name.to_string(),
223                        available,
224                    ))
225                }
226            }
227            None => Ok(0),
228        }
229    }
230
231    /// Get structural summary of workbook
232    pub fn get_summary(&self, file_name: &str) -> WorkbookSummary {
233        let sheet_summaries = self
234            .sheets
235            .iter()
236            .map(|sheet| {
237                let row_count = sheet.row_count();
238                let col_count = sheet.col_count();
239                let mut formula_count = 0;
240
241                for col in &sheet.columns {
242                    for src in &col.src {
243                        if src.starts_with('=') {
244                            formula_count += 1;
245                        }
246                    }
247                }
248
249                SheetSummary {
250                    name: sheet.name.clone(),
251                    row_count,
252                    col_count,
253                    formula_count,
254                }
255            })
256            .collect();
257
258        WorkbookSummary {
259            file_name: file_name.to_string(),
260            sheet_count: self.sheets.len(),
261            chart_count: self.charts.len(),
262            sheets: sheet_summaries,
263        }
264    }
265
266    /// Ensure sheet bounds can accommodate specified target_row and target_col
267    pub fn ensure_capacity(&mut self, sheet_idx: usize, target_row: usize, target_col: usize) {
268        if sheet_idx >= self.sheets.len() {
269            return;
270        }
271        self.sheets[sheet_idx].ensure_capacity(target_row, target_col);
272    }
273
274    /// Merges `style` into one cell's existing style.
275    ///
276    /// Row and column are 0-based, like every other coordinate on this type.
277    /// A1 notation is a CLI/parser-boundary concern: callers holding a
278    /// string like `"Sheet2!B3"` parse it themselves and resolve the sheet
279    /// prefix against `sheet_name` before calling in.
280    pub fn set_cell_style(
281        &mut self,
282        sheet_name: Option<&str>,
283        row: usize,
284        col: usize,
285        style: crate::core::CellStyle,
286    ) -> crate::Result<()> {
287        let sheet_idx = self.find_sheet_index(sheet_name)?;
288        self.sheets[sheet_idx].update_cell_style(row, col, |s| s.merge(&style));
289        Ok(())
290    }
291
292    /// Merges `style` into every cell of an inclusive 0-based range.
293    pub fn set_range_style(
294        &mut self,
295        sheet_name: Option<&str>,
296        start_row: usize,
297        start_col: usize,
298        end_row: usize,
299        end_col: usize,
300        style: crate::core::CellStyle,
301    ) -> crate::Result<()> {
302        if end_row < start_row || end_col < start_col {
303            return Err(Error::InvalidRange(
304                "range end must not precede its start".to_string(),
305            ));
306        }
307        let sheet_idx = self.find_sheet_index(sheet_name)?;
308        for r in start_row..=end_row {
309            for c in start_col..=end_col {
310                self.sheets[sheet_idx].update_cell_style(r, c, |s| s.merge(&style));
311            }
312        }
313        Ok(())
314    }
315
316    /// The style applied to one 0-based cell, if it has one.
317    pub fn get_cell_style(
318        &self,
319        sheet_name: Option<&str>,
320        row: usize,
321        col: usize,
322    ) -> crate::Result<Option<crate::core::CellStyle>> {
323        let sheet_idx = self.find_sheet_index(sheet_name)?;
324        Ok(self.sheets[sheet_idx].get_cell_style(row, col).cloned())
325    }
326
327    /// Sets an Excel Table's visual style, looking the table up by name
328    /// across every sheet.
329    ///
330    /// # Errors
331    ///
332    /// [`Error::NotFound`] if no table in the workbook has that name.
333    pub fn set_table_style(&mut self, table_name: &str, style_name: &str) -> crate::Result<()> {
334        for sheet in &mut self.sheets {
335            for table in &mut sheet.tables {
336                if table.name.eq_ignore_ascii_case(table_name) {
337                    table.set_style_name(Some(style_name.to_string()));
338                    return Ok(());
339                }
340            }
341        }
342        Err(Error::not_found(ObjectKind::Table, table_name.to_string()))
343    }
344
345    /// An Excel Table's visual style, or `None` if it has none set.
346    ///
347    /// # Errors
348    ///
349    /// [`Error::NotFound`] if no table in the workbook has that name.
350    pub fn get_table_style(&self, table_name: &str) -> crate::Result<Option<String>> {
351        for sheet in &self.sheets {
352            for table in &sheet.tables {
353                if table.name.eq_ignore_ascii_case(table_name) {
354                    return Ok(table.style_name.clone());
355                }
356            }
357        }
358        Err(Error::not_found(ObjectKind::Table, table_name.to_string()))
359    }
360
361    /// Update cell source / value at (row, col)
362    pub fn set_cell(&mut self, sheet_idx: usize, row: usize, col: usize, value: String) {
363        self.ensure_capacity(sheet_idx, row, col);
364        let sheet = &mut self.sheets[sheet_idx];
365        sheet.set_cell_src(row, col, value);
366    }
367
368    /// Insert row at 0-based index
369    pub fn insert_row(&mut self, sheet_idx: usize, row_idx: usize) -> crate::Result<()> {
370        let sheet = &mut self.sheets[sheet_idx];
371        sheet.insert_row(row_idx);
372        Ok(())
373    }
374
375    /// Delete row at 0-based index
376    pub fn delete_row(&mut self, sheet_idx: usize, row_idx: usize) -> crate::Result<()> {
377        let sheet = &mut self.sheets[sheet_idx];
378        if row_idx >= sheet.row_count() {
379            return Err(Error::OutOfBounds {
380                what: "row",
381                index: row_idx,
382                len: sheet.row_count(),
383            });
384        }
385        sheet.delete_row(row_idx);
386        Ok(())
387    }
388
389    /// Insert column at 0-based index
390    pub fn insert_col(&mut self, sheet_idx: usize, col_idx: usize) -> crate::Result<()> {
391        let sheet = &mut self.sheets[sheet_idx];
392        sheet.insert_col(col_idx);
393        Ok(())
394    }
395
396    /// Delete column at 0-based index
397    pub fn delete_col(&mut self, sheet_idx: usize, col_idx: usize) -> crate::Result<()> {
398        let sheet = &mut self.sheets[sheet_idx];
399        if col_idx >= sheet.col_count() {
400            return Err(Error::OutOfBounds {
401                what: "column",
402                index: col_idx,
403                len: sheet.col_count(),
404            });
405        }
406        sheet.delete_col(col_idx);
407        Ok(())
408    }
409
410    /// Add new sheet with specified name
411    pub fn add_sheet(&mut self, name: &str) -> crate::Result<()> {
412        if self
413            .sheets
414            .iter()
415            .any(|s| s.name.eq_ignore_ascii_case(name))
416        {
417            return Err(Error::AlreadyExists {
418                kind: ObjectKind::Sheet,
419                name: name.to_string(),
420            });
421        }
422
423        let mut columns = Vec::new();
424        for col_idx in 0..5 {
425            let mut col = DataColumn::new(10);
426            col.id = generate_unique_id();
427            col.name = col_idx_to_letters(col_idx);
428            columns.push(col);
429        }
430
431        let new_sheet = Sheet {
432            id: generate_unique_id(),
433            name: name.to_string(),
434            columns,
435            tables: Vec::new(),
436            dependencies: std::collections::HashMap::new(),
437            dependencies_rev: std::collections::HashMap::new(),
438            uncommitted_actions: Vec::new(),
439        };
440
441        self.sheets.push(new_sheet);
442        Ok(())
443    }
444
445    /// Delete sheet by name
446    pub fn delete_sheet(&mut self, name: &str) -> crate::Result<()> {
447        let idx = self.find_sheet_index(Some(name))?;
448        if self.sheets.len() <= 1 {
449            return Err(Error::LastSheetInWorkbook);
450        }
451        self.sheets.remove(idx);
452        Ok(())
453    }
454
455    /// Rename sheet
456    pub fn rename_sheet(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
457        let idx = self.find_sheet_index(Some(old_name))?;
458        if self
459            .sheets
460            .iter()
461            .enumerate()
462            .any(|(i, s)| i != idx && s.name.eq_ignore_ascii_case(new_name))
463        {
464            return Err(Error::NameTaken {
465                kind: ObjectKind::Sheet,
466                name: new_name.to_string(),
467            });
468        }
469        self.sheets[idx].name = new_name.to_string();
470        Ok(())
471    }
472
473    /// Add chart to workbook
474    #[allow(clippy::too_many_arguments)]
475    pub fn add_chart(
476        &mut self,
477        sheet_name: &str,
478        chart_type: ChartType,
479        range: String,
480        title: Option<String>,
481        anchor: Option<(usize, usize)>,
482    ) -> crate::Result<u64> {
483        let _ = self.find_sheet_index(Some(sheet_name))?;
484        let id = generate_unique_id();
485        let name = format!("Chart {}", self.charts.len() + 1);
486        let (anchor_row, anchor_col) = anchor.unwrap_or((0, 0));
487
488        let chart = Chart {
489            id,
490            name,
491            chart_type,
492            data_range: range,
493            title,
494            xlabel: None,
495            ylabel: None,
496            show_legend: true,
497            anchor_row,
498            anchor_col,
499        };
500
501        self.charts.push(chart);
502        Ok(id)
503    }
504
505    /// Edit an existing chart's properties. Every parameter is optional;
506    /// `None` leaves that field unchanged. `title`/`xlabel`/`ylabel` are
507    /// tri-state (`Option<Option<String>>`): outer `None` leaves the field
508    /// unchanged, `Some(None)` clears it, `Some(Some(text))` sets it.
509    #[allow(clippy::too_many_arguments)]
510    pub fn edit_chart(
511        &mut self,
512        id: u64,
513        name: Option<String>,
514        chart_type: Option<ChartType>,
515        data_range: Option<String>,
516        title: Option<Option<String>>,
517        xlabel: Option<Option<String>>,
518        ylabel: Option<Option<String>>,
519        show_legend: Option<bool>,
520        anchor: Option<(usize, usize)>,
521    ) -> crate::Result<()> {
522        let chart = self
523            .charts
524            .iter_mut()
525            .find(|c| c.id == id)
526            .ok_or_else(|| Error::not_found(ObjectKind::Chart, id.to_string()))?;
527        if let Some(name) = name {
528            chart.name = name;
529        }
530        if let Some(chart_type) = chart_type {
531            chart.chart_type = chart_type;
532        }
533        if let Some(data_range) = data_range {
534            chart.data_range = data_range;
535        }
536        if let Some(title) = title {
537            chart.title = title;
538        }
539        if let Some(xlabel) = xlabel {
540            chart.xlabel = xlabel;
541        }
542        if let Some(ylabel) = ylabel {
543            chart.ylabel = ylabel;
544        }
545        if let Some(show_legend) = show_legend {
546            chart.show_legend = show_legend;
547        }
548        if let Some((anchor_row, anchor_col)) = anchor {
549            chart.anchor_row = anchor_row;
550            chart.anchor_col = anchor_col;
551        }
552        Ok(())
553    }
554
555    /// Whether the workbook carries a VBA project.
556    pub fn has_vba_project(&self) -> bool {
557        self.vba_project.is_some()
558    }
559
560    /// Lists every module in the workbook's VBA project, if it has one.
561    pub fn list_vba_modules(&self) -> Vec<&VbaModule> {
562        self.vba_project
563            .as_ref()
564            .map(|p| p.modules.iter().collect())
565            .unwrap_or_default()
566    }
567
568    /// Creates an empty, entirely synthetic VBA project (see
569    /// `VbaProject::new_empty`) if this workbook doesn't already have one.
570    /// Idempotent.
571    pub fn ensure_vba_project(&mut self) -> crate::Result<()> {
572        if self.vba_project.is_some() {
573            return Ok(());
574        }
575        self.vba_project = Some(VbaProject::new_empty());
576        Ok(())
577    }
578
579    /// Adds a new module to the workbook's VBA project (creating the
580    /// project from the bundled template first, if needed). `bound_sheet_id`
581    /// is required for `VbaModuleKind::Document` (except when `name` is
582    /// `"ThisWorkbook"`, which -- like real Excel's own always-present
583    /// ThisWorkbook module -- isn't tied to a specific sheet; any
584    /// `bound_sheet_id` passed alongside it is ignored rather than stored)
585    /// -- note this does NOT rename the sheet, or vice versa; Excel allows a
586    /// document module's own name and its sheet's display name to diverge,
587    /// and this codebase deliberately doesn't cascade one into the other.
588    pub fn add_vba_module(
589        &mut self,
590        name: String,
591        kind: VbaModuleKind,
592        source: String,
593        bound_sheet_id: Option<u64>,
594    ) -> crate::Result<()> {
595        validate_vba_module_name(&name).map_err(|reason| Error::InvalidName {
596            kind: ObjectKind::VbaModule,
597            name: name.clone(),
598            reason,
599        })?;
600        let is_this_workbook = kind == VbaModuleKind::Document && name == "ThisWorkbook";
601        if kind == VbaModuleKind::Document && !is_this_workbook {
602            let sheet_id = bound_sheet_id
603                .ok_or_else(|| Error::Vba("document modules require a bound sheet".to_string()))?;
604            if !self.sheets.iter().any(|s| s.id == sheet_id) {
605                return Err(Error::not_found(ObjectKind::Sheet, sheet_id.to_string()));
606            }
607        }
608        self.ensure_vba_project()?;
609        let project = self.vba_project.as_mut().unwrap();
610        if project.module_name_taken(&name) {
611            return Err(Error::AlreadyExists {
612                kind: ObjectKind::VbaModule,
613                name: name.to_string(),
614            });
615        }
616        if kind == VbaModuleKind::Document
617            && bound_sheet_id.is_some()
618            && project
619                .modules
620                .iter()
621                .any(|m| m.kind == VbaModuleKind::Document && m.bound_sheet_id == bound_sheet_id)
622        {
623            return Err(Error::DocumentModuleExists);
624        }
625        // Donate p-code prefix bytes and a module cookie from any existing
626        // module in this same project -- proven (via a scratchpad
627        // proof-of-concept against real Excel) that the prefix's content
628        // doesn't need to correspond to the module it precedes, only its
629        // shape matters. If this project has no modules yet (the common
630        // case for one freshly created by `ensure_vba_project`), fall back
631        // to the synthetic seed values instead.
632        let prefix_bytes = project
633            .modules
634            .first()
635            .map(|m| m.prefix_bytes.clone())
636            .unwrap_or_else(|| project.seed_prefix_bytes.clone());
637        let module_cookie = project
638            .modules
639            .first()
640            .map(|m| m.module_cookie)
641            .unwrap_or(project.seed_module_cookie);
642        let stored_bound_sheet_id = if kind == VbaModuleKind::Document && !is_this_workbook {
643            bound_sheet_id
644        } else {
645            None
646        };
647        project.modules.push(VbaModule {
648            name,
649            kind,
650            source,
651            bound_sheet_id: stored_bound_sheet_id,
652            prefix_bytes,
653            module_cookie,
654            // A brand-new module has no already-compressed form to reuse.
655            cached_compressed_source: None,
656        });
657        Ok(())
658    }
659
660    /// Removes a VBA module by name, matched case-insensitively.
661    ///
662    /// # Errors
663    ///
664    /// [`Error::Vba`] if the workbook has no VBA project, or
665    /// [`Error::NotFound`] if it has no module by that name.
666    pub fn remove_vba_module(&mut self, name: &str) -> crate::Result<()> {
667        let project = self
668            .vba_project
669            .as_mut()
670            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
671        let before = project.modules.len();
672        project
673            .modules
674            .retain(|m| !m.name.eq_ignore_ascii_case(name));
675        if project.modules.len() == before {
676            return Err(Error::not_found(ObjectKind::VbaModule, name.to_string()));
677        }
678        Ok(())
679    }
680
681    /// Renames a VBA module.
682    ///
683    /// Renames only the module; VBA source that calls into it is not
684    /// rewritten, so a module referenced by name elsewhere will no longer
685    /// resolve.
686    ///
687    /// # Errors
688    ///
689    /// [`Error::InvalidName`] if `new_name` is not a valid VBA identifier,
690    /// [`Error::AlreadyExists`] if another module already has it,
691    /// [`Error::Vba`] if the workbook has no VBA project, or
692    /// [`Error::NotFound`] if it has no module called `old_name`.
693    pub fn rename_vba_module(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
694        validate_vba_module_name(new_name).map_err(|reason| Error::InvalidName {
695            kind: ObjectKind::VbaModule,
696            name: new_name.to_string(),
697            reason,
698        })?;
699        let project = self
700            .vba_project
701            .as_mut()
702            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
703        if !old_name.eq_ignore_ascii_case(new_name) && project.module_name_taken(new_name) {
704            return Err(Error::AlreadyExists {
705                kind: ObjectKind::VbaModule,
706                name: new_name.to_string(),
707            });
708        }
709        let module = project
710            .find_module_mut(old_name)
711            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, old_name))?;
712        module.name = new_name.to_string();
713        Ok(())
714    }
715
716    /// Replaces a VBA module's source text.
717    ///
718    /// The caller supplies the whole module body, including its
719    /// `Attribute VB_Name = "..."` line, matching how real Excel-authored
720    /// module streams are shaped.
721    ///
722    /// # Errors
723    ///
724    /// [`Error::Vba`] if the workbook has no VBA project, or
725    /// [`Error::NotFound`] if it has no module by that name.
726    pub fn set_vba_module_source(&mut self, name: &str, source: String) -> crate::Result<()> {
727        let project = self
728            .vba_project
729            .as_mut()
730            .ok_or_else(|| Error::Vba("workbook has no VBA project".to_string()))?;
731        let module = project
732            .find_module_mut(name)
733            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, name))?;
734        module.source = source;
735        // Invalidate the cached compressed form -- see
736        // VbaModule::cached_compressed_source -- since it no longer matches
737        // the new `source`.
738        module.cached_compressed_source = None;
739        Ok(())
740    }
741
742    /// Delete chart by u64 ID
743    pub fn delete_chart(&mut self, id: u64) -> crate::Result<()> {
744        if let Some(pos) = self.charts.iter().position(|c| c.id == id) {
745            self.charts.remove(pos);
746            Ok(())
747        } else {
748            Err(Error::not_found(ObjectKind::Chart, id.to_string()))
749        }
750    }
751
752    /// Find the sheet that owns the table with the given name, and the
753    /// table itself. Table names are unique across the whole workbook.
754    pub fn find_table(&self, name: &str) -> Option<(&Sheet, &ExcelTable)> {
755        self.sheets
756            .iter()
757            .find_map(|s| s.find_table(name).map(|t| (s, t)))
758    }
759
760    /// List every table in the workbook, alongside the name of the sheet it
761    /// lives on.
762    pub fn list_tables(&self) -> Vec<(&str, &ExcelTable)> {
763        self.sheets
764            .iter()
765            .flat_map(|s| s.tables.iter().map(move |t| (s.name.as_str(), t)))
766            .collect()
767    }
768
769    fn find_table_sheet_index(&self, name: &str) -> crate::Result<usize> {
770        self.sheets
771            .iter()
772            .position(|s| s.find_table(name).is_some())
773            .ok_or_else(|| Error::not_found(ObjectKind::Table, name))
774    }
775
776    fn table_name_taken(&self, name: &str) -> bool {
777        self.sheets
778            .iter()
779            .any(|s| s.tables.iter().any(|t| t.name.eq_ignore_ascii_case(name)))
780    }
781
782    /// Define a new Excel Table over an existing cell range on a sheet.
783    /// Table names are unique across the entire workbook (not just the
784    /// sheet), matching how Excel itself scopes structured-reference names.
785    #[allow(clippy::too_many_arguments)]
786    pub fn add_table(
787        &mut self,
788        sheet_name: Option<&str>,
789        name: &str,
790        start_row: usize,
791        start_col: usize,
792        end_row: usize,
793        end_col: usize,
794        has_header_row: bool,
795        has_totals_row: bool,
796    ) -> crate::Result<u64> {
797        if self.table_name_taken(name) {
798            return Err(Error::AlreadyExists {
799                kind: ObjectKind::Table,
800                name: name.to_string(),
801            });
802        }
803        let idx = self.find_sheet_index(sheet_name)?;
804        self.sheets[idx]
805            .add_table(
806                name.to_string(),
807                start_row,
808                start_col,
809                end_row,
810                end_col,
811                has_header_row,
812                has_totals_row,
813            )
814            .map_err(Error::InvalidArgument)
815    }
816
817    /// Delete a table by name (leaves the underlying cell contents alone).
818    pub fn delete_table(&mut self, name: &str) -> crate::Result<()> {
819        let idx = self.find_table_sheet_index(name)?;
820        self.sheets[idx]
821            .delete_table_by_name(name)
822            .map_err(Error::InvalidArgument)
823    }
824
825    /// Rename a table.
826    pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
827        if !old_name.eq_ignore_ascii_case(new_name) && self.table_name_taken(new_name) {
828            return Err(Error::NameTaken {
829                kind: ObjectKind::Table,
830                name: new_name.to_string(),
831            });
832        }
833        let idx = self.find_table_sheet_index(old_name)?;
834        self.sheets[idx]
835            .rename_table(old_name, new_name)
836            .map_err(Error::InvalidArgument)?;
837        // Excel updates every structured reference to a renamed table, so a
838        // formula like `=SUM(Sales[Amount])` keeps working after "Sales" is
839        // renamed; match that instead of silently breaking those formulas.
840        self.rewrite_table_references(old_name, Some(new_name), None);
841        self.evaluate()
842    }
843
844    /// Rewrites every formula in the workbook that structurally references
845    /// `table_name` (optionally renaming the table and/or one column),
846    /// mirroring how Excel keeps structured references in sync when a Table
847    /// or one of its column headers is renamed.
848    fn rewrite_table_references(
849        &mut self,
850        table_name: &str,
851        new_table_name: Option<&str>,
852        col_rename: Option<(&str, &str)>,
853    ) {
854        for sheet in &mut self.sheets {
855            for col_idx in 0..sheet.columns.len() {
856                let row_count = sheet.columns[col_idx].src.len();
857                for row_idx in 0..row_count {
858                    let src = sheet.columns[col_idx].src[row_idx].clone();
859                    if let Some(new_src) = crate::core::parser::rewrite_structured_table_reference(
860                        &src,
861                        table_name,
862                        new_table_name,
863                        col_rename,
864                    ) {
865                        sheet.set_cell_src(row_idx, col_idx, new_src);
866                    }
867                }
868            }
869        }
870    }
871
872    /// Resize a table by moving its bottom-right corner.
873    pub fn resize_table(
874        &mut self,
875        name: &str,
876        new_end_row: usize,
877        new_end_col: usize,
878    ) -> crate::Result<()> {
879        let idx = self.find_table_sheet_index(name)?;
880        self.sheets[idx]
881            .resize_table(name, new_end_row, new_end_col)
882            .map_err(Error::InvalidArgument)
883    }
884
885    /// Rename one column (0-based, relative to the table) of a table.
886    pub fn rename_table_column(
887        &mut self,
888        table_name: &str,
889        col_index: usize,
890        new_name: &str,
891    ) -> crate::Result<()> {
892        let idx = self.find_table_sheet_index(table_name)?;
893        let old_col_name = self.sheets[idx]
894            .find_table(table_name)
895            .and_then(|t| t.columns.get(col_index).cloned())
896            .ok_or_else(|| {
897                Error::InvalidArgument(format!(
898                    "column index {col_index} out of bounds for table '{table_name}'"
899                ))
900            })?;
901        self.sheets[idx]
902            .rename_table_column(table_name, col_index, new_name)
903            .map_err(Error::InvalidArgument)?;
904        // As with rename_table, keep dependent formulas working across the
905        // rename instead of leaving them referencing the old column name.
906        self.rewrite_table_references(table_name, None, Some((&old_col_name, new_name)));
907        self.evaluate()
908    }
909
910    /// Find a pivot table by name (case-insensitive).
911    pub fn find_pivot_table(&self, name: &str) -> Option<&PivotTable> {
912        self.pivot_tables
913            .iter()
914            .find(|p| p.name.eq_ignore_ascii_case(name))
915    }
916
917    fn find_pivot_table_index(&self, name: &str) -> crate::Result<usize> {
918        self.pivot_tables
919            .iter()
920            .position(|p| p.name.eq_ignore_ascii_case(name))
921            .ok_or_else(|| Error::not_found(ObjectKind::PivotTable, name))
922    }
923
924    /// List every pivot table in the workbook.
925    pub fn list_pivot_tables(&self) -> &[PivotTable] {
926        &self.pivot_tables
927    }
928
929    fn pivot_table_name_taken(&self, name: &str) -> bool {
930        self.pivot_tables
931            .iter()
932            .any(|p| p.name.eq_ignore_ascii_case(name))
933    }
934
935    /// Defines a new pivot table sourced from an existing Excel Table, with
936    /// no fields assigned yet -- mirroring Excel inserting an empty
937    /// PivotTable shell that fills in as fields are added to it.
938    #[allow(clippy::too_many_arguments)]
939    pub fn add_pivot_table_from_table(
940        &mut self,
941        name: &str,
942        source_table_name: &str,
943        dest_sheet_name: Option<&str>,
944        dest_row: usize,
945        dest_col: usize,
946        grand_totals_row: bool,
947        grand_totals_col: bool,
948    ) -> crate::Result<u64> {
949        if self.pivot_table_name_taken(name) {
950            return Err(Error::AlreadyExists {
951                kind: ObjectKind::PivotTable,
952                name: name.to_string(),
953            });
954        }
955        self.find_table(source_table_name)
956            .ok_or_else(|| Error::not_found(ObjectKind::Table, source_table_name))?;
957        let dest_idx = self.find_sheet_index(dest_sheet_name)?;
958        let id = generate_unique_id();
959        self.pivot_tables.push(PivotTable {
960            id,
961            name: name.to_string(),
962            source: PivotSource::Table {
963                name: source_table_name.to_string(),
964            },
965            dest_sheet_id: self.sheets[dest_idx].id,
966            dest_row,
967            dest_col,
968            row_fields: Vec::new(),
969            col_fields: Vec::new(),
970            value_fields: Vec::new(),
971            filter_fields: Vec::new(),
972            grand_totals_row,
973            grand_totals_col,
974            last_output_end_row: None,
975            last_output_end_col: None,
976        });
977        self.refresh_pivot_table(name)?;
978        Ok(id)
979    }
980
981    /// Defines a new pivot table sourced from a plain cell range (its first
982    /// row is treated as column headers), with no fields assigned yet.
983    #[allow(clippy::too_many_arguments)]
984    pub fn add_pivot_table_from_range(
985        &mut self,
986        name: &str,
987        source_sheet_name: Option<&str>,
988        start_row: usize,
989        start_col: usize,
990        end_row: usize,
991        end_col: usize,
992        dest_sheet_name: Option<&str>,
993        dest_row: usize,
994        dest_col: usize,
995        grand_totals_row: bool,
996        grand_totals_col: bool,
997    ) -> crate::Result<u64> {
998        if self.pivot_table_name_taken(name) {
999            return Err(Error::AlreadyExists {
1000                kind: ObjectKind::PivotTable,
1001                name: name.to_string(),
1002            });
1003        }
1004        let src_idx = self.find_sheet_index(source_sheet_name)?;
1005        let dest_idx = self.find_sheet_index(dest_sheet_name)?;
1006        let id = generate_unique_id();
1007        self.pivot_tables.push(PivotTable {
1008            id,
1009            name: name.to_string(),
1010            source: PivotSource::Range {
1011                sheet_id: self.sheets[src_idx].id,
1012                start_row,
1013                start_col,
1014                end_row,
1015                end_col,
1016            },
1017            dest_sheet_id: self.sheets[dest_idx].id,
1018            dest_row,
1019            dest_col,
1020            row_fields: Vec::new(),
1021            col_fields: Vec::new(),
1022            value_fields: Vec::new(),
1023            filter_fields: Vec::new(),
1024            grand_totals_row,
1025            grand_totals_col,
1026            last_output_end_row: None,
1027            last_output_end_col: None,
1028        });
1029        self.refresh_pivot_table(name)?;
1030        Ok(id)
1031    }
1032
1033    /// Deletes a pivot table definition and clears its last rendered output
1034    /// range (leaves the source data untouched).
1035    pub fn delete_pivot_table(&mut self, name: &str) -> crate::Result<()> {
1036        let idx = self.find_pivot_table_index(name)?;
1037        let pivot = self.pivot_tables.remove(idx);
1038        if let (Some(end_row), Some(end_col)) =
1039            (pivot.last_output_end_row, pivot.last_output_end_col)
1040            && let Some(sheet_idx) = self.sheets.iter().position(|s| s.id == pivot.dest_sheet_id)
1041        {
1042            self.clear_range(sheet_idx, pivot.dest_row, pivot.dest_col, end_row, end_col);
1043        }
1044        Ok(())
1045    }
1046
1047    /// Renames a pivot table (names are unique workbook-wide, like tables).
1048    pub fn rename_pivot_table(&mut self, old_name: &str, new_name: &str) -> crate::Result<()> {
1049        if !old_name.eq_ignore_ascii_case(new_name) && self.pivot_table_name_taken(new_name) {
1050            return Err(Error::NameTaken {
1051                kind: ObjectKind::PivotTable,
1052                name: new_name.to_string(),
1053            });
1054        }
1055        let idx = self.find_pivot_table_index(old_name)?;
1056        self.pivot_tables[idx].name = new_name.to_string();
1057        Ok(())
1058    }
1059
1060    /// Adds a field to one of a pivot table's four areas (Row/Column/
1061    /// Value/Filter) and immediately refreshes its output, mirroring
1062    /// Excel's live-updating field list.
1063    ///
1064    /// A field can only occupy one area at a time, exactly like dragging a
1065    /// field to a new area in Excel's field list moves it rather than
1066    /// duplicating it (confirmed via the win32com driver: setting
1067    /// `PivotField.Orientation` a second time relocates the field). Value
1068    /// fields are the one exception -- Excel allows the same source column
1069    /// to appear as multiple value fields simultaneously (e.g. both "Sum of
1070    /// Amount" and "Min of Amount"), so adding to `PivotArea::Value` does
1071    /// not evict the column from Row/Column/Filter, and vice versa a
1072    /// Row/Column/Filter add does not evict existing value fields.
1073    pub fn add_pivot_field(
1074        &mut self,
1075        pivot_name: &str,
1076        area: PivotArea,
1077        column: &str,
1078        aggregation: Option<PivotAggregation>,
1079    ) -> crate::Result<()> {
1080        let idx = self.find_pivot_table_index(pivot_name)?;
1081        if !matches!(area, PivotArea::Value) {
1082            let pivot = &mut self.pivot_tables[idx];
1083            remove_pivot_field(&mut pivot.row_fields, column);
1084            remove_pivot_field(&mut pivot.col_fields, column);
1085            pivot
1086                .filter_fields
1087                .retain(|f| !f.column.eq_ignore_ascii_case(column));
1088        }
1089        match area {
1090            PivotArea::Row => self.pivot_tables[idx]
1091                .row_fields
1092                .push(PivotField::new(column)),
1093            PivotArea::Column => self.pivot_tables[idx]
1094                .col_fields
1095                .push(PivotField::new(column)),
1096            PivotArea::Value => {
1097                let agg = aggregation.unwrap_or(PivotAggregation::Sum);
1098                self.pivot_tables[idx]
1099                    .value_fields
1100                    .push(PivotValueField::new(column, agg));
1101            }
1102            PivotArea::Filter => self.pivot_tables[idx]
1103                .filter_fields
1104                .push(PivotFilterField::new(column)),
1105        }
1106        self.refresh_pivot_table(pivot_name)
1107    }
1108
1109    /// Removes a field from one of a pivot table's four areas and
1110    /// refreshes its output.
1111    pub fn remove_pivot_field(
1112        &mut self,
1113        pivot_name: &str,
1114        area: PivotArea,
1115        column: &str,
1116    ) -> crate::Result<()> {
1117        let idx = self.find_pivot_table_index(pivot_name)?;
1118        let removed = match area {
1119            PivotArea::Row => remove_pivot_field(&mut self.pivot_tables[idx].row_fields, column),
1120            PivotArea::Column => remove_pivot_field(&mut self.pivot_tables[idx].col_fields, column),
1121            PivotArea::Value => {
1122                let before = self.pivot_tables[idx].value_fields.len();
1123                self.pivot_tables[idx]
1124                    .value_fields
1125                    .retain(|f| !f.column.eq_ignore_ascii_case(column));
1126                before != self.pivot_tables[idx].value_fields.len()
1127            }
1128            PivotArea::Filter => {
1129                let before = self.pivot_tables[idx].filter_fields.len();
1130                self.pivot_tables[idx]
1131                    .filter_fields
1132                    .retain(|f| !f.column.eq_ignore_ascii_case(column));
1133                before != self.pivot_tables[idx].filter_fields.len()
1134            }
1135        };
1136        if !removed {
1137            return Err(Error::not_found(
1138                ObjectKind::PivotField,
1139                format!("{column}' in pivot table '{pivot_name}"),
1140            ));
1141        }
1142        self.refresh_pivot_table(pivot_name)
1143    }
1144
1145    /// Restricts (or clears, with `values: None`) a filter field's allowed
1146    /// values and refreshes the pivot table's output.
1147    pub fn set_pivot_filter(
1148        &mut self,
1149        pivot_name: &str,
1150        column: &str,
1151        values: Option<Vec<String>>,
1152    ) -> crate::Result<()> {
1153        let idx = self.find_pivot_table_index(pivot_name)?;
1154        let field = self.pivot_tables[idx]
1155            .filter_fields
1156            .iter_mut()
1157            .find(|f| f.column.eq_ignore_ascii_case(column))
1158            .ok_or_else(|| {
1159                Error::not_found(
1160                    ObjectKind::PivotField,
1161                    format!("{column}' on pivot table '{pivot_name}"),
1162                )
1163            })?;
1164        field.selected_values = values;
1165        self.refresh_pivot_table(pivot_name)
1166    }
1167
1168    /// Recomputes a pivot table's aggregation and re-materializes it as
1169    /// plain values onto its destination sheet. Like Excel, a pivot table
1170    /// only updates on an explicit refresh, never automatically as its
1171    /// source data changes.
1172    pub fn refresh_pivot_table(&mut self, pivot_name: &str) -> crate::Result<()> {
1173        let idx = self.find_pivot_table_index(pivot_name)?;
1174        let pivot = self.pivot_tables[idx].clone();
1175        let dest_idx = self
1176            .sheets
1177            .iter()
1178            .position(|s| s.id == pivot.dest_sheet_id)
1179            .ok_or_else(|| {
1180                Error::InvalidArgument(
1181                    "pivot table's destination sheet no longer exists".to_string(),
1182                )
1183            })?;
1184
1185        let grid: Option<PivotGrid> = if pivot.value_fields.is_empty() {
1186            None
1187        } else {
1188            let sheet_refs: Vec<&Sheet> = self.sheets.iter().collect();
1189            Some(compute_pivot(&sheet_refs, &pivot).map_err(Error::InvalidArgument)?)
1190        };
1191
1192        // Clear the previously rendered area first, since a refresh can
1193        // shrink the grid (fewer groups, a narrower filter, etc).
1194        if let (Some(old_end_row), Some(old_end_col)) =
1195            (pivot.last_output_end_row, pivot.last_output_end_col)
1196        {
1197            self.clear_range(
1198                dest_idx,
1199                pivot.dest_row,
1200                pivot.dest_col,
1201                old_end_row,
1202                old_end_col,
1203            );
1204        }
1205
1206        let new_bounds = grid.as_ref().map(|grid| {
1207            let height = grid.height();
1208            let width = grid.width.max(1);
1209            self.ensure_capacity(
1210                dest_idx,
1211                pivot.dest_row + height.saturating_sub(1),
1212                pivot.dest_col + width.saturating_sub(1),
1213            );
1214
1215            let mut r = pivot.dest_row;
1216            for (name, state) in &grid.filter_rows {
1217                self.set_cell(dest_idx, r, pivot.dest_col, pivot_label_literal(name));
1218                self.set_cell(dest_idx, r, pivot.dest_col + 1, pivot_label_literal(state));
1219                r += 1;
1220            }
1221            if !grid.filter_rows.is_empty() {
1222                r += 1; // blank spacer row before the grid, matching Excel
1223            }
1224            for header in &grid.header_rows {
1225                for (c, text) in header.iter().enumerate() {
1226                    self.set_cell(dest_idx, r, pivot.dest_col + c, pivot_label_literal(text));
1227                }
1228                r += 1;
1229            }
1230            for body in &grid.body_rows {
1231                for (c, label) in body.row_labels.iter().enumerate() {
1232                    self.set_cell(dest_idx, r, pivot.dest_col + c, pivot_label_literal(label));
1233                }
1234                for (c, val) in body.values.iter().enumerate() {
1235                    self.set_cell(
1236                        dest_idx,
1237                        r,
1238                        pivot.dest_col + body.row_labels.len() + c,
1239                        pivot_value_literal(val),
1240                    );
1241                }
1242                r += 1;
1243            }
1244            (
1245                pivot.dest_row + height.saturating_sub(1),
1246                pivot.dest_col + width.saturating_sub(1),
1247            )
1248        });
1249
1250        self.pivot_tables[idx].last_output_end_row = new_bounds.map(|(r, _)| r);
1251        self.pivot_tables[idx].last_output_end_col = new_bounds.map(|(_, c)| c);
1252        self.evaluate()
1253    }
1254
1255    /// Blanks every cell in the given rectangular range (inclusive),
1256    /// clipped to the sheet's current bounds. Used to wipe a pivot table's
1257    /// previous output before re-rendering a possibly smaller grid.
1258    fn clear_range(
1259        &mut self,
1260        sheet_idx: usize,
1261        start_row: usize,
1262        start_col: usize,
1263        end_row: usize,
1264        end_col: usize,
1265    ) {
1266        if sheet_idx >= self.sheets.len() {
1267            return;
1268        }
1269        let (row_count, col_count) = {
1270            let s = &self.sheets[sheet_idx];
1271            (s.row_count(), s.col_count())
1272        };
1273        if row_count == 0 || col_count == 0 {
1274            return;
1275        }
1276        for r in start_row..=end_row.min(row_count - 1) {
1277            for c in start_col..=end_col.min(col_count - 1) {
1278                self.sheets[sheet_idx].set_cell_src(r, c, String::new());
1279            }
1280        }
1281    }
1282}