Skip to main content

sheetkit_core/
table.rs

1//! Table and auto-filter management.
2//!
3//! Provides functions for creating, listing, and deleting OOXML table parts on
4//! worksheets, as well as the existing auto-filter helpers.
5
6use crate::error::{Error, Result};
7use sheetkit_xml::table::{
8    TableAutoFilter, TableColumnXml, TableColumnsXml, TableStyleInfoXml, TableXml,
9};
10use sheetkit_xml::worksheet::{AutoFilter, WorksheetXml};
11
12/// Configuration for creating a table.
13#[derive(Debug, Clone)]
14pub struct TableConfig {
15    /// The table name (used internally, must be unique within the workbook).
16    pub name: String,
17    /// The display name shown in the UI.
18    pub display_name: String,
19    /// The cell range (e.g. "A1:D10").
20    pub range: String,
21    /// Column definitions.
22    pub columns: Vec<TableColumn>,
23    /// Whether to show the header row. Defaults to true.
24    pub show_header_row: bool,
25    /// The table style name (e.g. "TableStyleMedium2").
26    pub style_name: Option<String>,
27    /// Whether to enable auto-filter on the table.
28    pub auto_filter: bool,
29    /// Whether to show first column formatting.
30    pub show_first_column: bool,
31    /// Whether to show last column formatting.
32    pub show_last_column: bool,
33    /// Whether to show row stripes.
34    pub show_row_stripes: bool,
35    /// Whether to show column stripes.
36    pub show_column_stripes: bool,
37}
38
39impl Default for TableConfig {
40    fn default() -> Self {
41        Self {
42            name: String::new(),
43            display_name: String::new(),
44            range: String::new(),
45            columns: Vec::new(),
46            show_header_row: true,
47            style_name: None,
48            auto_filter: true,
49            show_first_column: false,
50            show_last_column: false,
51            show_row_stripes: true,
52            show_column_stripes: false,
53        }
54    }
55}
56
57/// A column within a table.
58#[derive(Debug, Clone)]
59pub struct TableColumn {
60    /// The column header name.
61    pub name: String,
62    /// Optional totals row function (e.g., "sum", "count", "average").
63    pub totals_row_function: Option<String>,
64    /// Optional totals row label (used for the first column in totals row).
65    pub totals_row_label: Option<String>,
66}
67
68/// Metadata about an existing table, returned by list/get operations.
69#[derive(Debug, Clone, PartialEq)]
70pub struct TableInfo {
71    /// The table name.
72    pub name: String,
73    /// The display name.
74    pub display_name: String,
75    /// The cell range (e.g. "A1:D10").
76    pub range: String,
77    /// Whether the table has a header row.
78    pub show_header_row: bool,
79    /// Whether auto-filter is enabled.
80    pub auto_filter: bool,
81    /// Column names.
82    pub columns: Vec<String>,
83    /// The style name, if any.
84    pub style_name: Option<String>,
85}
86
87/// Build a `TableXml` from a `TableConfig` and a unique table ID.
88pub(crate) fn build_table_xml(config: &TableConfig, table_id: u32) -> TableXml {
89    let columns: Vec<TableColumnXml> = config
90        .columns
91        .iter()
92        .enumerate()
93        .map(|(i, col)| TableColumnXml {
94            id: (i + 1) as u32,
95            name: col.name.clone(),
96            totals_row_function: col.totals_row_function.clone(),
97            totals_row_label: col.totals_row_label.clone(),
98        })
99        .collect();
100
101    let auto_filter = if config.auto_filter {
102        Some(TableAutoFilter {
103            reference: config.range.clone(),
104        })
105    } else {
106        None
107    };
108
109    let style_info = if config.style_name.is_some()
110        || config.show_first_column
111        || config.show_last_column
112        || config.show_row_stripes
113        || config.show_column_stripes
114    {
115        Some(TableStyleInfoXml {
116            name: config.style_name.clone(),
117            show_first_column: Some(config.show_first_column),
118            show_last_column: Some(config.show_last_column),
119            show_row_stripes: Some(config.show_row_stripes),
120            show_column_stripes: Some(config.show_column_stripes),
121        })
122    } else {
123        None
124    };
125
126    let header_row_count = if !config.show_header_row {
127        Some(0)
128    } else {
129        None
130    };
131
132    TableXml {
133        xmlns: sheetkit_xml::namespaces::SPREADSHEET_ML.to_string(),
134        id: table_id,
135        name: config.name.clone(),
136        display_name: config.display_name.clone(),
137        reference: config.range.clone(),
138        totals_row_count: None,
139        totals_row_shown: None,
140        header_row_count,
141        auto_filter,
142        table_columns: TableColumnsXml {
143            count: columns.len() as u32,
144            columns,
145        },
146        table_style_info: style_info,
147    }
148}
149
150/// Convert a `TableXml` into a `TableInfo` for external consumption.
151pub(crate) fn table_xml_to_info(table_xml: &TableXml) -> TableInfo {
152    let columns = table_xml
153        .table_columns
154        .columns
155        .iter()
156        .map(|c| c.name.clone())
157        .collect();
158    let show_header_row = table_xml.header_row_count != Some(0);
159    let auto_filter = table_xml.auto_filter.is_some();
160    let style_name = table_xml
161        .table_style_info
162        .as_ref()
163        .and_then(|s| s.name.clone());
164
165    TableInfo {
166        name: table_xml.name.clone(),
167        display_name: table_xml.display_name.clone(),
168        range: table_xml.reference.clone(),
169        show_header_row,
170        auto_filter,
171        columns,
172        style_name,
173    }
174}
175
176/// Validate that a table name is non-empty and the range is non-empty.
177pub(crate) fn validate_table_config(config: &TableConfig) -> Result<()> {
178    if config.name.is_empty() {
179        return Err(Error::InvalidArgument("table name cannot be empty".into()));
180    }
181    if config.range.is_empty() {
182        return Err(Error::InvalidArgument("table range cannot be empty".into()));
183    }
184    if config.columns.is_empty() {
185        return Err(Error::InvalidArgument(
186            "table must have at least one column".into(),
187        ));
188    }
189    Ok(())
190}
191
192/// Set an auto-filter on a worksheet for the given cell range.
193pub fn set_auto_filter(ws: &mut WorksheetXml, range: &str) -> Result<()> {
194    ws.auto_filter = Some(AutoFilter {
195        reference: range.to_string(),
196    });
197    Ok(())
198}
199
200/// Remove any auto-filter from a worksheet.
201pub fn remove_auto_filter(ws: &mut WorksheetXml) {
202    ws.auto_filter = None;
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_set_auto_filter() {
211        let mut ws = WorksheetXml::default();
212        set_auto_filter(&mut ws, "A1:D10").unwrap();
213
214        assert!(ws.auto_filter.is_some());
215        assert_eq!(ws.auto_filter.as_ref().unwrap().reference, "A1:D10");
216    }
217
218    #[test]
219    fn test_remove_auto_filter() {
220        let mut ws = WorksheetXml::default();
221        set_auto_filter(&mut ws, "A1:D10").unwrap();
222        remove_auto_filter(&mut ws);
223
224        assert!(ws.auto_filter.is_none());
225    }
226
227    #[test]
228    fn test_auto_filter_xml_roundtrip() {
229        let mut ws = WorksheetXml::default();
230        set_auto_filter(&mut ws, "A1:C100").unwrap();
231
232        let xml = quick_xml::se::to_string(&ws).unwrap();
233        assert!(xml.contains("autoFilter"));
234        assert!(xml.contains("A1:C100"));
235
236        let parsed: WorksheetXml = quick_xml::de::from_str(&xml).unwrap();
237        assert!(parsed.auto_filter.is_some());
238        assert_eq!(parsed.auto_filter.as_ref().unwrap().reference, "A1:C100");
239    }
240
241    #[test]
242    fn test_remove_auto_filter_when_none() {
243        let mut ws = WorksheetXml::default();
244        remove_auto_filter(&mut ws);
245        assert!(ws.auto_filter.is_none());
246    }
247
248    #[test]
249    fn test_overwrite_auto_filter() {
250        let mut ws = WorksheetXml::default();
251        set_auto_filter(&mut ws, "A1:B10").unwrap();
252        set_auto_filter(&mut ws, "A1:D20").unwrap();
253
254        assert_eq!(ws.auto_filter.as_ref().unwrap().reference, "A1:D20");
255    }
256
257    #[test]
258    fn test_table_config_creation() {
259        let config = TableConfig {
260            name: "Table1".to_string(),
261            display_name: "Table1".to_string(),
262            range: "A1:D10".to_string(),
263            columns: vec![
264                TableColumn {
265                    name: "Name".to_string(),
266                    totals_row_function: None,
267                    totals_row_label: None,
268                },
269                TableColumn {
270                    name: "Age".to_string(),
271                    totals_row_function: None,
272                    totals_row_label: None,
273                },
274                TableColumn {
275                    name: "City".to_string(),
276                    totals_row_function: None,
277                    totals_row_label: None,
278                },
279                TableColumn {
280                    name: "Score".to_string(),
281                    totals_row_function: None,
282                    totals_row_label: None,
283                },
284            ],
285            show_header_row: true,
286            style_name: Some("TableStyleMedium2".to_string()),
287            auto_filter: true,
288            ..TableConfig::default()
289        };
290
291        assert_eq!(config.name, "Table1");
292        assert_eq!(config.columns.len(), 4);
293        assert!(config.auto_filter);
294    }
295
296    #[test]
297    fn test_build_table_xml() {
298        let config = TableConfig {
299            name: "Sales".to_string(),
300            display_name: "Sales".to_string(),
301            range: "A1:C5".to_string(),
302            columns: vec![
303                TableColumn {
304                    name: "Product".to_string(),
305                    totals_row_function: None,
306                    totals_row_label: None,
307                },
308                TableColumn {
309                    name: "Quantity".to_string(),
310                    totals_row_function: None,
311                    totals_row_label: None,
312                },
313                TableColumn {
314                    name: "Price".to_string(),
315                    totals_row_function: None,
316                    totals_row_label: None,
317                },
318            ],
319            show_header_row: true,
320            style_name: Some("TableStyleMedium2".to_string()),
321            auto_filter: true,
322            show_row_stripes: true,
323            ..TableConfig::default()
324        };
325
326        let table_xml = build_table_xml(&config, 1);
327        assert_eq!(table_xml.id, 1);
328        assert_eq!(table_xml.name, "Sales");
329        assert_eq!(table_xml.reference, "A1:C5");
330        assert_eq!(table_xml.table_columns.count, 3);
331        assert!(table_xml.auto_filter.is_some());
332        assert!(table_xml.table_style_info.is_some());
333        assert!(table_xml.header_row_count.is_none());
334    }
335
336    #[test]
337    fn test_build_table_xml_no_header() {
338        let config = TableConfig {
339            name: "T1".to_string(),
340            display_name: "T1".to_string(),
341            range: "A1:B5".to_string(),
342            columns: vec![TableColumn {
343                name: "Col1".to_string(),
344                totals_row_function: None,
345                totals_row_label: None,
346            }],
347            show_header_row: false,
348            ..TableConfig::default()
349        };
350
351        let table_xml = build_table_xml(&config, 2);
352        assert_eq!(table_xml.header_row_count, Some(0));
353    }
354
355    #[test]
356    fn test_table_xml_to_info() {
357        let table_xml = TableXml {
358            xmlns: sheetkit_xml::namespaces::SPREADSHEET_ML.to_string(),
359            id: 1,
360            name: "Inventory".to_string(),
361            display_name: "Inventory".to_string(),
362            reference: "A1:D20".to_string(),
363            totals_row_count: None,
364            totals_row_shown: None,
365            header_row_count: None,
366            auto_filter: Some(TableAutoFilter {
367                reference: "A1:D20".to_string(),
368            }),
369            table_columns: TableColumnsXml {
370                count: 4,
371                columns: vec![
372                    TableColumnXml {
373                        id: 1,
374                        name: "Item".to_string(),
375                        totals_row_function: None,
376                        totals_row_label: None,
377                    },
378                    TableColumnXml {
379                        id: 2,
380                        name: "Stock".to_string(),
381                        totals_row_function: None,
382                        totals_row_label: None,
383                    },
384                    TableColumnXml {
385                        id: 3,
386                        name: "Price".to_string(),
387                        totals_row_function: None,
388                        totals_row_label: None,
389                    },
390                    TableColumnXml {
391                        id: 4,
392                        name: "Supplier".to_string(),
393                        totals_row_function: None,
394                        totals_row_label: None,
395                    },
396                ],
397            },
398            table_style_info: Some(TableStyleInfoXml {
399                name: Some("TableStyleLight1".to_string()),
400                show_first_column: Some(false),
401                show_last_column: Some(false),
402                show_row_stripes: Some(true),
403                show_column_stripes: Some(false),
404            }),
405        };
406
407        let info = table_xml_to_info(&table_xml);
408        assert_eq!(info.name, "Inventory");
409        assert_eq!(info.display_name, "Inventory");
410        assert_eq!(info.range, "A1:D20");
411        assert!(info.show_header_row);
412        assert!(info.auto_filter);
413        assert_eq!(info.columns, vec!["Item", "Stock", "Price", "Supplier"]);
414        assert_eq!(info.style_name, Some("TableStyleLight1".to_string()));
415    }
416
417    #[test]
418    fn test_validate_table_config_empty_name() {
419        let config = TableConfig {
420            name: String::new(),
421            range: "A1:B5".to_string(),
422            columns: vec![TableColumn {
423                name: "Col".to_string(),
424                totals_row_function: None,
425                totals_row_label: None,
426            }],
427            ..TableConfig::default()
428        };
429        assert!(validate_table_config(&config).is_err());
430    }
431
432    #[test]
433    fn test_validate_table_config_empty_range() {
434        let config = TableConfig {
435            name: "T1".to_string(),
436            range: String::new(),
437            columns: vec![TableColumn {
438                name: "Col".to_string(),
439                totals_row_function: None,
440                totals_row_label: None,
441            }],
442            ..TableConfig::default()
443        };
444        assert!(validate_table_config(&config).is_err());
445    }
446
447    #[test]
448    fn test_validate_table_config_no_columns() {
449        let config = TableConfig {
450            name: "T1".to_string(),
451            range: "A1:B5".to_string(),
452            columns: vec![],
453            ..TableConfig::default()
454        };
455        assert!(validate_table_config(&config).is_err());
456    }
457
458    #[test]
459    fn test_validate_table_config_valid() {
460        let config = TableConfig {
461            name: "T1".to_string(),
462            display_name: "T1".to_string(),
463            range: "A1:B5".to_string(),
464            columns: vec![TableColumn {
465                name: "Col".to_string(),
466                totals_row_function: None,
467                totals_row_label: None,
468            }],
469            ..TableConfig::default()
470        };
471        assert!(validate_table_config(&config).is_ok());
472    }
473
474    #[test]
475    fn test_table_config_default() {
476        let config = TableConfig::default();
477        assert!(config.show_header_row);
478        assert!(config.auto_filter);
479        assert!(config.show_row_stripes);
480        assert!(!config.show_first_column);
481        assert!(!config.show_last_column);
482        assert!(!config.show_column_stripes);
483    }
484}