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