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