Skip to main content

visi_core/core/
table.rs

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