Skip to main content

visi_core/core/
table.rs

1use serde::{Deserialize, Serialize};
2
3use crate::core::engine::{Sheet, generate_unique_id};
4
5/// A named, rectangular range within a single worksheet, mirroring an Excel
6/// Table (a.k.a. `ListObject`): a header row, a body of data rows, and an
7/// optional totals row, all with stable per-column names that formulas can
8/// reference via structured references (e.g. `Sales[Amount]`).
9///
10/// This is a distinct concept from a `Sheet`: elsewhere in this codebase a
11/// `Sheet` is informally called a "table" (see `Sheet::new`'s default name
12/// `"table_1"`), but an `ExcelTable` is a sub-range that lives *on* a sheet,
13/// exactly like a real Excel Table can occupy only part of a worksheet.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct ExcelTable {
16    /// Workbook-unique identifier, stable across renames.
17    pub id: u64,
18    /// The table's name, as a structured reference spells it. Unique
19    /// workbook-wide and matched case-insensitively.
20    pub name: String,
21    /// The sheet this table occupies part of.
22    pub sheet_id: u64,
23    /// Topmost row of the range, 0-based -- the header row when there is one.
24    pub start_row: usize,
25    /// Leftmost column of the range, 0-based.
26    pub start_col: usize,
27    /// Bottommost row of the range, 0-based and inclusive -- the totals row
28    /// when there is one.
29    pub end_row: usize,
30    /// Rightmost column of the range, 0-based and inclusive.
31    pub end_col: usize,
32    /// Whether the first row is a header rather than data.
33    pub has_header_row: bool,
34    /// Whether the last row is a totals row rather than data.
35    pub has_totals_row: bool,
36    /// Column names, in sheet-column order, one per column in
37    /// `start_col..=end_col`. Kept in sync with the header row's cell text
38    /// (when `has_header_row` is true) by the CRUD methods in this file.
39    pub columns: Vec<String>,
40    /// Visual style theme name (e.g. "TableStyleMedium9", "TableStyleLight1", or custom theme)
41    #[serde(default)]
42    pub style_name: Option<String>,
43    /// Whether the last row of the range is Excel's *insert row* placeholder
44    /// rather than data -- i.e. the table has **zero data rows**.
45    ///
46    /// This cannot be inferred from the extent, which is the surprise:
47    /// deleting a one-data-row table's only row leaves `ref` at `A1:C2` and
48    /// sets `insertRow="1"` in `xl/tables/tableN.xml`, so a zero-row table
49    /// and a table with one *blank* data row have identical bounds. Excel
50    /// tells them apart by this flag and so must we -- `ListObject`'s
51    /// `.DataBodyRange` is `Nothing` and `.ListRows.Count` is 0 for the
52    /// former and a real range and 1 for the latter. Measured with
53    /// `fuzz/vba_table_probe.py --empty`.
54    #[serde(default)]
55    pub has_insert_row: bool,
56}
57
58impl ExcelTable {
59    /// Sets the table's visual style, or clears it with `None`.
60    pub fn set_style_name(&mut self, style_name: Option<String>) {
61        self.style_name = style_name;
62    }
63
64    /// Total rows in the range, header and totals rows included.
65    pub fn row_count(&self) -> usize {
66        self.end_row - self.start_row + 1
67    }
68
69    /// Columns in the range.
70    pub fn col_count(&self) -> usize {
71        self.end_col - self.start_col + 1
72    }
73
74    /// First row of the table's actual data body (excludes the header row).
75    pub fn data_start_row(&self) -> usize {
76        self.start_row + usize::from(self.has_header_row)
77    }
78
79    /// Last row of the table's actual data body, excluding the totals row
80    /// and Excel's insert-row placeholder.
81    ///
82    /// May be less than `data_start_row()` for a table with no data rows, so
83    /// callers building a range from the pair must handle the empty case
84    /// rather than assuming `start..=end` is non-empty. See
85    /// [`ExcelTable::data_row_count`].
86    pub fn data_end_row(&self) -> usize {
87        self.end_row
88            .saturating_sub(usize::from(self.has_totals_row))
89            .saturating_sub(usize::from(self.has_insert_row))
90    }
91
92    /// How many data rows the table actually has, which is 0 for a table
93    /// sitting on its insert-row placeholder.
94    ///
95    /// Use this rather than comparing `data_start_row()` with
96    /// `data_end_row()`: an empty table's end is *below* its start, so the
97    /// subtraction underflows.
98    pub fn data_row_count(&self) -> usize {
99        (self.data_end_row() + 1).saturating_sub(self.data_start_row())
100    }
101
102    /// The header row's sheet-row index, or `None` if the table has no
103    /// header.
104    pub fn header_row(&self) -> Option<usize> {
105        self.has_header_row.then_some(self.start_row)
106    }
107
108    /// The totals row's sheet-row index, or `None` if the table has no
109    /// totals row.
110    pub fn totals_row(&self) -> Option<usize> {
111        self.has_totals_row.then_some(self.end_row)
112    }
113
114    /// Index (0-based, relative to the table's own columns) of the column
115    /// with the given name, matched case-insensitively as Excel does for
116    /// structured references.
117    pub fn local_column_index(&self, name: &str) -> Option<usize> {
118        self.columns
119            .iter()
120            .position(|c| c.eq_ignore_ascii_case(name))
121    }
122
123    /// Whether this table's range overlaps the given rectangular range.
124    pub fn overlaps(
125        &self,
126        start_row: usize,
127        start_col: usize,
128        end_row: usize,
129        end_col: usize,
130    ) -> bool {
131        self.start_row <= end_row
132            && start_row <= self.end_row
133            && self.start_col <= end_col
134            && start_col <= self.end_col
135    }
136}
137
138fn validate_table_name(name: &str) -> Result<(), String> {
139    let trimmed = name.trim();
140    if trimmed.is_empty() {
141        return Err("Table name cannot be empty".to_string());
142    }
143    let first = trimmed.chars().next().unwrap();
144    if !(first.is_alphabetic() || first == '_') {
145        return Err(format!(
146            "Table name '{}' must start with a letter or underscore",
147            name
148        ));
149    }
150    if !trimmed
151        .chars()
152        .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
153    {
154        return Err(format!(
155            "Table name '{}' may only contain letters, digits, underscores, and periods",
156            name
157        ));
158    }
159    Ok(())
160}
161
162fn check_duplicate_column_names(columns: &[String]) -> Result<(), String> {
163    let mut seen = std::collections::HashSet::new();
164    for c in columns {
165        if !seen.insert(c.to_ascii_lowercase()) {
166            return Err(format!("Duplicate column name '{}' in table header row", c));
167        }
168    }
169    Ok(())
170}
171
172impl Sheet {
173    /// Finds a table on this sheet by name, matched case-insensitively as
174    /// Excel does.
175    pub fn find_table(&self, name: &str) -> Option<&ExcelTable> {
176        self.tables
177            .iter()
178            .find(|t| t.name.eq_ignore_ascii_case(name))
179    }
180
181    /// [`Sheet::find_table`], mutably.
182    pub fn find_table_mut(&mut self, name: &str) -> Option<&mut ExcelTable> {
183        self.tables
184            .iter_mut()
185            .find(|t| t.name.eq_ignore_ascii_case(name))
186    }
187
188    /// Reads the header text for sheet column `col_idx` at `header_row`, if
189    /// non-blank; otherwise falls back to a default "ColumnN" name (N is
190    /// 1-based within the table).
191    fn table_column_header(&self, header_row: usize, col_idx: usize, local_idx: usize) -> String {
192        // Prefer the cell's computed value (what a user actually sees) over
193        // its raw source text, in case a header cell happens to hold a
194        // formula rather than plain text; fall back to raw source for
195        // cells that haven't been committed/evaluated yet.
196        let computed = self
197            .columns
198            .get(col_idx)
199            .and_then(|c| c.data.get(header_row))
200            .map(|d| d.to_string())
201            .filter(|s| !s.is_empty());
202        computed
203            .or_else(|| {
204                self.columns
205                    .get(col_idx)
206                    .and_then(|c| c.src.get(header_row))
207                    .map(|s| s.trim().to_string())
208                    .filter(|s| !s.is_empty())
209            })
210            .unwrap_or_else(|| format!("Column{}", local_idx + 1))
211    }
212
213    /// Defines a new Excel Table over the rectangular range
214    /// `start_row..=end_row` x `start_col..=end_col` (0-based, inclusive)
215    /// on this sheet. Column names are read from the header row's existing
216    /// cell text when `has_header_row` is true, falling back to "ColumnN"
217    /// for blank cells; otherwise every column gets a default "ColumnN"
218    /// name.
219    #[allow(clippy::too_many_arguments)]
220    pub fn add_table(
221        &mut self,
222        name: String,
223        start_row: usize,
224        start_col: usize,
225        end_row: usize,
226        end_col: usize,
227        has_header_row: bool,
228        has_totals_row: bool,
229    ) -> Result<u64, String> {
230        validate_table_name(&name)?;
231        if self.find_table(&name).is_some() {
232            return Err(format!(
233                "Table '{}' already exists on sheet '{}'",
234                name, self.name
235            ));
236        }
237        if end_row < start_row || end_col < start_col {
238            return Err("Table range end must not precede its start".to_string());
239        }
240        let (row_count, col_count) = (self.row_count(), self.col_count());
241        if end_row >= row_count || end_col >= col_count {
242            return Err(format!(
243                "Table range exceeds sheet bounds ({} rows x {} cols)",
244                row_count, col_count
245            ));
246        }
247        if let Some(existing) = self
248            .tables
249            .iter()
250            .find(|t| t.overlaps(start_row, start_col, end_row, end_col))
251        {
252            return Err(format!(
253                "Table range overlaps existing table '{}' on sheet '{}'",
254                existing.name, self.name
255            ));
256        }
257
258        let columns: Vec<String> = (start_col..=end_col)
259            .enumerate()
260            .map(|(local_idx, col_idx)| {
261                if has_header_row {
262                    self.table_column_header(start_row, col_idx, local_idx)
263                } else {
264                    format!("Column{}", local_idx + 1)
265                }
266            })
267            .collect();
268        check_duplicate_column_names(&columns)?;
269
270        let id = generate_unique_id();
271        self.tables.push(ExcelTable {
272            id,
273            name,
274            sheet_id: self.id,
275            start_row,
276            start_col,
277            end_row,
278            end_col,
279            has_header_row,
280            has_totals_row,
281            columns,
282            style_name: None,
283            has_insert_row: false,
284        });
285        Ok(id)
286    }
287
288    /// Removes a table definition from this sheet, leaving the cells it
289    /// covered untouched.
290    ///
291    /// # Errors
292    ///
293    /// Returns a message if no table on this sheet has that name.
294    pub fn delete_table_by_name(&mut self, name: &str) -> Result<(), String> {
295        if let Some(pos) = self
296            .tables
297            .iter()
298            .position(|t| t.name.eq_ignore_ascii_case(name))
299        {
300            self.tables.remove(pos);
301            Ok(())
302        } else {
303            Err(format!(
304                "Table '{}' not found on sheet '{}'",
305                name, self.name
306            ))
307        }
308    }
309
310    /// Renames a table on this sheet.
311    ///
312    /// Renaming here does *not* rewrite the formulas that reference the table
313    /// -- that cascade is `WorkbookManager::rename_table`'s job, and it is
314    /// what keeps `Sales[Amount]` pointing at the renamed table. Prefer that
315    /// entry point unless you are rewriting the references yourself.
316    ///
317    /// # Errors
318    ///
319    /// Returns a message if the new name is not a valid table name, if
320    /// another table on this sheet already has it, or if no table on this
321    /// sheet has `old_name`.
322    pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> Result<(), String> {
323        validate_table_name(new_name)?;
324        if self.tables.iter().any(|t| {
325            !t.name.eq_ignore_ascii_case(old_name) && t.name.eq_ignore_ascii_case(new_name)
326        }) {
327            return Err(format!("Table name '{}' is already taken", new_name));
328        }
329        let sheet_name = self.name.clone();
330        let table = self
331            .find_table_mut(old_name)
332            .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", old_name, sheet_name))?;
333        table.name = new_name.to_string();
334        Ok(())
335    }
336
337    /// Extends or shrinks a table's range by moving its bottom-right corner
338    /// to `new_end_row`/`new_end_col` (the top-left corner never moves).
339    /// Column names for any newly-included columns are read from the
340    /// header row (or default to "ColumnN"); names for columns that
341    /// already existed are preserved by position.
342    pub fn resize_table(
343        &mut self,
344        name: &str,
345        new_end_row: usize,
346        new_end_col: usize,
347    ) -> Result<(), String> {
348        let (id, start_row, start_col, has_header_row, old_columns) = {
349            let table = self
350                .find_table(name)
351                .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", name, self.name))?;
352            (
353                table.id,
354                table.start_row,
355                table.start_col,
356                table.has_header_row,
357                table.columns.clone(),
358            )
359        };
360
361        if new_end_row < start_row || new_end_col < start_col {
362            return Err("Table range end must not precede its start".to_string());
363        }
364        let (row_count, col_count) = (self.row_count(), self.col_count());
365        if new_end_row >= row_count || new_end_col >= col_count {
366            return Err(format!(
367                "Table range exceeds sheet bounds ({} rows x {} cols)",
368                row_count, col_count
369            ));
370        }
371        if let Some(existing) = self
372            .tables
373            .iter()
374            .find(|t| t.id != id && t.overlaps(start_row, start_col, new_end_row, new_end_col))
375        {
376            return Err(format!(
377                "Resized range would overlap existing table '{}' on sheet '{}'",
378                existing.name, self.name
379            ));
380        }
381
382        let new_columns: Vec<String> = (start_col..=new_end_col)
383            .enumerate()
384            .map(|(local_idx, col_idx)| {
385                old_columns.get(local_idx).cloned().unwrap_or_else(|| {
386                    if has_header_row {
387                        self.table_column_header(start_row, col_idx, local_idx)
388                    } else {
389                        format!("Column{}", local_idx + 1)
390                    }
391                })
392            })
393            .collect();
394        check_duplicate_column_names(&new_columns)?;
395
396        let table = self.find_table_mut(name).unwrap();
397        table.end_row = new_end_row;
398        table.end_col = new_end_col;
399        table.columns = new_columns;
400        Ok(())
401    }
402
403    /// Renames one column (0-based, relative to the table) of a table,
404    /// updating both its stored name and the header row's cell text (if
405    /// the table has one).
406    pub fn rename_table_column(
407        &mut self,
408        table_name: &str,
409        col_index: usize,
410        new_name: &str,
411    ) -> Result<(), String> {
412        let trimmed = new_name.trim();
413        if trimmed.is_empty() {
414            return Err("Column name cannot be empty".to_string());
415        }
416
417        let (sheet_col, header_row, has_header_row) = {
418            let table = self.find_table(table_name).ok_or_else(|| {
419                format!("Table '{}' not found on sheet '{}'", table_name, self.name)
420            })?;
421            if col_index >= table.columns.len() {
422                return Err(format!(
423                    "Column index {} out of bounds (table '{}' has {} columns)",
424                    col_index,
425                    table_name,
426                    table.columns.len()
427                ));
428            }
429            if table
430                .columns
431                .iter()
432                .enumerate()
433                .any(|(i, c)| i != col_index && c.eq_ignore_ascii_case(trimmed))
434            {
435                return Err(format!(
436                    "Table '{}' already has a column named '{}'",
437                    table_name, trimmed
438                ));
439            }
440            (
441                table.start_col + col_index,
442                table.start_row,
443                table.has_header_row,
444            )
445        };
446
447        if let Some(table) = self.find_table_mut(table_name) {
448            table.columns[col_index] = trimmed.to_string();
449        }
450        if has_header_row {
451            self.set_cell_src(header_row, sheet_col, trimmed.to_string());
452        }
453        Ok(())
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::core::engine::SheetInit;
461
462    fn sheet_with_data() -> Sheet {
463        let mut sheet = Sheet::new(SheetInit {
464            name: Some("Sheet1".to_string()),
465            rows: 6,
466            cols: 3,
467            ..Default::default()
468        });
469        // Row 0: headers, rows 1-4: data, row 5: totals.
470        let header = ["Name", "Amount", "Qty"];
471        let data = [
472            ["Widget", "10", "2"],
473            ["Gadget", "20", "3"],
474            ["Gizmo", "30", "4"],
475            ["Doohickey", "40", "5"],
476        ];
477        for (c, h) in header.iter().enumerate() {
478            sheet.set_cell_src(0, c, h.to_string());
479        }
480        for (r, row) in data.iter().enumerate() {
481            for (c, v) in row.iter().enumerate() {
482                sheet.set_cell_src(r + 1, c, v.to_string());
483            }
484        }
485        sheet.set_cell_src(5, 1, "=SUM(B2:B5)".to_string());
486        sheet.commit(None).unwrap();
487        sheet
488    }
489
490    #[test]
491    fn test_add_table_reads_headers_and_bounds() {
492        let mut sheet = sheet_with_data();
493        let id = sheet
494            .add_table("Sales".to_string(), 0, 0, 5, 2, true, true)
495            .unwrap();
496        let table = sheet.find_table("Sales").unwrap();
497        assert_eq!(table.id, id);
498        assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
499        assert_eq!(table.data_start_row(), 1);
500        assert_eq!(table.data_end_row(), 4);
501        assert_eq!(table.header_row(), Some(0));
502        assert_eq!(table.totals_row(), Some(5));
503    }
504
505    #[test]
506    fn test_add_table_no_header_row_uses_default_names() {
507        let mut sheet = sheet_with_data();
508        sheet
509            .add_table("Raw".to_string(), 1, 0, 4, 2, false, false)
510            .unwrap();
511        let table = sheet.find_table("Raw").unwrap();
512        assert_eq!(table.columns, vec!["Column1", "Column2", "Column3"]);
513        assert_eq!(table.data_start_row(), 1);
514        assert_eq!(table.data_end_row(), 4);
515        assert_eq!(table.totals_row(), None);
516    }
517
518    #[test]
519    fn test_add_table_rejects_duplicate_name() {
520        let mut sheet = sheet_with_data();
521        sheet
522            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
523            .unwrap();
524        let err = sheet
525            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
526            .unwrap_err();
527        assert!(err.contains("already exists"));
528    }
529
530    #[test]
531    fn test_add_table_rejects_invalid_name() {
532        let mut sheet = sheet_with_data();
533        let err = sheet
534            .add_table("1Sales".to_string(), 0, 0, 4, 2, true, false)
535            .unwrap_err();
536        assert!(err.contains("must start with"));
537
538        let err2 = sheet
539            .add_table("Sales Report".to_string(), 0, 0, 4, 2, true, false)
540            .unwrap_err();
541        assert!(err2.contains("letters, digits"));
542    }
543
544    #[test]
545    fn test_add_table_rejects_out_of_bounds_range() {
546        let mut sheet = sheet_with_data();
547        let err = sheet
548            .add_table("Sales".to_string(), 0, 0, 10, 2, true, false)
549            .unwrap_err();
550        assert!(err.contains("exceeds sheet bounds"));
551    }
552
553    #[test]
554    fn test_add_table_rejects_overlap() {
555        let mut sheet = sheet_with_data();
556        sheet
557            .add_table("Sales".to_string(), 0, 0, 4, 1, true, false)
558            .unwrap();
559        let err = sheet
560            .add_table("Other".to_string(), 0, 1, 4, 2, true, false)
561            .unwrap_err();
562        assert!(err.contains("overlaps"));
563    }
564
565    #[test]
566    fn test_delete_and_rename_table() {
567        let mut sheet = sheet_with_data();
568        sheet
569            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
570            .unwrap();
571
572        sheet.rename_table("Sales", "Revenue").unwrap();
573        assert!(sheet.find_table("Sales").is_none());
574        assert!(sheet.find_table("Revenue").is_some());
575
576        sheet.delete_table_by_name("Revenue").unwrap();
577        assert!(sheet.find_table("Revenue").is_none());
578
579        let err = sheet.delete_table_by_name("Revenue").unwrap_err();
580        assert!(err.contains("not found"));
581    }
582
583    #[test]
584    fn test_resize_table_grows_and_shrinks() {
585        let mut sheet = sheet_with_data();
586        sheet
587            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
588            .unwrap();
589        assert_eq!(sheet.find_table("Sales").unwrap().columns.len(), 2);
590
591        // Grow to include the Qty column and one more row.
592        sheet.resize_table("Sales", 4, 2).unwrap();
593        let table = sheet.find_table("Sales").unwrap();
594        assert_eq!(table.end_row, 4);
595        assert_eq!(table.end_col, 2);
596        assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
597
598        // Shrink back down; existing column names are preserved by position.
599        sheet.resize_table("Sales", 3, 0).unwrap();
600        let table = sheet.find_table("Sales").unwrap();
601        assert_eq!(table.end_row, 3);
602        assert_eq!(table.end_col, 0);
603        assert_eq!(table.columns, vec!["Name"]);
604    }
605
606    #[test]
607    fn test_rename_table_column_updates_header_cell() {
608        let mut sheet = sheet_with_data();
609        sheet
610            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
611            .unwrap();
612
613        sheet.rename_table_column("Sales", 1, "Total").unwrap();
614        assert_eq!(
615            sheet.find_table("Sales").unwrap().columns,
616            vec!["Name", "Total", "Qty"]
617        );
618        // The header row's actual cell text is kept in sync.
619        assert_eq!(sheet.columns[1].src[0], "Total");
620    }
621
622    #[test]
623    fn test_rename_table_column_rejects_duplicate() {
624        let mut sheet = sheet_with_data();
625        sheet
626            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
627            .unwrap();
628        let err = sheet.rename_table_column("Sales", 1, "Name").unwrap_err();
629        assert!(err.contains("already has a column"));
630    }
631
632    #[test]
633    fn an_insert_row_placeholder_means_zero_data_rows() {
634        // Excel's own shape for an emptied table, measured with
635        // `fuzz/vba_table_probe.py --empty`: deleting the only data row of an
636        // `A1:C2` table leaves the extent at `A1:C2` and sets `insertRow="1"`,
637        // so the flag is the *only* thing distinguishing this from a table
638        // with one blank data row. `ListObject.DataBodyRange` is `Nothing`
639        // for the former and `$A$2:$C$2` for the latter.
640        let mut table = ExcelTable {
641            id: 1,
642            name: "Hollow".to_string(),
643            sheet_id: 1,
644            start_row: 0,
645            start_col: 0,
646            end_row: 1,
647            end_col: 2,
648            has_header_row: true,
649            has_totals_row: false,
650            columns: vec!["Region".into(), "Product".into(), "Amount".into()],
651            style_name: None,
652            has_insert_row: false,
653        };
654        // One blank data row.
655        assert_eq!(table.data_row_count(), 1);
656        assert_eq!(table.data_start_row(), 1);
657        assert_eq!(table.data_end_row(), 1);
658
659        // The same extent, sitting on its insert row: zero data rows.
660        table.has_insert_row = true;
661        assert_eq!(table.data_row_count(), 0);
662        // The end is now *below* the start, which is why `data_row_count`
663        // exists rather than callers subtracting the two.
664        assert!(table.data_end_row() < table.data_start_row());
665    }
666
667    #[test]
668    fn data_row_count_does_not_underflow_on_a_header_only_table() {
669        let table = ExcelTable {
670            id: 1,
671            name: "T".to_string(),
672            sheet_id: 1,
673            start_row: 0,
674            start_col: 0,
675            end_row: 0,
676            end_col: 1,
677            has_header_row: true,
678            has_totals_row: false,
679            columns: vec!["A".into(), "B".into()],
680            style_name: None,
681            has_insert_row: false,
682        };
683        assert_eq!(table.data_row_count(), 0);
684    }
685}