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}
50
51impl ExcelTable {
52 pub fn set_style_name(&mut self, style_name: Option<String>) {
54 self.style_name = style_name;
55 }
56
57 pub fn row_count(&self) -> usize {
59 self.end_row - self.start_row + 1
60 }
61
62 pub fn col_count(&self) -> usize {
64 self.end_col - self.start_col + 1
65 }
66
67 pub fn data_start_row(&self) -> usize {
69 self.start_row + usize::from(self.has_header_row)
70 }
71
72 pub fn data_end_row(&self) -> usize {
75 self.end_row - usize::from(self.has_totals_row)
76 }
77
78 pub fn header_row(&self) -> Option<usize> {
81 self.has_header_row.then_some(self.start_row)
82 }
83
84 pub fn totals_row(&self) -> Option<usize> {
87 self.has_totals_row.then_some(self.end_row)
88 }
89
90 pub fn local_column_index(&self, name: &str) -> Option<usize> {
94 self.columns
95 .iter()
96 .position(|c| c.eq_ignore_ascii_case(name))
97 }
98
99 pub fn overlaps(
101 &self,
102 start_row: usize,
103 start_col: usize,
104 end_row: usize,
105 end_col: usize,
106 ) -> bool {
107 self.start_row <= end_row
108 && start_row <= self.end_row
109 && self.start_col <= end_col
110 && start_col <= self.end_col
111 }
112}
113
114fn validate_table_name(name: &str) -> Result<(), String> {
115 let trimmed = name.trim();
116 if trimmed.is_empty() {
117 return Err("Table name cannot be empty".to_string());
118 }
119 let first = trimmed.chars().next().unwrap();
120 if !(first.is_alphabetic() || first == '_') {
121 return Err(format!(
122 "Table name '{}' must start with a letter or underscore",
123 name
124 ));
125 }
126 if !trimmed
127 .chars()
128 .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
129 {
130 return Err(format!(
131 "Table name '{}' may only contain letters, digits, underscores, and periods",
132 name
133 ));
134 }
135 Ok(())
136}
137
138fn check_duplicate_column_names(columns: &[String]) -> Result<(), String> {
139 let mut seen = std::collections::HashSet::new();
140 for c in columns {
141 if !seen.insert(c.to_ascii_lowercase()) {
142 return Err(format!("Duplicate column name '{}' in table header row", c));
143 }
144 }
145 Ok(())
146}
147
148impl Sheet {
149 pub fn find_table(&self, name: &str) -> Option<&ExcelTable> {
152 self.tables
153 .iter()
154 .find(|t| t.name.eq_ignore_ascii_case(name))
155 }
156
157 pub fn find_table_mut(&mut self, name: &str) -> Option<&mut ExcelTable> {
159 self.tables
160 .iter_mut()
161 .find(|t| t.name.eq_ignore_ascii_case(name))
162 }
163
164 fn table_column_header(&self, header_row: usize, col_idx: usize, local_idx: usize) -> String {
168 let computed = self
173 .columns
174 .get(col_idx)
175 .and_then(|c| c.data.get(header_row))
176 .map(|d| d.to_string())
177 .filter(|s| !s.is_empty());
178 computed
179 .or_else(|| {
180 self.columns
181 .get(col_idx)
182 .and_then(|c| c.src.get(header_row))
183 .map(|s| s.trim().to_string())
184 .filter(|s| !s.is_empty())
185 })
186 .unwrap_or_else(|| format!("Column{}", local_idx + 1))
187 }
188
189 #[allow(clippy::too_many_arguments)]
196 pub fn add_table(
197 &mut self,
198 name: String,
199 start_row: usize,
200 start_col: usize,
201 end_row: usize,
202 end_col: usize,
203 has_header_row: bool,
204 has_totals_row: bool,
205 ) -> Result<u64, String> {
206 validate_table_name(&name)?;
207 if self.find_table(&name).is_some() {
208 return Err(format!(
209 "Table '{}' already exists on sheet '{}'",
210 name, self.name
211 ));
212 }
213 if end_row < start_row || end_col < start_col {
214 return Err("Table range end must not precede its start".to_string());
215 }
216 let (row_count, col_count) = (self.row_count(), self.col_count());
217 if end_row >= row_count || end_col >= col_count {
218 return Err(format!(
219 "Table range exceeds sheet bounds ({} rows x {} cols)",
220 row_count, col_count
221 ));
222 }
223 if let Some(existing) = self
224 .tables
225 .iter()
226 .find(|t| t.overlaps(start_row, start_col, end_row, end_col))
227 {
228 return Err(format!(
229 "Table range overlaps existing table '{}' on sheet '{}'",
230 existing.name, self.name
231 ));
232 }
233
234 let columns: Vec<String> = (start_col..=end_col)
235 .enumerate()
236 .map(|(local_idx, col_idx)| {
237 if has_header_row {
238 self.table_column_header(start_row, col_idx, local_idx)
239 } else {
240 format!("Column{}", local_idx + 1)
241 }
242 })
243 .collect();
244 check_duplicate_column_names(&columns)?;
245
246 let id = generate_unique_id();
247 self.tables.push(ExcelTable {
248 id,
249 name,
250 sheet_id: self.id,
251 start_row,
252 start_col,
253 end_row,
254 end_col,
255 has_header_row,
256 has_totals_row,
257 columns,
258 style_name: None,
259 });
260 Ok(id)
261 }
262
263 pub fn delete_table_by_name(&mut self, name: &str) -> Result<(), String> {
270 if let Some(pos) = self
271 .tables
272 .iter()
273 .position(|t| t.name.eq_ignore_ascii_case(name))
274 {
275 self.tables.remove(pos);
276 Ok(())
277 } else {
278 Err(format!(
279 "Table '{}' not found on sheet '{}'",
280 name, self.name
281 ))
282 }
283 }
284
285 pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> Result<(), String> {
298 validate_table_name(new_name)?;
299 if self.tables.iter().any(|t| {
300 !t.name.eq_ignore_ascii_case(old_name) && t.name.eq_ignore_ascii_case(new_name)
301 }) {
302 return Err(format!("Table name '{}' is already taken", new_name));
303 }
304 let sheet_name = self.name.clone();
305 let table = self
306 .find_table_mut(old_name)
307 .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", old_name, sheet_name))?;
308 table.name = new_name.to_string();
309 Ok(())
310 }
311
312 pub fn resize_table(
318 &mut self,
319 name: &str,
320 new_end_row: usize,
321 new_end_col: usize,
322 ) -> Result<(), String> {
323 let (id, start_row, start_col, has_header_row, old_columns) = {
324 let table = self
325 .find_table(name)
326 .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", name, self.name))?;
327 (
328 table.id,
329 table.start_row,
330 table.start_col,
331 table.has_header_row,
332 table.columns.clone(),
333 )
334 };
335
336 if new_end_row < start_row || new_end_col < start_col {
337 return Err("Table range end must not precede its start".to_string());
338 }
339 let (row_count, col_count) = (self.row_count(), self.col_count());
340 if new_end_row >= row_count || new_end_col >= col_count {
341 return Err(format!(
342 "Table range exceeds sheet bounds ({} rows x {} cols)",
343 row_count, col_count
344 ));
345 }
346 if let Some(existing) = self
347 .tables
348 .iter()
349 .find(|t| t.id != id && t.overlaps(start_row, start_col, new_end_row, new_end_col))
350 {
351 return Err(format!(
352 "Resized range would overlap existing table '{}' on sheet '{}'",
353 existing.name, self.name
354 ));
355 }
356
357 let new_columns: Vec<String> = (start_col..=new_end_col)
358 .enumerate()
359 .map(|(local_idx, col_idx)| {
360 old_columns.get(local_idx).cloned().unwrap_or_else(|| {
361 if has_header_row {
362 self.table_column_header(start_row, col_idx, local_idx)
363 } else {
364 format!("Column{}", local_idx + 1)
365 }
366 })
367 })
368 .collect();
369 check_duplicate_column_names(&new_columns)?;
370
371 let table = self.find_table_mut(name).unwrap();
372 table.end_row = new_end_row;
373 table.end_col = new_end_col;
374 table.columns = new_columns;
375 Ok(())
376 }
377
378 pub fn rename_table_column(
382 &mut self,
383 table_name: &str,
384 col_index: usize,
385 new_name: &str,
386 ) -> Result<(), String> {
387 let trimmed = new_name.trim();
388 if trimmed.is_empty() {
389 return Err("Column name cannot be empty".to_string());
390 }
391
392 let (sheet_col, header_row, has_header_row) = {
393 let table = self.find_table(table_name).ok_or_else(|| {
394 format!("Table '{}' not found on sheet '{}'", table_name, self.name)
395 })?;
396 if col_index >= table.columns.len() {
397 return Err(format!(
398 "Column index {} out of bounds (table '{}' has {} columns)",
399 col_index,
400 table_name,
401 table.columns.len()
402 ));
403 }
404 if table
405 .columns
406 .iter()
407 .enumerate()
408 .any(|(i, c)| i != col_index && c.eq_ignore_ascii_case(trimmed))
409 {
410 return Err(format!(
411 "Table '{}' already has a column named '{}'",
412 table_name, trimmed
413 ));
414 }
415 (
416 table.start_col + col_index,
417 table.start_row,
418 table.has_header_row,
419 )
420 };
421
422 if let Some(table) = self.find_table_mut(table_name) {
423 table.columns[col_index] = trimmed.to_string();
424 }
425 if has_header_row {
426 self.set_cell_src(header_row, sheet_col, trimmed.to_string());
427 }
428 Ok(())
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::core::engine::SheetInit;
436
437 fn sheet_with_data() -> Sheet {
438 let mut sheet = Sheet::new(SheetInit {
439 name: Some("Sheet1".to_string()),
440 rows: 6,
441 cols: 3,
442 ..Default::default()
443 });
444 let header = ["Name", "Amount", "Qty"];
446 let data = [
447 ["Widget", "10", "2"],
448 ["Gadget", "20", "3"],
449 ["Gizmo", "30", "4"],
450 ["Doohickey", "40", "5"],
451 ];
452 for (c, h) in header.iter().enumerate() {
453 sheet.set_cell_src(0, c, h.to_string());
454 }
455 for (r, row) in data.iter().enumerate() {
456 for (c, v) in row.iter().enumerate() {
457 sheet.set_cell_src(r + 1, c, v.to_string());
458 }
459 }
460 sheet.set_cell_src(5, 1, "=SUM(B2:B5)".to_string());
461 sheet.commit(None).unwrap();
462 sheet
463 }
464
465 #[test]
466 fn test_add_table_reads_headers_and_bounds() {
467 let mut sheet = sheet_with_data();
468 let id = sheet
469 .add_table("Sales".to_string(), 0, 0, 5, 2, true, true)
470 .unwrap();
471 let table = sheet.find_table("Sales").unwrap();
472 assert_eq!(table.id, id);
473 assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
474 assert_eq!(table.data_start_row(), 1);
475 assert_eq!(table.data_end_row(), 4);
476 assert_eq!(table.header_row(), Some(0));
477 assert_eq!(table.totals_row(), Some(5));
478 }
479
480 #[test]
481 fn test_add_table_no_header_row_uses_default_names() {
482 let mut sheet = sheet_with_data();
483 sheet
484 .add_table("Raw".to_string(), 1, 0, 4, 2, false, false)
485 .unwrap();
486 let table = sheet.find_table("Raw").unwrap();
487 assert_eq!(table.columns, vec!["Column1", "Column2", "Column3"]);
488 assert_eq!(table.data_start_row(), 1);
489 assert_eq!(table.data_end_row(), 4);
490 assert_eq!(table.totals_row(), None);
491 }
492
493 #[test]
494 fn test_add_table_rejects_duplicate_name() {
495 let mut sheet = sheet_with_data();
496 sheet
497 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
498 .unwrap();
499 let err = sheet
500 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
501 .unwrap_err();
502 assert!(err.contains("already exists"));
503 }
504
505 #[test]
506 fn test_add_table_rejects_invalid_name() {
507 let mut sheet = sheet_with_data();
508 let err = sheet
509 .add_table("1Sales".to_string(), 0, 0, 4, 2, true, false)
510 .unwrap_err();
511 assert!(err.contains("must start with"));
512
513 let err2 = sheet
514 .add_table("Sales Report".to_string(), 0, 0, 4, 2, true, false)
515 .unwrap_err();
516 assert!(err2.contains("letters, digits"));
517 }
518
519 #[test]
520 fn test_add_table_rejects_out_of_bounds_range() {
521 let mut sheet = sheet_with_data();
522 let err = sheet
523 .add_table("Sales".to_string(), 0, 0, 10, 2, true, false)
524 .unwrap_err();
525 assert!(err.contains("exceeds sheet bounds"));
526 }
527
528 #[test]
529 fn test_add_table_rejects_overlap() {
530 let mut sheet = sheet_with_data();
531 sheet
532 .add_table("Sales".to_string(), 0, 0, 4, 1, true, false)
533 .unwrap();
534 let err = sheet
535 .add_table("Other".to_string(), 0, 1, 4, 2, true, false)
536 .unwrap_err();
537 assert!(err.contains("overlaps"));
538 }
539
540 #[test]
541 fn test_delete_and_rename_table() {
542 let mut sheet = sheet_with_data();
543 sheet
544 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
545 .unwrap();
546
547 sheet.rename_table("Sales", "Revenue").unwrap();
548 assert!(sheet.find_table("Sales").is_none());
549 assert!(sheet.find_table("Revenue").is_some());
550
551 sheet.delete_table_by_name("Revenue").unwrap();
552 assert!(sheet.find_table("Revenue").is_none());
553
554 let err = sheet.delete_table_by_name("Revenue").unwrap_err();
555 assert!(err.contains("not found"));
556 }
557
558 #[test]
559 fn test_resize_table_grows_and_shrinks() {
560 let mut sheet = sheet_with_data();
561 sheet
562 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
563 .unwrap();
564 assert_eq!(sheet.find_table("Sales").unwrap().columns.len(), 2);
565
566 sheet.resize_table("Sales", 4, 2).unwrap();
568 let table = sheet.find_table("Sales").unwrap();
569 assert_eq!(table.end_row, 4);
570 assert_eq!(table.end_col, 2);
571 assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
572
573 sheet.resize_table("Sales", 3, 0).unwrap();
575 let table = sheet.find_table("Sales").unwrap();
576 assert_eq!(table.end_row, 3);
577 assert_eq!(table.end_col, 0);
578 assert_eq!(table.columns, vec!["Name"]);
579 }
580
581 #[test]
582 fn test_rename_table_column_updates_header_cell() {
583 let mut sheet = sheet_with_data();
584 sheet
585 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
586 .unwrap();
587
588 sheet.rename_table_column("Sales", 1, "Total").unwrap();
589 assert_eq!(
590 sheet.find_table("Sales").unwrap().columns,
591 vec!["Name", "Total", "Qty"]
592 );
593 assert_eq!(sheet.columns[1].src[0], "Total");
595 }
596
597 #[test]
598 fn test_rename_table_column_rejects_duplicate() {
599 let mut sheet = sheet_with_data();
600 sheet
601 .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
602 .unwrap();
603 let err = sheet.rename_table_column("Sales", 1, "Name").unwrap_err();
604 assert!(err.contains("already has a column"));
605 }
606}