1use serde::{Deserialize, Serialize};
8
9use crate::core::engine::{Sheet, generate_unique_id};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct ExcelTable {
22 pub id: u64,
24 pub name: String,
27 pub sheet_id: u64,
29 pub start_row: usize,
31 pub start_col: usize,
33 pub end_row: usize,
36 pub end_col: usize,
38 pub has_header_row: bool,
40 pub has_totals_row: bool,
42 pub columns: Vec<String>,
46 #[serde(default)]
48 pub style_name: Option<String>,
49 #[serde(default)]
61 pub has_insert_row: bool,
62}
63
64impl ExcelTable {
65 pub fn set_style_name(&mut self, style_name: Option<String>) {
67 self.style_name = style_name;
68 }
69
70 pub fn row_count(&self) -> usize {
72 self.end_row - self.start_row + 1
73 }
74
75 pub fn col_count(&self) -> usize {
77 self.end_col - self.start_col + 1
78 }
79
80 pub fn data_start_row(&self) -> usize {
82 self.start_row + usize::from(self.has_header_row)
83 }
84
85 pub fn data_end_row(&self) -> usize {
93 self.end_row
94 .saturating_sub(usize::from(self.has_totals_row))
95 .saturating_sub(usize::from(self.has_insert_row))
96 }
97
98 pub fn data_row_count(&self) -> usize {
105 (self.data_end_row() + 1).saturating_sub(self.data_start_row())
106 }
107
108 pub fn header_row(&self) -> Option<usize> {
111 self.has_header_row.then_some(self.start_row)
112 }
113
114 pub fn totals_row(&self) -> Option<usize> {
117 self.has_totals_row.then_some(self.end_row)
118 }
119
120 pub fn local_column_index(&self, name: &str) -> Option<usize> {
124 self.columns
125 .iter()
126 .position(|c| c.eq_ignore_ascii_case(name))
127 }
128
129 pub fn overlaps(
131 &self,
132 start_row: usize,
133 start_col: usize,
134 end_row: usize,
135 end_col: usize,
136 ) -> bool {
137 self.start_row <= end_row
138 && start_row <= self.end_row
139 && self.start_col <= end_col
140 && start_col <= self.end_col
141 }
142}
143
144fn validate_table_name(name: &str) -> Result<(), String> {
145 let trimmed = name.trim();
146 if trimmed.is_empty() {
147 return Err("Table name cannot be empty".to_string());
148 }
149 let first = trimmed.chars().next().unwrap();
150 if !(first.is_alphabetic() || first == '_') {
151 return Err(format!(
152 "Table name '{}' must start with a letter or underscore",
153 name
154 ));
155 }
156 if !trimmed
157 .chars()
158 .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
159 {
160 return Err(format!(
161 "Table name '{}' may only contain letters, digits, underscores, and periods",
162 name
163 ));
164 }
165 Ok(())
166}
167
168fn check_duplicate_column_names(columns: &[String]) -> Result<(), String> {
169 let mut seen = std::collections::HashSet::new();
170 for c in columns {
171 if !seen.insert(c.to_ascii_lowercase()) {
172 return Err(format!("Duplicate column name '{}' in table header row", c));
173 }
174 }
175 Ok(())
176}
177
178impl Sheet {
179 pub fn find_table(&self, name: &str) -> Option<&ExcelTable> {
182 self.tables
183 .iter()
184 .find(|t| t.name.eq_ignore_ascii_case(name))
185 }
186
187 pub fn find_table_mut(&mut self, name: &str) -> Option<&mut ExcelTable> {
189 self.tables
190 .iter_mut()
191 .find(|t| t.name.eq_ignore_ascii_case(name))
192 }
193
194 fn table_column_header(&self, header_row: usize, col_idx: usize, local_idx: usize) -> String {
198 let computed = self
203 .columns
204 .get(col_idx)
205 .and_then(|c| c.data.get(header_row))
206 .map(|d| d.to_string())
207 .filter(|s| !s.is_empty());
208 computed
209 .or_else(|| {
210 self.columns
211 .get(col_idx)
212 .and_then(|c| c.src.get(header_row))
213 .map(|s| s.trim().to_string())
214 .filter(|s| !s.is_empty())
215 })
216 .unwrap_or_else(|| format!("Column{}", local_idx + 1))
217 }
218
219 #[allow(clippy::too_many_arguments)]
226 pub fn add_table(
227 &mut self,
228 name: String,
229 start_row: usize,
230 start_col: usize,
231 end_row: usize,
232 end_col: usize,
233 has_header_row: bool,
234 has_totals_row: bool,
235 ) -> Result<u64, String> {
236 validate_table_name(&name)?;
237 if self.find_table(&name).is_some() {
238 return Err(format!(
239 "Table '{}' already exists on sheet '{}'",
240 name, self.name
241 ));
242 }
243 if end_row < start_row || end_col < start_col {
244 return Err("Table range end must not precede its start".to_string());
245 }
246 let (row_count, col_count) = (self.row_count(), self.col_count());
247 if end_row >= row_count || end_col >= col_count {
248 return Err(format!(
249 "Table range exceeds sheet bounds ({} rows x {} cols)",
250 row_count, col_count
251 ));
252 }
253 if let Some(existing) = self
254 .tables
255 .iter()
256 .find(|t| t.overlaps(start_row, start_col, end_row, end_col))
257 {
258 return Err(format!(
259 "Table range overlaps existing table '{}' on sheet '{}'",
260 existing.name, self.name
261 ));
262 }
263
264 let columns: Vec<String> = (start_col..=end_col)
265 .enumerate()
266 .map(|(local_idx, col_idx)| {
267 if has_header_row {
268 self.table_column_header(start_row, col_idx, local_idx)
269 } else {
270 format!("Column{}", local_idx + 1)
271 }
272 })
273 .collect();
274 check_duplicate_column_names(&columns)?;
275
276 let id = generate_unique_id();
277 self.tables.push(ExcelTable {
278 id,
279 name,
280 sheet_id: self.id,
281 start_row,
282 start_col,
283 end_row,
284 end_col,
285 has_header_row,
286 has_totals_row,
287 columns,
288 style_name: None,
289 has_insert_row: false,
290 });
291 Ok(id)
292 }
293
294 pub fn delete_table_by_name(&mut self, name: &str) -> Result<(), String> {
301 if let Some(pos) = self
302 .tables
303 .iter()
304 .position(|t| t.name.eq_ignore_ascii_case(name))
305 {
306 self.tables.remove(pos);
307 Ok(())
308 } else {
309 Err(format!(
310 "Table '{}' not found on sheet '{}'",
311 name, self.name
312 ))
313 }
314 }
315
316 pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> Result<(), String> {
329 validate_table_name(new_name)?;
330 if self.tables.iter().any(|t| {
331 !t.name.eq_ignore_ascii_case(old_name) && t.name.eq_ignore_ascii_case(new_name)
332 }) {
333 return Err(format!("Table name '{}' is already taken", new_name));
334 }
335 let sheet_name = self.name.clone();
336 let table = self
337 .find_table_mut(old_name)
338 .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", old_name, sheet_name))?;
339 table.name = new_name.to_string();
340 Ok(())
341 }
342
343 pub fn resize_table(
349 &mut self,
350 name: &str,
351 new_end_row: usize,
352 new_end_col: usize,
353 ) -> Result<(), String> {
354 let (id, start_row, start_col, has_header_row, old_columns) = {
355 let table = self
356 .find_table(name)
357 .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", name, self.name))?;
358 (
359 table.id,
360 table.start_row,
361 table.start_col,
362 table.has_header_row,
363 table.columns.clone(),
364 )
365 };
366
367 if new_end_row < start_row || new_end_col < start_col {
368 return Err("Table range end must not precede its start".to_string());
369 }
370 let (row_count, col_count) = (self.row_count(), self.col_count());
371 if new_end_row >= row_count || new_end_col >= col_count {
372 return Err(format!(
373 "Table range exceeds sheet bounds ({} rows x {} cols)",
374 row_count, col_count
375 ));
376 }
377 if let Some(existing) = self
378 .tables
379 .iter()
380 .find(|t| t.id != id && t.overlaps(start_row, start_col, new_end_row, new_end_col))
381 {
382 return Err(format!(
383 "Resized range would overlap existing table '{}' on sheet '{}'",
384 existing.name, self.name
385 ));
386 }
387
388 let new_columns: Vec<String> = (start_col..=new_end_col)
389 .enumerate()
390 .map(|(local_idx, col_idx)| {
391 old_columns.get(local_idx).cloned().unwrap_or_else(|| {
392 if has_header_row {
393 self.table_column_header(start_row, col_idx, local_idx)
394 } else {
395 format!("Column{}", local_idx + 1)
396 }
397 })
398 })
399 .collect();
400 check_duplicate_column_names(&new_columns)?;
401
402 let table = self.find_table_mut(name).unwrap();
403 table.end_row = new_end_row;
404 table.end_col = new_end_col;
405 table.columns = new_columns;
406 Ok(())
407 }
408
409 pub fn rename_table_column(
413 &mut self,
414 table_name: &str,
415 col_index: usize,
416 new_name: &str,
417 ) -> Result<(), String> {
418 let trimmed = new_name.trim();
419 if trimmed.is_empty() {
420 return Err("Column name cannot be empty".to_string());
421 }
422
423 let (sheet_col, header_row, has_header_row) = {
424 let table = self.find_table(table_name).ok_or_else(|| {
425 format!("Table '{}' not found on sheet '{}'", table_name, self.name)
426 })?;
427 if col_index >= table.columns.len() {
428 return Err(format!(
429 "Column index {} out of bounds (table '{}' has {} columns)",
430 col_index,
431 table_name,
432 table.columns.len()
433 ));
434 }
435 if table
436 .columns
437 .iter()
438 .enumerate()
439 .any(|(i, c)| i != col_index && c.eq_ignore_ascii_case(trimmed))
440 {
441 return Err(format!(
442 "Table '{}' already has a column named '{}'",
443 table_name, trimmed
444 ));
445 }
446 (
447 table.start_col + col_index,
448 table.start_row,
449 table.has_header_row,
450 )
451 };
452
453 if let Some(table) = self.find_table_mut(table_name) {
454 table.columns[col_index] = trimmed.to_string();
455 }
456 if has_header_row {
457 self.set_cell_src(header_row, sheet_col, trimmed.to_string());
458 }
459 Ok(())
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::core::engine::SheetInit;
467
468 fn sheet_with_data() -> Sheet {
469 let mut sheet = Sheet::new(SheetInit {
470 name: Some("Sheet1".to_string()),
471 rows: 6,
472 cols: 3,
473 ..Default::default()
474 });
475 let header = ["Name", "Amount", "Qty"];
477 let data = [
478 ["Widget", "10", "2"],
479 ["Gadget", "20", "3"],
480 ["Gizmo", "30", "4"],
481 ["Doohickey", "40", "5"],
482 ];
483 for (c, h) in header.iter().enumerate() {
484 sheet.set_cell_src(0, c, h.to_string());
485 }
486 for (r, row) in data.iter().enumerate() {
487 for (c, v) in row.iter().enumerate() {
488 sheet.set_cell_src(r + 1, c, v.to_string());
489 }
490 }
491 sheet.set_cell_src(5, 1, "=SUM(B2:B5)".to_string());
492 sheet.commit(None).unwrap();
493 sheet
494 }
495
496 #[test]
497 fn test_add_table_reads_headers_and_bounds() {
498 let mut sheet = sheet_with_data();
499 let id = sheet
500 .add_table("Sales".to_string(), 0, 0, 5, 2, true, true)
501 .unwrap();
502 let table = sheet.find_table("Sales").unwrap();
503 assert_eq!(table.id, id);
504 assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
505 assert_eq!(table.data_start_row(), 1);
506 assert_eq!(table.data_end_row(), 4);
507 assert_eq!(table.header_row(), Some(0));
508 assert_eq!(table.totals_row(), Some(5));
509 }
510
511 #[test]
512 fn test_add_table_no_header_row_uses_default_names() {
513 let mut sheet = sheet_with_data();
514 sheet
515 .add_table("Raw".to_string(), 1, 0, 4, 2, false, false)
516 .unwrap();
517 let table = sheet.find_table("Raw").unwrap();
518 assert_eq!(table.columns, vec!["Column1", "Column2", "Column3"]);
519 assert_eq!(table.data_start_row(), 1);
520 assert_eq!(table.data_end_row(), 4);
521 assert_eq!(table.totals_row(), None);
522 }
523
524 #[test]
525 fn test_add_table_rejects_duplicate_name() {
526 let mut sheet = sheet_with_data();
527 sheet
528 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
529 .unwrap();
530 let err = sheet
531 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
532 .unwrap_err();
533 assert!(err.contains("already exists"));
534 }
535
536 #[test]
537 fn test_add_table_rejects_invalid_name() {
538 let mut sheet = sheet_with_data();
539 let err = sheet
540 .add_table("1Sales".to_string(), 0, 0, 4, 2, true, false)
541 .unwrap_err();
542 assert!(err.contains("must start with"));
543
544 let err2 = sheet
545 .add_table("Sales Report".to_string(), 0, 0, 4, 2, true, false)
546 .unwrap_err();
547 assert!(err2.contains("letters, digits"));
548 }
549
550 #[test]
551 fn test_add_table_rejects_out_of_bounds_range() {
552 let mut sheet = sheet_with_data();
553 let err = sheet
554 .add_table("Sales".to_string(), 0, 0, 10, 2, true, false)
555 .unwrap_err();
556 assert!(err.contains("exceeds sheet bounds"));
557 }
558
559 #[test]
560 fn test_add_table_rejects_overlap() {
561 let mut sheet = sheet_with_data();
562 sheet
563 .add_table("Sales".to_string(), 0, 0, 4, 1, true, false)
564 .unwrap();
565 let err = sheet
566 .add_table("Other".to_string(), 0, 1, 4, 2, true, false)
567 .unwrap_err();
568 assert!(err.contains("overlaps"));
569 }
570
571 #[test]
572 fn test_delete_and_rename_table() {
573 let mut sheet = sheet_with_data();
574 sheet
575 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
576 .unwrap();
577
578 sheet.rename_table("Sales", "Revenue").unwrap();
579 assert!(sheet.find_table("Sales").is_none());
580 assert!(sheet.find_table("Revenue").is_some());
581
582 sheet.delete_table_by_name("Revenue").unwrap();
583 assert!(sheet.find_table("Revenue").is_none());
584
585 let err = sheet.delete_table_by_name("Revenue").unwrap_err();
586 assert!(err.contains("not found"));
587 }
588
589 #[test]
590 fn test_resize_table_grows_and_shrinks() {
591 let mut sheet = sheet_with_data();
592 sheet
593 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
594 .unwrap();
595 assert_eq!(sheet.find_table("Sales").unwrap().columns.len(), 2);
596
597 sheet.resize_table("Sales", 4, 2).unwrap();
599 let table = sheet.find_table("Sales").unwrap();
600 assert_eq!(table.end_row, 4);
601 assert_eq!(table.end_col, 2);
602 assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
603
604 sheet.resize_table("Sales", 3, 0).unwrap();
606 let table = sheet.find_table("Sales").unwrap();
607 assert_eq!(table.end_row, 3);
608 assert_eq!(table.end_col, 0);
609 assert_eq!(table.columns, vec!["Name"]);
610 }
611
612 #[test]
613 fn test_rename_table_column_updates_header_cell() {
614 let mut sheet = sheet_with_data();
615 sheet
616 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
617 .unwrap();
618
619 sheet.rename_table_column("Sales", 1, "Total").unwrap();
620 assert_eq!(
621 sheet.find_table("Sales").unwrap().columns,
622 vec!["Name", "Total", "Qty"]
623 );
624 assert_eq!(sheet.columns[1].src[0], "Total");
626 }
627
628 #[test]
629 fn test_rename_table_column_rejects_duplicate() {
630 let mut sheet = sheet_with_data();
631 sheet
632 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
633 .unwrap();
634 let err = sheet.rename_table_column("Sales", 1, "Name").unwrap_err();
635 assert!(err.contains("already has a column"));
636 }
637
638 #[test]
639 fn an_insert_row_placeholder_means_zero_data_rows() {
640 let mut table = ExcelTable {
647 id: 1,
648 name: "Hollow".to_string(),
649 sheet_id: 1,
650 start_row: 0,
651 start_col: 0,
652 end_row: 1,
653 end_col: 2,
654 has_header_row: true,
655 has_totals_row: false,
656 columns: vec!["Region".into(), "Product".into(), "Amount".into()],
657 style_name: None,
658 has_insert_row: false,
659 };
660 assert_eq!(table.data_row_count(), 1);
662 assert_eq!(table.data_start_row(), 1);
663 assert_eq!(table.data_end_row(), 1);
664
665 table.has_insert_row = true;
667 assert_eq!(table.data_row_count(), 0);
668 assert!(table.data_end_row() < table.data_start_row());
671 }
672
673 #[test]
674 fn data_row_count_does_not_underflow_on_a_header_only_table() {
675 let table = ExcelTable {
676 id: 1,
677 name: "T".to_string(),
678 sheet_id: 1,
679 start_row: 0,
680 start_col: 0,
681 end_row: 0,
682 end_col: 1,
683 has_header_row: true,
684 has_totals_row: false,
685 columns: vec!["A".into(), "B".into()],
686 style_name: None,
687 has_insert_row: false,
688 };
689 assert_eq!(table.data_row_count(), 0);
690 }
691}