1use anyhow::{Context, Result};
2use calamine::{open_workbook_auto, Reader, Xls, Xlsx};
3use std::collections::HashSet;
4use std::fs::File;
5use std::io::BufReader;
6use std::panic::{catch_unwind, AssertUnwindSafe};
7use std::path::Path;
8
9use crate::excel::{Cell, CellType, FreezePanes, Sheet};
10use crate::utils::{index_to_col_name, parse_cell_reference};
11
12mod formula_lookup;
13mod freeze_panes;
14mod save;
15mod sheet_parse;
16
17use formula_lookup::lookup_formula_in_xlsx;
18use freeze_panes::lookup_freeze_panes_in_xlsx;
19use sheet_parse::create_sheet_from_range;
20
21pub enum CalamineWorkbook {
22 Xlsx(Box<Xlsx<BufReader<File>>>),
23 Xls(Box<Xls<BufReader<File>>>),
24 None,
25}
26
27impl Clone for CalamineWorkbook {
28 fn clone(&self) -> Self {
29 CalamineWorkbook::None
30 }
31}
32
33pub struct Workbook {
34 sheets: Vec<Sheet>,
35 current_sheet_index: usize,
36 file_path: String,
37 is_modified: bool,
38 calamine_workbook: CalamineWorkbook,
39 lazy_loading: bool,
40 loaded_sheets: HashSet<usize>, }
42
43impl Clone for Workbook {
44 fn clone(&self) -> Self {
45 Workbook {
46 sheets: self.sheets.clone(),
47 current_sheet_index: self.current_sheet_index,
48 file_path: self.file_path.clone(),
49 is_modified: self.is_modified,
50 calamine_workbook: CalamineWorkbook::None,
51 lazy_loading: false,
52 loaded_sheets: self.loaded_sheets.clone(),
53 }
54 }
55}
56
57pub fn open_workbook<P: AsRef<Path>>(path: P, enable_lazy_loading: bool) -> Result<Workbook> {
58 let path_str = path.as_ref().to_string_lossy().to_string();
59
60 let hook = std::panic::take_hook();
61 std::panic::set_hook(Box::new(|_| {}));
62 let result = catch_unwind(AssertUnwindSafe(|| {
63 open_workbook_impl(path.as_ref(), enable_lazy_loading)
64 }));
65 std::panic::set_hook(hook);
66
67 match result {
68 Ok(inner) => inner,
69 Err(_) => anyhow::bail!(
70 "Unable to parse Excel file: {} (parser panic: malformed workbook data)",
71 path_str
72 ),
73 }
74}
75
76fn open_workbook_impl<P: AsRef<Path>>(path: P, enable_lazy_loading: bool) -> Result<Workbook> {
77 let path_str = path.as_ref().to_string_lossy().to_string();
78 let path_ref = path.as_ref();
79
80 let extension = path_ref
82 .extension()
83 .and_then(|ext| ext.to_str())
84 .map(|ext| ext.to_lowercase());
85
86 let supports_lazy_loading =
88 enable_lazy_loading && matches!(extension.as_deref(), Some("xlsx" | "xlsm"));
89
90 let mut workbook = open_workbook_auto(&path)
92 .with_context(|| format!("Unable to parse Excel file: {}", path_str))?;
93
94 let sheet_names = workbook.sheet_names().to_vec();
95 let freeze_panes_by_name = sheet_names
96 .iter()
97 .map(|name| {
98 (
99 name.clone(),
100 lookup_freeze_panes_in_xlsx(path_ref, name).unwrap_or_default(),
101 )
102 })
103 .collect::<std::collections::HashMap<_, _>>();
104
105 let mut sheets = Vec::with_capacity(sheet_names.len());
107
108 let mut calamine_workbook = CalamineWorkbook::None;
110
111 if supports_lazy_loading {
112 for name in &sheet_names {
115 let sheet = Sheet {
117 name: name.to_string(),
118 data: vec![vec![Cell::empty(); 1]; 1],
119 max_rows: 0,
120 max_cols: 0,
121 is_loaded: false,
122 freeze_panes: freeze_panes_by_name.get(name).cloned().unwrap_or_default(),
123 };
124
125 sheets.push(sheet);
126 }
127
128 if let Ok(file) = File::open(&path) {
130 let reader = BufReader::new(file);
131
132 if let Ok(xlsx_workbook) = Xlsx::new(reader) {
134 calamine_workbook = CalamineWorkbook::Xlsx(Box::new(xlsx_workbook));
135 } else {
136 if let Ok(file) = File::open(&path) {
138 let reader = BufReader::new(file);
139 if let Ok(xls_workbook) = Xls::new(reader) {
140 calamine_workbook = CalamineWorkbook::Xls(Box::new(xls_workbook));
141 }
142 }
143 }
144 }
145 } else {
146 for name in &sheet_names {
148 let range = workbook.worksheet_range(name).with_context(|| {
149 format!(
150 "Unable to parse Excel file: {} (unable to read worksheet: {})",
151 path_str, name
152 )
153 })?;
154
155 let formula_range = workbook.worksheet_formula(name).ok();
156 let mut sheet = create_sheet_from_range(name, range, formula_range);
157 sheet.is_loaded = true;
158 sheet.freeze_panes = freeze_panes_by_name.get(name).cloned().unwrap_or_default();
159 sheets.push(sheet);
160 }
161 }
162
163 if sheets.is_empty() {
164 anyhow::bail!("No worksheets found in file");
165 }
166
167 let mut loaded_sheets = HashSet::new();
168
169 if !supports_lazy_loading {
170 for i in 0..sheets.len() {
171 loaded_sheets.insert(i);
172 }
173 }
174
175 Ok(Workbook {
176 sheets,
177 current_sheet_index: 0,
178 file_path: path_str,
179 is_modified: false,
180 calamine_workbook,
181 lazy_loading: supports_lazy_loading,
182 loaded_sheets,
183 })
184}
185
186fn shrink_freeze_rows(freeze_panes: &mut FreezePanes, start_row: usize, end_row: usize) -> bool {
187 if freeze_panes.rows == 0 || start_row > end_row || start_row > freeze_panes.rows {
188 return false;
189 }
190
191 let affected_end = end_row.min(freeze_panes.rows);
192 let deleted_frozen_rows = affected_end - start_row + 1;
193 freeze_panes.rows = freeze_panes.rows.saturating_sub(deleted_frozen_rows);
194 true
195}
196
197fn shrink_freeze_cols(freeze_panes: &mut FreezePanes, start_col: usize, end_col: usize) -> bool {
198 if freeze_panes.cols == 0 || start_col > end_col || start_col > freeze_panes.cols {
199 return false;
200 }
201
202 let affected_end = end_col.min(freeze_panes.cols);
203 let deleted_frozen_cols = affected_end - start_col + 1;
204 freeze_panes.cols = freeze_panes.cols.saturating_sub(deleted_frozen_cols);
205 true
206}
207
208impl Workbook {
209 pub fn get_current_sheet(&self) -> &Sheet {
210 &self.sheets[self.current_sheet_index]
211 }
212
213 pub fn get_current_sheet_mut(&mut self) -> &mut Sheet {
214 &mut self.sheets[self.current_sheet_index]
215 }
216
217 pub fn ensure_sheet_loaded(&mut self, sheet_index: usize, sheet_name: &str) -> Result<()> {
218 if !self.lazy_loading || self.sheets[sheet_index].is_loaded {
219 return Ok(());
220 }
221
222 let hook = std::panic::take_hook();
224 std::panic::set_hook(Box::new(|_| {}));
225 match &mut self.calamine_workbook {
226 CalamineWorkbook::Xlsx(xlsx) => {
227 let result = catch_unwind(AssertUnwindSafe(|| xlsx.worksheet_range(sheet_name)));
228 std::panic::set_hook(hook);
229 match result {
230 Ok(Ok(range)) => {
231 let formula_range = xlsx.worksheet_formula(sheet_name).ok();
232 let freeze_panes = self.sheets[sheet_index].freeze_panes.clone();
233 let mut sheet = create_sheet_from_range(sheet_name, range, formula_range);
234 let original_name = self.sheets[sheet_index].name.clone();
235 sheet.name = original_name;
236 sheet.freeze_panes = freeze_panes;
237 self.sheets[sheet_index] = sheet;
238 self.loaded_sheets.insert(sheet_index);
239 }
240 Ok(Err(_)) => {
241 }
243 Err(_) => {
244 self.calamine_workbook = CalamineWorkbook::None;
245 return Err(anyhow::anyhow!(
246 "Unable to read worksheet '{}': parser panic: malformed workbook data",
247 sheet_name
248 ));
249 }
250 }
251 }
252 CalamineWorkbook::Xls(xls) => {
253 let result = catch_unwind(AssertUnwindSafe(|| xls.worksheet_range(sheet_name)));
254 std::panic::set_hook(hook);
255 match result {
256 Ok(Ok(range)) => {
257 let formula_range = xls.worksheet_formula(sheet_name).ok();
258 let freeze_panes = self.sheets[sheet_index].freeze_panes.clone();
259 let mut sheet = create_sheet_from_range(sheet_name, range, formula_range);
260 let original_name = self.sheets[sheet_index].name.clone();
261 sheet.name = original_name;
262 sheet.freeze_panes = freeze_panes;
263 self.sheets[sheet_index] = sheet;
264 self.loaded_sheets.insert(sheet_index);
265 }
266 Ok(Err(_)) => {
267 }
269 Err(_) => {
270 self.calamine_workbook = CalamineWorkbook::None;
271 return Err(anyhow::anyhow!(
272 "Unable to read worksheet '{}': parser panic: malformed workbook data",
273 sheet_name
274 ));
275 }
276 }
277 }
278 CalamineWorkbook::None => {
279 std::panic::set_hook(hook);
280 return Err(anyhow::anyhow!("Cannot load sheet: no workbook available"));
281 }
282 }
283
284 Ok(())
285 }
286
287 pub fn get_sheet_by_index(&self, index: usize) -> Option<&Sheet> {
288 self.sheets.get(index)
289 }
290
291 pub fn get_sheet_by_name(&self, name: &str) -> Option<&Sheet> {
292 self.sheets.iter().find(|s| s.name == name)
293 }
294
295 pub(crate) fn formula_for_cell(
296 &self,
297 sheet_index: usize,
298 sheet_name: &str,
299 cell_ref: &str,
300 ) -> Option<String> {
301 let (row, col) = parse_cell_reference(cell_ref)?;
302 let loaded_formula = self
303 .sheets
304 .get(sheet_index)
305 .and_then(|sheet| sheet.data.get(row))
306 .and_then(|cells| cells.get(col))
307 .and_then(|cell| cell.formula.clone());
308
309 loaded_formula
310 .or_else(|| lookup_formula_in_xlsx(Path::new(&self.file_path), sheet_name, cell_ref))
311 }
312
313 pub fn resolve_sheet(&self, spec: &str) -> Result<usize> {
315 if let Ok(index) = spec.parse::<usize>() {
317 if index < self.sheets.len() {
318 return Ok(index);
319 }
320 }
321
322 if let Some(index) = self.sheets.iter().position(|s| s.name == spec) {
324 return Ok(index);
325 }
326
327 anyhow::bail!("Sheet '{}' not found", spec)
328 }
329
330 pub fn resolve_sheet_by_name(&self, name: &str) -> Result<usize> {
332 self.sheets
333 .iter()
334 .position(|s| s.name == name)
335 .ok_or_else(|| anyhow::anyhow!("Sheet '{}' not found", name))
336 }
337
338 pub fn resolve_sheet_by_index(&self, index: usize) -> Result<usize> {
340 if index < self.sheets.len() {
341 Ok(index)
342 } else {
343 anyhow::bail!(
344 "Sheet index {} out of range (max: {})",
345 index,
346 self.sheets.len().saturating_sub(1)
347 )
348 }
349 }
350
351 pub fn count_non_empty_rows(&self, sheet_index: usize) -> Result<usize> {
353 let sheet = self
354 .sheets
355 .get(sheet_index)
356 .ok_or_else(|| anyhow::anyhow!("Sheet index out of range"))?;
357
358 let mut count = 0;
359 for row in 1..=sheet.max_rows {
360 if row < sheet.data.len() {
361 let has_data = sheet.data[row].iter().any(|cell| !cell.value.is_empty());
362 if has_data {
363 count += 1;
364 }
365 }
366 }
367 Ok(count)
368 }
369
370 pub fn count_non_empty_cols(&self, sheet_index: usize) -> Result<usize> {
372 let sheet = self
373 .sheets
374 .get(sheet_index)
375 .ok_or_else(|| anyhow::anyhow!("Sheet index out of range"))?;
376
377 let mut count = 0;
378 for col in 1..=sheet.max_cols {
379 let has_data = sheet.data.iter().any(|row| {
380 if col < row.len() {
381 !row[col].value.is_empty()
382 } else {
383 false
384 }
385 });
386 if has_data {
387 count += 1;
388 }
389 }
390 Ok(count)
391 }
392
393 pub fn find_header_candidates(
396 &self,
397 sheet_index: usize,
398 ) -> Result<(Vec<usize>, Option<usize>)> {
399 let sheet = self
400 .sheets
401 .get(sheet_index)
402 .ok_or_else(|| anyhow::anyhow!("Sheet index out of range"))?;
403
404 if sheet.max_rows == 0 {
405 return Ok((vec![], None));
406 }
407
408 let mut candidates = Vec::new();
409 let max_scan = sheet.max_rows.min(20);
410
411 for row in 1..=max_scan {
412 if row >= sheet.data.len() {
413 break;
414 }
415 let row_data = &sheet.data[row];
416 let non_empty_count = row_data
417 .iter()
418 .take(sheet.max_cols + 1)
419 .filter(|c| !c.value.is_empty())
420 .count();
421 let text_count = row_data
422 .iter()
423 .take(sheet.max_cols + 1)
424 .filter(|c| {
425 !c.value.is_empty()
426 && (c.cell_type == CellType::Text || c.cell_type == CellType::Boolean)
427 })
428 .count();
429 let total_cols = sheet.max_cols.max(1);
430
431 let non_empty_ratio = non_empty_count as f64 / total_cols as f64;
432 let text_ratio = if non_empty_count > 0 {
433 text_count as f64 / non_empty_count as f64
434 } else {
435 0.0
436 };
437
438 if non_empty_ratio >= 0.3 && text_ratio >= 0.5 {
440 candidates.push(row);
441 }
442 }
443
444 let recommended = candidates.first().copied();
445 Ok((candidates, recommended))
446 }
447
448 pub fn get_used_range(&self, sheet_index: usize) -> Result<String> {
451 let sheet = self
452 .sheets
453 .get(sheet_index)
454 .ok_or_else(|| anyhow::anyhow!("Sheet index out of range"))?;
455
456 if sheet.max_rows == 0 || sheet.max_cols == 0 {
457 return Ok(String::new());
458 }
459
460 let end_col = index_to_col_name(sheet.max_cols);
461 Ok(format!("A1:{}{}", end_col, sheet.max_rows))
462 }
463
464 pub fn ensure_cell_exists(&mut self, row: usize, col: usize) {
465 let sheet = &mut self.sheets[self.current_sheet_index];
466
467 if row >= sheet.data.len() {
469 let default_row_len = if sheet.data.is_empty() {
470 col + 1
471 } else {
472 sheet.data[0].len()
473 };
474 let rows_to_add = row + 1 - sheet.data.len();
475
476 sheet
477 .data
478 .extend(vec![vec![Cell::empty(); default_row_len]; rows_to_add]);
479 sheet.max_rows = sheet.max_rows.max(row);
480 }
481
482 if col >= sheet.data[0].len() {
484 for row_data in &mut sheet.data {
485 row_data.resize_with(col + 1, Cell::empty);
486 }
487
488 sheet.max_cols = sheet.max_cols.max(col);
489 }
490 }
491
492 pub fn set_cell_value(&mut self, row: usize, col: usize, value: String) -> Result<()> {
493 self.ensure_cell_exists(row, col);
494
495 let sheet = &mut self.sheets[self.current_sheet_index];
496 let current_value = &sheet.data[row][col].value;
497
498 if current_value != &value {
500 let is_formula = value.starts_with('=');
501 sheet.data[row][col] = Cell::new(value, is_formula);
502
503 if col > sheet.max_cols && !sheet.data[row][col].value.is_empty() {
505 sheet.max_cols = col;
506 }
507
508 self.is_modified = true;
509 }
510
511 Ok(())
512 }
513
514 pub fn set_freeze_panes(&mut self, rows: usize, cols: usize) {
515 let sheet = &mut self.sheets[self.current_sheet_index];
516
517 if sheet.freeze_panes.rows != rows || sheet.freeze_panes.cols != cols {
518 sheet.freeze_panes = FreezePanes { rows, cols };
519 self.is_modified = true;
520 }
521 }
522
523 pub fn clear_freeze_panes(&mut self) {
524 self.set_freeze_panes(0, 0);
525 }
526
527 pub fn get_sheet_names(&self) -> Vec<String> {
528 let mut names = Vec::with_capacity(self.sheets.len());
529 for sheet in &self.sheets {
530 names.push(sheet.name.clone());
531 }
532 names
533 }
534
535 pub fn get_current_sheet_name(&self) -> String {
536 self.sheets[self.current_sheet_index].name.clone()
537 }
538
539 pub fn get_current_sheet_index(&self) -> usize {
540 self.current_sheet_index
541 }
542
543 pub fn switch_sheet(&mut self, index: usize) -> Result<()> {
544 if index >= self.sheets.len() {
545 anyhow::bail!("Sheet index out of range");
546 }
547
548 self.current_sheet_index = index;
549 Ok(())
550 }
551
552 pub fn add_sheet(&mut self, name: &str, index: usize) -> Result<String> {
553 let sheet_name = name.trim();
554
555 self.validate_sheet_name(sheet_name)?;
556 self.insert_sheet_at_index(Sheet::blank(sheet_name.to_string()), index)?;
557
558 Ok(sheet_name.to_string())
559 }
560
561 pub fn delete_current_sheet(&mut self) -> Result<()> {
562 self.delete_sheet_at_index(self.current_sheet_index)
563 }
564
565 pub fn delete_sheet_at_index(&mut self, index: usize) -> Result<()> {
566 if self.sheets.len() <= 1 {
568 anyhow::bail!("Cannot delete the last sheet");
569 }
570
571 if index >= self.sheets.len() {
572 anyhow::bail!("Sheet index out of range");
573 }
574
575 self.sheets.remove(index);
576 self.is_modified = true;
577
578 if index < self.current_sheet_index {
579 self.current_sheet_index = self.current_sheet_index.saturating_sub(1);
580 } else if self.current_sheet_index >= self.sheets.len() {
581 self.current_sheet_index = self.sheets.len() - 1;
582 }
583
584 Ok(())
585 }
586
587 pub fn delete_row(&mut self, row: usize) -> Result<()> {
588 let sheet = &mut self.sheets[self.current_sheet_index];
589
590 if row < 1 {
592 return Ok(());
593 }
594
595 if row > sheet.max_rows {
597 return Ok(());
598 }
599
600 let freeze_changed = shrink_freeze_rows(&mut sheet.freeze_panes, row, row);
601
602 if row < sheet.data.len() {
604 sheet.data.remove(row);
605 self.recalculate_max_cols();
606 self.is_modified = true;
607 }
608
609 if freeze_changed {
610 self.is_modified = true;
611 }
612
613 Ok(())
614 }
615
616 pub fn delete_rows(&mut self, start_row: usize, end_row: usize) -> Result<()> {
618 let sheet = &mut self.sheets[self.current_sheet_index];
619
620 if start_row < 1 || start_row > end_row {
622 return Ok(());
623 }
624
625 if start_row > sheet.max_rows {
627 return Ok(());
628 }
629
630 let adjusted_end_row = end_row.min(sheet.data.len() - 1);
632
633 let effective_end_row = if end_row > sheet.max_rows {
635 sheet.max_rows
636 } else {
637 adjusted_end_row
638 };
639
640 let freeze_changed =
641 shrink_freeze_rows(&mut sheet.freeze_panes, start_row, effective_end_row);
642
643 if start_row <= effective_end_row && start_row < sheet.data.len() {
645 for row in (start_row..=effective_end_row).rev() {
647 if row < sheet.data.len() {
648 sheet.data.remove(row);
649 }
650 }
651
652 self.recalculate_max_cols();
653 self.is_modified = true;
654 }
655
656 if freeze_changed {
657 self.is_modified = true;
658 }
659
660 Ok(())
661 }
662
663 pub fn delete_column(&mut self, col: usize) -> Result<()> {
664 let sheet = &mut self.sheets[self.current_sheet_index];
665
666 if col < 1 {
668 return Ok(());
669 }
670
671 if col > sheet.max_cols {
673 return Ok(());
674 }
675
676 let freeze_changed = shrink_freeze_cols(&mut sheet.freeze_panes, col, col);
677 let mut has_data = false;
678 for row in &sheet.data {
679 if col < row.len() && !row[col].value.is_empty() {
680 has_data = true;
681 break;
682 }
683 }
684
685 for row in sheet.data.iter_mut() {
686 if col < row.len() {
687 row.remove(col);
688 }
689 }
690
691 self.recalculate_max_cols();
692 self.recalculate_max_rows();
693
694 if has_data || freeze_changed {
695 self.is_modified = true;
696 }
697
698 Ok(())
699 }
700
701 pub fn delete_columns(&mut self, start_col: usize, end_col: usize) -> Result<()> {
703 let sheet = &mut self.sheets[self.current_sheet_index];
704
705 if start_col < 1 || start_col > end_col {
707 return Ok(());
708 }
709
710 if start_col > sheet.max_cols {
712 return Ok(());
713 }
714
715 let effective_end_col = end_col.min(sheet.max_cols);
717
718 let freeze_changed =
719 shrink_freeze_cols(&mut sheet.freeze_panes, start_col, effective_end_col);
720 let mut has_data = false;
721 for row in &sheet.data {
722 for col in start_col..=effective_end_col {
723 if col < row.len() && !row[col].value.is_empty() {
724 has_data = true;
725 break;
726 }
727 }
728 if has_data {
729 break;
730 }
731 }
732
733 for row in sheet.data.iter_mut() {
734 for col in (start_col..=effective_end_col).rev() {
735 if col < row.len() {
736 row.remove(col);
737 }
738 }
739 }
740
741 self.recalculate_max_cols();
742 self.recalculate_max_rows();
743
744 if has_data || freeze_changed {
745 self.is_modified = true;
746 }
747
748 Ok(())
749 }
750
751 pub fn is_modified(&self) -> bool {
752 self.is_modified
753 }
754
755 pub fn set_modified(&mut self, modified: bool) {
756 self.is_modified = modified;
757 }
758
759 pub fn get_file_path(&self) -> &str {
760 &self.file_path
761 }
762
763 pub fn is_lazy_loading(&self) -> bool {
764 self.lazy_loading
765 }
766
767 pub fn is_sheet_loaded(&self, sheet_index: usize) -> bool {
768 if !self.lazy_loading || sheet_index >= self.sheets.len() {
769 return true;
770 }
771
772 self.sheets[sheet_index].is_loaded
773 }
774
775 pub fn insert_sheet_at_index(&mut self, sheet: Sheet, index: usize) -> Result<()> {
776 if index > self.sheets.len() {
777 anyhow::bail!(
778 "Cannot insert sheet at index {}: index out of bounds (max index: {})",
779 index,
780 self.sheets.len()
781 );
782 }
783
784 if index <= self.current_sheet_index {
785 self.current_sheet_index += 1;
786 }
787
788 self.sheets.insert(index, sheet);
789 self.is_modified = true;
790 Ok(())
791 }
792
793 pub fn recalculate_max_cols(&mut self) {
794 let sheet = &mut self.sheets[self.current_sheet_index];
795
796 let actual_max_col = sheet
798 .data
799 .iter()
800 .map(|row| {
801 row.iter()
803 .enumerate()
804 .rev()
805 .find(|(_, cell)| !cell.value.is_empty())
806 .map(|(idx, _)| idx)
807 .unwrap_or(0)
808 })
809 .max()
810 .unwrap_or(0);
811
812 sheet.max_cols = actual_max_col.max(1);
813 }
814
815 pub fn recalculate_max_rows(&mut self) {
816 let sheet = &mut self.sheets[self.current_sheet_index];
817
818 let actual_max_row = sheet
820 .data
821 .iter()
822 .enumerate()
823 .rev()
824 .find(|(_, row)| row.iter().any(|cell| !cell.value.is_empty()))
825 .map(|(idx, _)| idx)
826 .unwrap_or(0);
827
828 sheet.max_rows = actual_max_row.max(1);
829 }
830
831 fn ensure_all_sheets_loaded(&mut self) -> Result<()> {
832 if !self.lazy_loading {
833 return Ok(());
834 }
835
836 let pending_sheets: Vec<(usize, String)> = self
837 .sheets
838 .iter()
839 .enumerate()
840 .filter(|(_, sheet)| !sheet.is_loaded)
841 .map(|(index, sheet)| (index, sheet.name.clone()))
842 .collect();
843
844 for (index, name) in pending_sheets {
845 self.ensure_sheet_loaded(index, &name)?;
846 }
847
848 Ok(())
849 }
850
851 fn validate_sheet_name(&self, name: &str) -> Result<()> {
852 if name.is_empty() {
853 anyhow::bail!("Sheet name cannot be empty");
854 }
855
856 if name.chars().count() > 31 {
857 anyhow::bail!("Sheet name cannot exceed 31 characters");
858 }
859
860 if name.starts_with('\'') || name.ends_with('\'') {
861 anyhow::bail!("Sheet name cannot start or end with apostrophes");
862 }
863
864 if name
865 .chars()
866 .any(|c| matches!(c, '[' | ']' | ':' | '*' | '?' | '/' | '\\'))
867 {
868 anyhow::bail!("Sheet name cannot contain any of these characters: [ ] : * ? / \\");
869 }
870
871 if self
872 .sheets
873 .iter()
874 .any(|sheet| sheet.name.eq_ignore_ascii_case(name))
875 {
876 anyhow::bail!("Sheet '{}' already exists", name);
877 }
878
879 Ok(())
880 }
881
882 #[cfg(test)]
883 pub(crate) fn from_sheets_for_test(sheets: Vec<Sheet>) -> Self {
884 let loaded_sheets = (0..sheets.len()).collect();
885
886 Self {
887 sheets,
888 current_sheet_index: 0,
889 file_path: "test.xlsx".to_string(),
890 is_modified: false,
891 calamine_workbook: CalamineWorkbook::None,
892 lazy_loading: false,
893 loaded_sheets,
894 }
895 }
896}
897
898#[cfg(test)]
899mod tests;