1use serde::{Deserialize, Serialize};
2
3use crate::core::engine::{Sheet, generate_unique_id};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct ExcelTable {
16 pub id: u64,
18 pub name: String,
21 pub sheet_id: u64,
23 pub start_row: usize,
25 pub start_col: usize,
27 pub end_row: usize,
30 pub end_col: usize,
32 pub has_header_row: bool,
34 pub has_totals_row: bool,
36 pub columns: Vec<String>,
40 #[serde(default)]
42 pub style_name: Option<String>,
43 #[serde(default)]
55 pub has_insert_row: bool,
56}
57
58impl ExcelTable {
59 pub fn set_style_name(&mut self, style_name: Option<String>) {
61 self.style_name = style_name;
62 }
63
64 pub fn row_count(&self) -> usize {
66 self.end_row - self.start_row + 1
67 }
68
69 pub fn col_count(&self) -> usize {
71 self.end_col - self.start_col + 1
72 }
73
74 pub fn data_start_row(&self) -> usize {
76 self.start_row + usize::from(self.has_header_row)
77 }
78
79 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 pub fn data_row_count(&self) -> usize {
99 (self.data_end_row() + 1).saturating_sub(self.data_start_row())
100 }
101
102 pub fn header_row(&self) -> Option<usize> {
105 self.has_header_row.then_some(self.start_row)
106 }
107
108 pub fn totals_row(&self) -> Option<usize> {
111 self.has_totals_row.then_some(self.end_row)
112 }
113
114 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 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 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 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 {
192 let computed = self
197 .columns
198 .get(col_idx)
199 .and_then(|c| c.data.get(header_row))
200 .map(|d| d.to_string())
201 .filter(|s| !s.is_empty());
202 computed
203 .or_else(|| {
204 self.columns
205 .get(col_idx)
206 .and_then(|c| c.src.get(header_row))
207 .map(|s| s.trim().to_string())
208 .filter(|s| !s.is_empty())
209 })
210 .unwrap_or_else(|| format!("Column{}", local_idx + 1))
211 }
212
213 #[allow(clippy::too_many_arguments)]
220 pub fn add_table(
221 &mut self,
222 name: String,
223 start_row: usize,
224 start_col: usize,
225 end_row: usize,
226 end_col: usize,
227 has_header_row: bool,
228 has_totals_row: bool,
229 ) -> Result<u64, String> {
230 validate_table_name(&name)?;
231 if self.find_table(&name).is_some() {
232 return Err(format!(
233 "Table '{}' already exists on sheet '{}'",
234 name, self.name
235 ));
236 }
237 if end_row < start_row || end_col < start_col {
238 return Err("Table range end must not precede its start".to_string());
239 }
240 let (row_count, col_count) = (self.row_count(), self.col_count());
241 if end_row >= row_count || end_col >= col_count {
242 return Err(format!(
243 "Table range exceeds sheet bounds ({} rows x {} cols)",
244 row_count, col_count
245 ));
246 }
247 if let Some(existing) = self
248 .tables
249 .iter()
250 .find(|t| t.overlaps(start_row, start_col, end_row, end_col))
251 {
252 return Err(format!(
253 "Table range overlaps existing table '{}' on sheet '{}'",
254 existing.name, self.name
255 ));
256 }
257
258 let columns: Vec<String> = (start_col..=end_col)
259 .enumerate()
260 .map(|(local_idx, col_idx)| {
261 if has_header_row {
262 self.table_column_header(start_row, col_idx, local_idx)
263 } else {
264 format!("Column{}", local_idx + 1)
265 }
266 })
267 .collect();
268 check_duplicate_column_names(&columns)?;
269
270 let id = generate_unique_id();
271 self.tables.push(ExcelTable {
272 id,
273 name,
274 sheet_id: self.id,
275 start_row,
276 start_col,
277 end_row,
278 end_col,
279 has_header_row,
280 has_totals_row,
281 columns,
282 style_name: None,
283 has_insert_row: false,
284 });
285 Ok(id)
286 }
287
288 pub fn delete_table_by_name(&mut self, name: &str) -> Result<(), String> {
295 if let Some(pos) = self
296 .tables
297 .iter()
298 .position(|t| t.name.eq_ignore_ascii_case(name))
299 {
300 self.tables.remove(pos);
301 Ok(())
302 } else {
303 Err(format!(
304 "Table '{}' not found on sheet '{}'",
305 name, self.name
306 ))
307 }
308 }
309
310 pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> Result<(), String> {
323 validate_table_name(new_name)?;
324 if self.tables.iter().any(|t| {
325 !t.name.eq_ignore_ascii_case(old_name) && t.name.eq_ignore_ascii_case(new_name)
326 }) {
327 return Err(format!("Table name '{}' is already taken", new_name));
328 }
329 let sheet_name = self.name.clone();
330 let table = self
331 .find_table_mut(old_name)
332 .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", old_name, sheet_name))?;
333 table.name = new_name.to_string();
334 Ok(())
335 }
336
337 pub fn resize_table(
343 &mut self,
344 name: &str,
345 new_end_row: usize,
346 new_end_col: usize,
347 ) -> Result<(), String> {
348 let (id, start_row, start_col, has_header_row, old_columns) = {
349 let table = self
350 .find_table(name)
351 .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", name, self.name))?;
352 (
353 table.id,
354 table.start_row,
355 table.start_col,
356 table.has_header_row,
357 table.columns.clone(),
358 )
359 };
360
361 if new_end_row < start_row || new_end_col < start_col {
362 return Err("Table range end must not precede its start".to_string());
363 }
364 let (row_count, col_count) = (self.row_count(), self.col_count());
365 if new_end_row >= row_count || new_end_col >= col_count {
366 return Err(format!(
367 "Table range exceeds sheet bounds ({} rows x {} cols)",
368 row_count, col_count
369 ));
370 }
371 if let Some(existing) = self
372 .tables
373 .iter()
374 .find(|t| t.id != id && t.overlaps(start_row, start_col, new_end_row, new_end_col))
375 {
376 return Err(format!(
377 "Resized range would overlap existing table '{}' on sheet '{}'",
378 existing.name, self.name
379 ));
380 }
381
382 let new_columns: Vec<String> = (start_col..=new_end_col)
383 .enumerate()
384 .map(|(local_idx, col_idx)| {
385 old_columns.get(local_idx).cloned().unwrap_or_else(|| {
386 if has_header_row {
387 self.table_column_header(start_row, col_idx, local_idx)
388 } else {
389 format!("Column{}", local_idx + 1)
390 }
391 })
392 })
393 .collect();
394 check_duplicate_column_names(&new_columns)?;
395
396 let table = self.find_table_mut(name).unwrap();
397 table.end_row = new_end_row;
398 table.end_col = new_end_col;
399 table.columns = new_columns;
400 Ok(())
401 }
402
403 pub fn rename_table_column(
407 &mut self,
408 table_name: &str,
409 col_index: usize,
410 new_name: &str,
411 ) -> Result<(), String> {
412 let trimmed = new_name.trim();
413 if trimmed.is_empty() {
414 return Err("Column name cannot be empty".to_string());
415 }
416
417 let (sheet_col, header_row, has_header_row) = {
418 let table = self.find_table(table_name).ok_or_else(|| {
419 format!("Table '{}' not found on sheet '{}'", table_name, self.name)
420 })?;
421 if col_index >= table.columns.len() {
422 return Err(format!(
423 "Column index {} out of bounds (table '{}' has {} columns)",
424 col_index,
425 table_name,
426 table.columns.len()
427 ));
428 }
429 if table
430 .columns
431 .iter()
432 .enumerate()
433 .any(|(i, c)| i != col_index && c.eq_ignore_ascii_case(trimmed))
434 {
435 return Err(format!(
436 "Table '{}' already has a column named '{}'",
437 table_name, trimmed
438 ));
439 }
440 (
441 table.start_col + col_index,
442 table.start_row,
443 table.has_header_row,
444 )
445 };
446
447 if let Some(table) = self.find_table_mut(table_name) {
448 table.columns[col_index] = trimmed.to_string();
449 }
450 if has_header_row {
451 self.set_cell_src(header_row, sheet_col, trimmed.to_string());
452 }
453 Ok(())
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::core::engine::SheetInit;
461
462 fn sheet_with_data() -> Sheet {
463 let mut sheet = Sheet::new(SheetInit {
464 name: Some("Sheet1".to_string()),
465 rows: 6,
466 cols: 3,
467 ..Default::default()
468 });
469 let header = ["Name", "Amount", "Qty"];
471 let data = [
472 ["Widget", "10", "2"],
473 ["Gadget", "20", "3"],
474 ["Gizmo", "30", "4"],
475 ["Doohickey", "40", "5"],
476 ];
477 for (c, h) in header.iter().enumerate() {
478 sheet.set_cell_src(0, c, h.to_string());
479 }
480 for (r, row) in data.iter().enumerate() {
481 for (c, v) in row.iter().enumerate() {
482 sheet.set_cell_src(r + 1, c, v.to_string());
483 }
484 }
485 sheet.set_cell_src(5, 1, "=SUM(B2:B5)".to_string());
486 sheet.commit(None).unwrap();
487 sheet
488 }
489
490 #[test]
491 fn test_add_table_reads_headers_and_bounds() {
492 let mut sheet = sheet_with_data();
493 let id = sheet
494 .add_table("Sales".to_string(), 0, 0, 5, 2, true, true)
495 .unwrap();
496 let table = sheet.find_table("Sales").unwrap();
497 assert_eq!(table.id, id);
498 assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
499 assert_eq!(table.data_start_row(), 1);
500 assert_eq!(table.data_end_row(), 4);
501 assert_eq!(table.header_row(), Some(0));
502 assert_eq!(table.totals_row(), Some(5));
503 }
504
505 #[test]
506 fn test_add_table_no_header_row_uses_default_names() {
507 let mut sheet = sheet_with_data();
508 sheet
509 .add_table("Raw".to_string(), 1, 0, 4, 2, false, false)
510 .unwrap();
511 let table = sheet.find_table("Raw").unwrap();
512 assert_eq!(table.columns, vec!["Column1", "Column2", "Column3"]);
513 assert_eq!(table.data_start_row(), 1);
514 assert_eq!(table.data_end_row(), 4);
515 assert_eq!(table.totals_row(), None);
516 }
517
518 #[test]
519 fn test_add_table_rejects_duplicate_name() {
520 let mut sheet = sheet_with_data();
521 sheet
522 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
523 .unwrap();
524 let err = sheet
525 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
526 .unwrap_err();
527 assert!(err.contains("already exists"));
528 }
529
530 #[test]
531 fn test_add_table_rejects_invalid_name() {
532 let mut sheet = sheet_with_data();
533 let err = sheet
534 .add_table("1Sales".to_string(), 0, 0, 4, 2, true, false)
535 .unwrap_err();
536 assert!(err.contains("must start with"));
537
538 let err2 = sheet
539 .add_table("Sales Report".to_string(), 0, 0, 4, 2, true, false)
540 .unwrap_err();
541 assert!(err2.contains("letters, digits"));
542 }
543
544 #[test]
545 fn test_add_table_rejects_out_of_bounds_range() {
546 let mut sheet = sheet_with_data();
547 let err = sheet
548 .add_table("Sales".to_string(), 0, 0, 10, 2, true, false)
549 .unwrap_err();
550 assert!(err.contains("exceeds sheet bounds"));
551 }
552
553 #[test]
554 fn test_add_table_rejects_overlap() {
555 let mut sheet = sheet_with_data();
556 sheet
557 .add_table("Sales".to_string(), 0, 0, 4, 1, true, false)
558 .unwrap();
559 let err = sheet
560 .add_table("Other".to_string(), 0, 1, 4, 2, true, false)
561 .unwrap_err();
562 assert!(err.contains("overlaps"));
563 }
564
565 #[test]
566 fn test_delete_and_rename_table() {
567 let mut sheet = sheet_with_data();
568 sheet
569 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
570 .unwrap();
571
572 sheet.rename_table("Sales", "Revenue").unwrap();
573 assert!(sheet.find_table("Sales").is_none());
574 assert!(sheet.find_table("Revenue").is_some());
575
576 sheet.delete_table_by_name("Revenue").unwrap();
577 assert!(sheet.find_table("Revenue").is_none());
578
579 let err = sheet.delete_table_by_name("Revenue").unwrap_err();
580 assert!(err.contains("not found"));
581 }
582
583 #[test]
584 fn test_resize_table_grows_and_shrinks() {
585 let mut sheet = sheet_with_data();
586 sheet
587 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
588 .unwrap();
589 assert_eq!(sheet.find_table("Sales").unwrap().columns.len(), 2);
590
591 sheet.resize_table("Sales", 4, 2).unwrap();
593 let table = sheet.find_table("Sales").unwrap();
594 assert_eq!(table.end_row, 4);
595 assert_eq!(table.end_col, 2);
596 assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
597
598 sheet.resize_table("Sales", 3, 0).unwrap();
600 let table = sheet.find_table("Sales").unwrap();
601 assert_eq!(table.end_row, 3);
602 assert_eq!(table.end_col, 0);
603 assert_eq!(table.columns, vec!["Name"]);
604 }
605
606 #[test]
607 fn test_rename_table_column_updates_header_cell() {
608 let mut sheet = sheet_with_data();
609 sheet
610 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
611 .unwrap();
612
613 sheet.rename_table_column("Sales", 1, "Total").unwrap();
614 assert_eq!(
615 sheet.find_table("Sales").unwrap().columns,
616 vec!["Name", "Total", "Qty"]
617 );
618 assert_eq!(sheet.columns[1].src[0], "Total");
620 }
621
622 #[test]
623 fn test_rename_table_column_rejects_duplicate() {
624 let mut sheet = sheet_with_data();
625 sheet
626 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
627 .unwrap();
628 let err = sheet.rename_table_column("Sales", 1, "Name").unwrap_err();
629 assert!(err.contains("already has a column"));
630 }
631
632 #[test]
633 fn an_insert_row_placeholder_means_zero_data_rows() {
634 let mut table = ExcelTable {
641 id: 1,
642 name: "Hollow".to_string(),
643 sheet_id: 1,
644 start_row: 0,
645 start_col: 0,
646 end_row: 1,
647 end_col: 2,
648 has_header_row: true,
649 has_totals_row: false,
650 columns: vec!["Region".into(), "Product".into(), "Amount".into()],
651 style_name: None,
652 has_insert_row: false,
653 };
654 assert_eq!(table.data_row_count(), 1);
656 assert_eq!(table.data_start_row(), 1);
657 assert_eq!(table.data_end_row(), 1);
658
659 table.has_insert_row = true;
661 assert_eq!(table.data_row_count(), 0);
662 assert!(table.data_end_row() < table.data_start_row());
665 }
666
667 #[test]
668 fn data_row_count_does_not_underflow_on_a_header_only_table() {
669 let table = ExcelTable {
670 id: 1,
671 name: "T".to_string(),
672 sheet_id: 1,
673 start_row: 0,
674 start_col: 0,
675 end_row: 0,
676 end_col: 1,
677 has_header_row: true,
678 has_totals_row: false,
679 columns: vec!["A".into(), "B".into()],
680 style_name: None,
681 has_insert_row: false,
682 };
683 assert_eq!(table.data_row_count(), 0);
684 }
685}