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