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
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 #[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 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 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 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 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}