Skip to main content

visi_core/core/
table.rs

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