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    fn table_column_header(&self, header_row: usize, col_idx: usize, local_idx: usize) -> String {
189        let computed = self
190            .columns
191            .get(col_idx)
192            .and_then(|c| c.data.get(header_row))
193            .map(|d| d.to_string())
194            .filter(|s| !s.is_empty());
195        computed
196            .or_else(|| {
197                self.columns
198                    .get(col_idx)
199                    .and_then(|c| c.src.get(header_row))
200                    .map(|s| s.trim().to_string())
201                    .filter(|s| !s.is_empty())
202            })
203            .unwrap_or_else(|| format!("Column{}", local_idx + 1))
204    }
205
206    /// Defines a new Excel Table over the rectangular range
207    /// `start_row..=end_row` x `start_col..=end_col` (0-based, inclusive)
208    /// on this sheet. Column names are read from the header row's existing
209    /// cell text when `has_header_row` is true, falling back to "ColumnN"
210    /// for blank cells; otherwise every column gets a default "ColumnN"
211    /// name.
212    #[allow(clippy::too_many_arguments)]
213    pub fn add_table(
214        &mut self,
215        name: String,
216        start_row: usize,
217        start_col: usize,
218        end_row: usize,
219        end_col: usize,
220        has_header_row: bool,
221        has_totals_row: bool,
222    ) -> Result<u64, String> {
223        validate_table_name(&name)?;
224        if self.find_table(&name).is_some() {
225            return Err(format!(
226                "Table '{}' already exists on sheet '{}'",
227                name, self.name
228            ));
229        }
230        if end_row < start_row || end_col < start_col {
231            return Err("Table range end must not precede its start".to_string());
232        }
233        let (row_count, col_count) = (self.row_count(), self.col_count());
234        if end_row >= row_count || end_col >= col_count {
235            return Err(format!(
236                "Table range exceeds sheet bounds ({} rows x {} cols)",
237                row_count, col_count
238            ));
239        }
240        if let Some(existing) = self
241            .tables
242            .iter()
243            .find(|t| t.overlaps(start_row, start_col, end_row, end_col))
244        {
245            return Err(format!(
246                "Table range overlaps existing table '{}' on sheet '{}'",
247                existing.name, self.name
248            ));
249        }
250
251        let columns: Vec<String> = (start_col..=end_col)
252            .enumerate()
253            .map(|(local_idx, col_idx)| {
254                if has_header_row {
255                    self.table_column_header(start_row, col_idx, local_idx)
256                } else {
257                    format!("Column{}", local_idx + 1)
258                }
259            })
260            .collect();
261        check_duplicate_column_names(&columns)?;
262
263        let id = generate_unique_id();
264        self.tables.push(ExcelTable {
265            id,
266            name,
267            sheet_id: self.id,
268            start_row,
269            start_col,
270            end_row,
271            end_col,
272            has_header_row,
273            has_totals_row,
274            columns,
275            style_name: None,
276            has_insert_row: false,
277        });
278        Ok(id)
279    }
280
281    /// Removes a table definition from this sheet, leaving the cells it
282    /// covered untouched.
283    ///
284    /// # Errors
285    ///
286    /// Returns a message if no table on this sheet has that name.
287    pub fn delete_table_by_name(&mut self, name: &str) -> Result<(), String> {
288        if let Some(pos) = self
289            .tables
290            .iter()
291            .position(|t| t.name.eq_ignore_ascii_case(name))
292        {
293            self.tables.remove(pos);
294            Ok(())
295        } else {
296            Err(format!(
297                "Table '{}' not found on sheet '{}'",
298                name, self.name
299            ))
300        }
301    }
302
303    /// Renames a table on this sheet.
304    ///
305    /// Renaming here does *not* rewrite the formulas that reference the table
306    /// -- that cascade is `WorkbookManager::rename_table`'s job, and it is
307    /// what keeps `Sales[Amount]` pointing at the renamed table. Prefer that
308    /// entry point unless you are rewriting the references yourself.
309    ///
310    /// # Errors
311    ///
312    /// Returns a message if the new name is not a valid table name, if
313    /// another table on this sheet already has it, or if no table on this
314    /// sheet has `old_name`.
315    pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> Result<(), String> {
316        validate_table_name(new_name)?;
317        if self.tables.iter().any(|t| {
318            !t.name.eq_ignore_ascii_case(old_name) && t.name.eq_ignore_ascii_case(new_name)
319        }) {
320            return Err(format!("Table name '{}' is already taken", new_name));
321        }
322        let sheet_name = self.name.clone();
323        let table = self
324            .find_table_mut(old_name)
325            .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", old_name, sheet_name))?;
326        table.name = new_name.to_string();
327        Ok(())
328    }
329
330    /// Extends or shrinks a table's range by moving its bottom-right corner
331    /// to `new_end_row`/`new_end_col` (the top-left corner never moves).
332    /// Column names for any newly-included columns are read from the
333    /// header row (or default to "ColumnN"); names for columns that
334    /// already existed are preserved by position.
335    pub fn resize_table(
336        &mut self,
337        name: &str,
338        new_end_row: usize,
339        new_end_col: usize,
340    ) -> Result<(), String> {
341        let (id, start_row, start_col, has_header_row, old_columns) = {
342            let table = self
343                .find_table(name)
344                .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", name, self.name))?;
345            (
346                table.id,
347                table.start_row,
348                table.start_col,
349                table.has_header_row,
350                table.columns.clone(),
351            )
352        };
353
354        if new_end_row < start_row || new_end_col < start_col {
355            return Err("Table range end must not precede its start".to_string());
356        }
357        let (row_count, col_count) = (self.row_count(), self.col_count());
358        if new_end_row >= row_count || new_end_col >= col_count {
359            return Err(format!(
360                "Table range exceeds sheet bounds ({} rows x {} cols)",
361                row_count, col_count
362            ));
363        }
364        if let Some(existing) = self
365            .tables
366            .iter()
367            .find(|t| t.id != id && t.overlaps(start_row, start_col, new_end_row, new_end_col))
368        {
369            return Err(format!(
370                "Resized range would overlap existing table '{}' on sheet '{}'",
371                existing.name, self.name
372            ));
373        }
374
375        let new_columns: Vec<String> = (start_col..=new_end_col)
376            .enumerate()
377            .map(|(local_idx, col_idx)| {
378                old_columns.get(local_idx).cloned().unwrap_or_else(|| {
379                    if has_header_row {
380                        self.table_column_header(start_row, col_idx, local_idx)
381                    } else {
382                        format!("Column{}", local_idx + 1)
383                    }
384                })
385            })
386            .collect();
387        check_duplicate_column_names(&new_columns)?;
388
389        let table = self.find_table_mut(name).unwrap();
390        table.end_row = new_end_row;
391        table.end_col = new_end_col;
392        table.columns = new_columns;
393        Ok(())
394    }
395
396    /// Renames one column (0-based, relative to the table) of a table,
397    /// updating both its stored name and the header row's cell text (if
398    /// the table has one).
399    pub fn rename_table_column(
400        &mut self,
401        table_name: &str,
402        col_index: usize,
403        new_name: &str,
404    ) -> Result<(), String> {
405        let trimmed = new_name.trim();
406        if trimmed.is_empty() {
407            return Err("Column name cannot be empty".to_string());
408        }
409
410        let (sheet_col, header_row, has_header_row) = {
411            let table = self.find_table(table_name).ok_or_else(|| {
412                format!("Table '{}' not found on sheet '{}'", table_name, self.name)
413            })?;
414            if col_index >= table.columns.len() {
415                return Err(format!(
416                    "Column index {} out of bounds (table '{}' has {} columns)",
417                    col_index,
418                    table_name,
419                    table.columns.len()
420                ));
421            }
422            if table
423                .columns
424                .iter()
425                .enumerate()
426                .any(|(i, c)| i != col_index && c.eq_ignore_ascii_case(trimmed))
427            {
428                return Err(format!(
429                    "Table '{}' already has a column named '{}'",
430                    table_name, trimmed
431                ));
432            }
433            (
434                table.start_col + col_index,
435                table.start_row,
436                table.has_header_row,
437            )
438        };
439
440        if let Some(table) = self.find_table_mut(table_name) {
441            table.columns[col_index] = trimmed.to_string();
442        }
443        if has_header_row {
444            self.set_cell_src(header_row, sheet_col, trimmed.to_string());
445        }
446        Ok(())
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::core::engine::SheetInit;
454
455    fn sheet_with_data() -> Sheet {
456        let mut sheet = Sheet::new(SheetInit {
457            name: Some("Sheet1".to_string()),
458            rows: 6,
459            cols: 3,
460            ..Default::default()
461        });
462        let header = ["Name", "Amount", "Qty"];
463        let data = [
464            ["Widget", "10", "2"],
465            ["Gadget", "20", "3"],
466            ["Gizmo", "30", "4"],
467            ["Doohickey", "40", "5"],
468        ];
469        for (c, h) in header.iter().enumerate() {
470            sheet.set_cell_src(0, c, h.to_string());
471        }
472        for (r, row) in data.iter().enumerate() {
473            for (c, v) in row.iter().enumerate() {
474                sheet.set_cell_src(r + 1, c, v.to_string());
475            }
476        }
477        sheet.set_cell_src(5, 1, "=SUM(B2:B5)".to_string());
478        sheet.commit(None).unwrap();
479        sheet
480    }
481
482    #[test]
483    fn test_add_table_reads_headers_and_bounds() {
484        let mut sheet = sheet_with_data();
485        let id = sheet
486            .add_table("Sales".to_string(), 0, 0, 5, 2, true, true)
487            .unwrap();
488        let table = sheet.find_table("Sales").unwrap();
489        assert_eq!(table.id, id);
490        assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
491        assert_eq!(table.data_start_row(), 1);
492        assert_eq!(table.data_end_row(), 4);
493        assert_eq!(table.header_row(), Some(0));
494        assert_eq!(table.totals_row(), Some(5));
495    }
496
497    #[test]
498    fn test_add_table_no_header_row_uses_default_names() {
499        let mut sheet = sheet_with_data();
500        sheet
501            .add_table("Raw".to_string(), 1, 0, 4, 2, false, false)
502            .unwrap();
503        let table = sheet.find_table("Raw").unwrap();
504        assert_eq!(table.columns, vec!["Column1", "Column2", "Column3"]);
505        assert_eq!(table.data_start_row(), 1);
506        assert_eq!(table.data_end_row(), 4);
507        assert_eq!(table.totals_row(), None);
508    }
509
510    #[test]
511    fn test_add_table_rejects_duplicate_name() {
512        let mut sheet = sheet_with_data();
513        sheet
514            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
515            .unwrap();
516        let err = sheet
517            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
518            .unwrap_err();
519        assert!(err.contains("already exists"));
520    }
521
522    #[test]
523    fn test_add_table_rejects_invalid_name() {
524        let mut sheet = sheet_with_data();
525        let err = sheet
526            .add_table("1Sales".to_string(), 0, 0, 4, 2, true, false)
527            .unwrap_err();
528        assert!(err.contains("must start with"));
529
530        let err2 = sheet
531            .add_table("Sales Report".to_string(), 0, 0, 4, 2, true, false)
532            .unwrap_err();
533        assert!(err2.contains("letters, digits"));
534    }
535
536    #[test]
537    fn test_add_table_rejects_out_of_bounds_range() {
538        let mut sheet = sheet_with_data();
539        let err = sheet
540            .add_table("Sales".to_string(), 0, 0, 10, 2, true, false)
541            .unwrap_err();
542        assert!(err.contains("exceeds sheet bounds"));
543    }
544
545    #[test]
546    fn test_add_table_rejects_overlap() {
547        let mut sheet = sheet_with_data();
548        sheet
549            .add_table("Sales".to_string(), 0, 0, 4, 1, true, false)
550            .unwrap();
551        let err = sheet
552            .add_table("Other".to_string(), 0, 1, 4, 2, true, false)
553            .unwrap_err();
554        assert!(err.contains("overlaps"));
555    }
556
557    #[test]
558    fn test_delete_and_rename_table() {
559        let mut sheet = sheet_with_data();
560        sheet
561            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
562            .unwrap();
563
564        sheet.rename_table("Sales", "Revenue").unwrap();
565        assert!(sheet.find_table("Sales").is_none());
566        assert!(sheet.find_table("Revenue").is_some());
567
568        sheet.delete_table_by_name("Revenue").unwrap();
569        assert!(sheet.find_table("Revenue").is_none());
570
571        let err = sheet.delete_table_by_name("Revenue").unwrap_err();
572        assert!(err.contains("not found"));
573    }
574
575    #[test]
576    fn test_resize_table_grows_and_shrinks() {
577        let mut sheet = sheet_with_data();
578        sheet
579            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
580            .unwrap();
581        assert_eq!(sheet.find_table("Sales").unwrap().columns.len(), 2);
582
583        sheet.resize_table("Sales", 4, 2).unwrap();
584        let table = sheet.find_table("Sales").unwrap();
585        assert_eq!(table.end_row, 4);
586        assert_eq!(table.end_col, 2);
587        assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
588
589        sheet.resize_table("Sales", 3, 0).unwrap();
590        let table = sheet.find_table("Sales").unwrap();
591        assert_eq!(table.end_row, 3);
592        assert_eq!(table.end_col, 0);
593        assert_eq!(table.columns, vec!["Name"]);
594    }
595
596    #[test]
597    fn test_rename_table_column_updates_header_cell() {
598        let mut sheet = sheet_with_data();
599        sheet
600            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
601            .unwrap();
602
603        sheet.rename_table_column("Sales", 1, "Total").unwrap();
604        assert_eq!(
605            sheet.find_table("Sales").unwrap().columns,
606            vec!["Name", "Total", "Qty"]
607        );
608        assert_eq!(sheet.columns[1].src[0], "Total");
609    }
610
611    #[test]
612    fn test_rename_table_column_rejects_duplicate() {
613        let mut sheet = sheet_with_data();
614        sheet
615            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
616            .unwrap();
617        let err = sheet.rename_table_column("Sales", 1, "Name").unwrap_err();
618        assert!(err.contains("already has a column"));
619    }
620
621    #[test]
622    fn an_insert_row_placeholder_means_zero_data_rows() {
623        let mut table = ExcelTable {
624            id: 1,
625            name: "Hollow".to_string(),
626            sheet_id: 1,
627            start_row: 0,
628            start_col: 0,
629            end_row: 1,
630            end_col: 2,
631            has_header_row: true,
632            has_totals_row: false,
633            columns: vec!["Region".into(), "Product".into(), "Amount".into()],
634            style_name: None,
635            has_insert_row: false,
636        };
637        assert_eq!(table.data_row_count(), 1);
638        assert_eq!(table.data_start_row(), 1);
639        assert_eq!(table.data_end_row(), 1);
640
641        table.has_insert_row = true;
642        assert_eq!(table.data_row_count(), 0);
643        assert!(table.data_end_row() < table.data_start_row());
644    }
645
646    #[test]
647    fn data_row_count_does_not_underflow_on_a_header_only_table() {
648        let table = ExcelTable {
649            id: 1,
650            name: "T".to_string(),
651            sheet_id: 1,
652            start_row: 0,
653            start_col: 0,
654            end_row: 0,
655            end_col: 1,
656            has_header_row: true,
657            has_totals_row: false,
658            columns: vec!["A".into(), "B".into()],
659            style_name: None,
660            has_insert_row: false,
661        };
662        assert_eq!(table.data_row_count(), 0);
663    }
664}