Skip to main content

excel_cli/excel/
workbook.rs

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>, // Track which sheets have been loaded
41}
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    // Determine if the file format supports lazy loading
81    let extension = path_ref
82        .extension()
83        .and_then(|ext| ext.to_str())
84        .map(|ext| ext.to_lowercase());
85
86    // Only enable lazy loading if both the flag is set AND the format supports it
87    let supports_lazy_loading =
88        enable_lazy_loading && matches!(extension.as_deref(), Some("xlsx" | "xlsm"));
89
90    // Open workbook directly from path
91    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    // Pre-allocate with the right capacity
106    let mut sheets = Vec::with_capacity(sheet_names.len());
107
108    // Store the original calamine workbook for lazy loading if enabled
109    let mut calamine_workbook = CalamineWorkbook::None;
110
111    if supports_lazy_loading {
112        // For formats that support lazy loading, keep the original workbook
113        // and only load sheet metadata
114        for name in &sheet_names {
115            // Create a minimal sheet with just the name
116            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        // Try to reopen the file to get a fresh reader for lazy loading
129        if let Ok(file) = File::open(&path) {
130            let reader = BufReader::new(file);
131
132            // Try to open as XLSX first
133            if let Ok(xlsx_workbook) = Xlsx::new(reader) {
134                calamine_workbook = CalamineWorkbook::Xlsx(Box::new(xlsx_workbook));
135            } else {
136                // If not XLSX, try to open as XLS
137                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 formats that don't support lazy loading or if lazy loading is disabled,
147        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        // Load the sheet data from the calamine workbook
223        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                        // Non-panic parse error: leave sheet unloaded and continue
242                    }
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                        // Non-panic parse error: leave sheet unloaded and continue
268                    }
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    /// Resolve a sheet specifier (name or 0-based index) to a sheet index.
314    pub fn resolve_sheet(&self, spec: &str) -> Result<usize> {
315        // Try parsing as 0-based index first
316        if let Ok(index) = spec.parse::<usize>() {
317            if index < self.sheets.len() {
318                return Ok(index);
319            }
320        }
321
322        // Try matching by exact name
323        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    /// Resolve sheet by exact name.
331    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    /// Resolve sheet by 0-based index.
339    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    /// Compute non-empty row count for a sheet.
352    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    /// Compute non-empty column count for a sheet.
371    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    /// Find header row candidates for a sheet.
394    /// Returns (candidates[], recommended_header_row).
395    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            // A good header row has decent coverage and mostly text values
439            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    /// Get the used range of a sheet in A1 notation (e.g., "A1:H2048").
449    /// Returns an empty string if the sheet has no data.
450    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        // Expand rows if needed
468        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        // Expand columns if needed
483        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        // Only set modified flag if value actually changes
499        if current_value != &value {
500            let is_formula = value.starts_with('=');
501            sheet.data[row][col] = Cell::new(value, is_formula);
502
503            // Update max_cols if needed
504            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        // Prevent deleting the last sheet
567        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 is less than 1, return early with success
591        if row < 1 {
592            return Ok(());
593        }
594
595        // If row is outside the max range, return early with success
596        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        // Only remove the row if it exists in the data
603        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    // Delete a range of rows from the current sheet
617    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 is less than 1 or start_row > end_row, return early with success
621        if start_row < 1 || start_row > end_row {
622            return Ok(());
623        }
624
625        // If the entire range is outside max_rows, return early with success
626        if start_row > sheet.max_rows {
627            return Ok(());
628        }
629
630        // Adjust end_row to not exceed the data length
631        let adjusted_end_row = end_row.min(sheet.data.len() - 1);
632
633        // If start_row is valid but end_row exceeds max_rows, adjust end_row to max_rows
634        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        // Only proceed if there are rows to delete
644        if start_row <= effective_end_row && start_row < sheet.data.len() {
645            // Remove rows in reverse order to avoid index shifting issues
646            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 column is less than 1, return early with success
667        if col < 1 {
668            return Ok(());
669        }
670
671        // If column is outside the max range, return early with success
672        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    // Delete a range of columns from the current sheet
702    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 is less than 1 or start_col > end_col, return early with success
706        if start_col < 1 || start_col > end_col {
707            return Ok(());
708        }
709
710        // If the entire range is outside max_cols, return early with success
711        if start_col > sheet.max_cols {
712            return Ok(());
713        }
714
715        // If start_col is valid but end_col exceeds max_cols, adjust end_col to max_cols
716        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        // Find maximum non-empty column across all rows
797        let actual_max_col = sheet
798            .data
799            .iter()
800            .map(|row| {
801                // Find last non-empty cell in this row
802                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        // Find last row with any non-empty cells
819        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;