Skip to main content

excel_cli/excel/
workbook.rs

1use anyhow::{Context, Result};
2use calamine::{open_workbook_auto, Data, Reader, Xls, Xlsx};
3use chrono::Local;
4use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook};
5use std::collections::HashSet;
6use std::fs::File;
7use std::io::BufReader;
8use std::path::Path;
9
10use crate::excel::{Cell, CellType, DataTypeInfo, Sheet};
11
12pub enum CalamineWorkbook {
13    Xlsx(Box<Xlsx<BufReader<File>>>),
14    Xls(Xls<BufReader<File>>),
15    None,
16}
17
18impl Clone for CalamineWorkbook {
19    fn clone(&self) -> Self {
20        CalamineWorkbook::None
21    }
22}
23
24pub struct Workbook {
25    sheets: Vec<Sheet>,
26    current_sheet_index: usize,
27    file_path: String,
28    is_modified: bool,
29    calamine_workbook: CalamineWorkbook,
30    lazy_loading: bool,
31    loaded_sheets: HashSet<usize>, // Track which sheets have been loaded
32}
33
34impl Clone for Workbook {
35    fn clone(&self) -> Self {
36        Workbook {
37            sheets: self.sheets.clone(),
38            current_sheet_index: self.current_sheet_index,
39            file_path: self.file_path.clone(),
40            is_modified: self.is_modified,
41            calamine_workbook: CalamineWorkbook::None,
42            lazy_loading: false,
43            loaded_sheets: self.loaded_sheets.clone(),
44        }
45    }
46}
47
48pub fn open_workbook<P: AsRef<Path>>(path: P, enable_lazy_loading: bool) -> Result<Workbook> {
49    let path_str = path.as_ref().to_string_lossy().to_string();
50    let path_ref = path.as_ref();
51
52    // Determine if the file format supports lazy loading
53    let extension = path_ref
54        .extension()
55        .and_then(|ext| ext.to_str())
56        .map(|ext| ext.to_lowercase());
57
58    // Only enable lazy loading if both the flag is set AND the format supports it
59    let supports_lazy_loading =
60        enable_lazy_loading && matches!(extension.as_deref(), Some("xlsx" | "xlsm"));
61
62    // Open workbook directly from path
63    let mut workbook = open_workbook_auto(&path)
64        .with_context(|| format!("Unable to parse Excel file: {}", path_str))?;
65
66    let sheet_names = workbook.sheet_names().to_vec();
67
68    // Pre-allocate with the right capacity
69    let mut sheets = Vec::with_capacity(sheet_names.len());
70
71    // Store the original calamine workbook for lazy loading if enabled
72    let mut calamine_workbook = CalamineWorkbook::None;
73
74    if supports_lazy_loading {
75        // For formats that support lazy loading, keep the original workbook
76        // and only load sheet metadata
77        for name in &sheet_names {
78            // Create a minimal sheet with just the name
79            let sheet = Sheet {
80                name: name.to_string(),
81                data: vec![vec![Cell::empty(); 1]; 1],
82                max_rows: 0,
83                max_cols: 0,
84                is_loaded: false,
85            };
86
87            sheets.push(sheet);
88        }
89
90        // Try to reopen the file to get a fresh reader for lazy loading
91        if let Ok(file) = File::open(&path) {
92            let reader = BufReader::new(file);
93
94            // Try to open as XLSX first
95            if let Ok(xlsx_workbook) = Xlsx::new(reader) {
96                calamine_workbook = CalamineWorkbook::Xlsx(Box::new(xlsx_workbook));
97            } else {
98                // If not XLSX, try to open as XLS
99                if let Ok(file) = File::open(&path) {
100                    let reader = BufReader::new(file);
101                    if let Ok(xls_workbook) = Xls::new(reader) {
102                        calamine_workbook = CalamineWorkbook::Xls(xls_workbook);
103                    }
104                }
105            }
106        }
107    } else {
108        // For formats that don't support lazy loading or if lazy loading is disabled,
109        for name in &sheet_names {
110            let range = workbook
111                .worksheet_range(name)
112                .with_context(|| format!("Unable to read worksheet: {}", name))?;
113
114            let mut sheet = create_sheet_from_range(name, range);
115            sheet.is_loaded = true;
116            sheets.push(sheet);
117        }
118    }
119
120    if sheets.is_empty() {
121        anyhow::bail!("No worksheets found in file");
122    }
123
124    let mut loaded_sheets = HashSet::new();
125
126    if !supports_lazy_loading {
127        for i in 0..sheets.len() {
128            loaded_sheets.insert(i);
129        }
130    }
131
132    Ok(Workbook {
133        sheets,
134        current_sheet_index: 0,
135        file_path: path_str,
136        is_modified: false,
137        calamine_workbook,
138        lazy_loading: supports_lazy_loading,
139        loaded_sheets,
140    })
141}
142
143fn create_sheet_from_range(name: &str, range: calamine::Range<Data>) -> Sheet {
144    let (height, width) = range.get_size();
145
146    // Create a data grid with empty cells, adding 1 to dimensions for 1-based indexing
147    let mut data = vec![vec![Cell::empty(); width + 1]; height + 1];
148
149    // Process only non-empty cells
150    for (row_idx, col_idx, cell) in range.used_cells() {
151        // Extract value, cell_type, and original_type from the Data
152        let (value, cell_type, original_type) = match cell {
153            Data::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)),
154
155            Data::String(s) => {
156                let value = s.clone();
157                (value, CellType::Text, Some(DataTypeInfo::String))
158            }
159
160            Data::Float(f) => {
161                let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 {
162                    (*f as i64).to_string()
163                } else {
164                    f.to_string()
165                };
166                (value, CellType::Number, Some(DataTypeInfo::Float(*f)))
167            }
168
169            Data::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))),
170
171            Data::Bool(b) => (
172                if *b {
173                    "TRUE".to_string()
174                } else {
175                    "FALSE".to_string()
176                },
177                CellType::Boolean,
178                Some(DataTypeInfo::Bool(*b)),
179            ),
180
181            Data::Error(e) => {
182                let mut value = String::with_capacity(15);
183                value.push_str("Error: ");
184                value.push_str(&format!("{:?}", e));
185                (value, CellType::Text, Some(DataTypeInfo::Error))
186            }
187
188            Data::DateTime(dt) => (
189                dt.to_string(),
190                CellType::Date,
191                Some(DataTypeInfo::DateTime(dt.as_f64())),
192            ),
193
194            Data::DateTimeIso(s) => {
195                let value = s.clone();
196                (
197                    value.clone(),
198                    CellType::Date,
199                    Some(DataTypeInfo::DateTimeIso(value)),
200                )
201            }
202
203            Data::DurationIso(s) => {
204                let value = s.clone();
205                (
206                    value.clone(),
207                    CellType::Text,
208                    Some(DataTypeInfo::DurationIso(value)),
209                )
210            }
211        };
212
213        let is_formula = !value.is_empty() && value.starts_with('=');
214
215        // Store the cell in data grid (using 1-based indexing)
216        data[row_idx + 1][col_idx + 1] =
217            Cell::new_with_type(value, is_formula, cell_type, original_type);
218    }
219
220    Sheet {
221        name: name.to_string(),
222        data,
223        max_rows: height,
224        max_cols: width,
225        is_loaded: true,
226    }
227}
228
229impl Workbook {
230    pub fn get_current_sheet(&self) -> &Sheet {
231        &self.sheets[self.current_sheet_index]
232    }
233
234    pub fn get_current_sheet_mut(&mut self) -> &mut Sheet {
235        &mut self.sheets[self.current_sheet_index]
236    }
237
238    pub fn ensure_sheet_loaded(&mut self, sheet_index: usize, sheet_name: &str) -> Result<()> {
239        if !self.lazy_loading || self.sheets[sheet_index].is_loaded {
240            return Ok(());
241        }
242
243        // Load the sheet data from the calamine workbook
244        match &mut self.calamine_workbook {
245            CalamineWorkbook::Xlsx(xlsx) => {
246                if let Ok(range) = xlsx.worksheet_range(sheet_name) {
247                    // Replace the placeholder sheet with a fully loaded one
248                    let mut sheet = create_sheet_from_range(sheet_name, range);
249
250                    // Preserve the original name in case it was customized
251                    let original_name = self.sheets[sheet_index].name.clone();
252                    sheet.name = original_name;
253
254                    self.sheets[sheet_index] = sheet;
255
256                    // Mark the sheet as loaded
257                    self.loaded_sheets.insert(sheet_index);
258                }
259            }
260            CalamineWorkbook::Xls(xls) => {
261                if let Ok(range) = xls.worksheet_range(sheet_name) {
262                    // Replace the placeholder sheet with a fully loaded one
263                    let mut sheet = create_sheet_from_range(sheet_name, range);
264
265                    // Preserve the original name in case it was customized
266                    let original_name = self.sheets[sheet_index].name.clone();
267                    sheet.name = original_name;
268
269                    self.sheets[sheet_index] = sheet;
270
271                    // Mark the sheet as loaded
272                    self.loaded_sheets.insert(sheet_index);
273                }
274            }
275            CalamineWorkbook::None => {
276                return Err(anyhow::anyhow!("Cannot load sheet: no workbook available"));
277            }
278        }
279
280        Ok(())
281    }
282
283    pub fn get_sheet_by_index(&self, index: usize) -> Option<&Sheet> {
284        self.sheets.get(index)
285    }
286
287    pub fn ensure_cell_exists(&mut self, row: usize, col: usize) {
288        let sheet = &mut self.sheets[self.current_sheet_index];
289
290        // Expand rows if needed
291        if row >= sheet.data.len() {
292            let default_row_len = if sheet.data.is_empty() {
293                col + 1
294            } else {
295                sheet.data[0].len()
296            };
297            let rows_to_add = row + 1 - sheet.data.len();
298
299            sheet
300                .data
301                .extend(vec![vec![Cell::empty(); default_row_len]; rows_to_add]);
302            sheet.max_rows = sheet.max_rows.max(row);
303        }
304
305        // Expand columns if needed
306        if col >= sheet.data[0].len() {
307            for row_data in &mut sheet.data {
308                row_data.resize_with(col + 1, Cell::empty);
309            }
310
311            sheet.max_cols = sheet.max_cols.max(col);
312        }
313    }
314
315    pub fn set_cell_value(&mut self, row: usize, col: usize, value: String) -> Result<()> {
316        self.ensure_cell_exists(row, col);
317
318        let sheet = &mut self.sheets[self.current_sheet_index];
319        let current_value = &sheet.data[row][col].value;
320
321        // Only set modified flag if value actually changes
322        if current_value != &value {
323            let is_formula = value.starts_with('=');
324            sheet.data[row][col] = Cell::new(value, is_formula);
325
326            // Update max_cols if needed
327            if col > sheet.max_cols && !sheet.data[row][col].value.is_empty() {
328                sheet.max_cols = col;
329            }
330
331            self.is_modified = true;
332        }
333
334        Ok(())
335    }
336
337    pub fn get_sheet_names(&self) -> Vec<String> {
338        let mut names = Vec::with_capacity(self.sheets.len());
339        for sheet in &self.sheets {
340            names.push(sheet.name.clone());
341        }
342        names
343    }
344
345    pub fn get_current_sheet_name(&self) -> String {
346        self.sheets[self.current_sheet_index].name.clone()
347    }
348
349    pub fn get_current_sheet_index(&self) -> usize {
350        self.current_sheet_index
351    }
352
353    pub fn switch_sheet(&mut self, index: usize) -> Result<()> {
354        if index >= self.sheets.len() {
355            anyhow::bail!("Sheet index out of range");
356        }
357
358        self.current_sheet_index = index;
359        Ok(())
360    }
361
362    pub fn add_sheet(&mut self, name: &str, index: usize) -> Result<String> {
363        let sheet_name = name.trim();
364
365        self.validate_sheet_name(sheet_name)?;
366        self.insert_sheet_at_index(Sheet::blank(sheet_name.to_string()), index)?;
367
368        Ok(sheet_name.to_string())
369    }
370
371    pub fn delete_current_sheet(&mut self) -> Result<()> {
372        self.delete_sheet_at_index(self.current_sheet_index)
373    }
374
375    pub fn delete_sheet_at_index(&mut self, index: usize) -> Result<()> {
376        // Prevent deleting the last sheet
377        if self.sheets.len() <= 1 {
378            anyhow::bail!("Cannot delete the last sheet");
379        }
380
381        if index >= self.sheets.len() {
382            anyhow::bail!("Sheet index out of range");
383        }
384
385        self.sheets.remove(index);
386        self.is_modified = true;
387
388        if index < self.current_sheet_index {
389            self.current_sheet_index = self.current_sheet_index.saturating_sub(1);
390        } else if self.current_sheet_index >= self.sheets.len() {
391            self.current_sheet_index = self.sheets.len() - 1;
392        }
393
394        Ok(())
395    }
396
397    pub fn delete_row(&mut self, row: usize) -> Result<()> {
398        let sheet = &mut self.sheets[self.current_sheet_index];
399
400        // If row is less than 1, return early with success
401        if row < 1 {
402            return Ok(());
403        }
404
405        // If row is outside the max range, return early with success
406        if row > sheet.max_rows {
407            return Ok(());
408        }
409
410        // Only remove the row if it exists in the data
411        if row < sheet.data.len() {
412            sheet.data.remove(row);
413            self.recalculate_max_cols();
414            self.is_modified = true;
415        }
416
417        Ok(())
418    }
419
420    // Delete a range of rows from the current sheet
421    pub fn delete_rows(&mut self, start_row: usize, end_row: usize) -> Result<()> {
422        let sheet = &mut self.sheets[self.current_sheet_index];
423
424        // If start_row is less than 1 or start_row > end_row, return early with success
425        if start_row < 1 || start_row > end_row {
426            return Ok(());
427        }
428
429        // If the entire range is outside max_rows, return early with success
430        if start_row > sheet.max_rows {
431            return Ok(());
432        }
433
434        // Adjust end_row to not exceed the data length
435        let adjusted_end_row = end_row.min(sheet.data.len() - 1);
436
437        // If start_row is valid but end_row exceeds max_rows, adjust end_row to max_rows
438        let effective_end_row = if end_row > sheet.max_rows {
439            sheet.max_rows
440        } else {
441            adjusted_end_row
442        };
443
444        // Only proceed if there are rows to delete
445        if start_row <= effective_end_row && start_row < sheet.data.len() {
446            // Remove rows in reverse order to avoid index shifting issues
447            for row in (start_row..=effective_end_row).rev() {
448                if row < sheet.data.len() {
449                    sheet.data.remove(row);
450                }
451            }
452
453            self.recalculate_max_cols();
454            self.is_modified = true;
455        }
456
457        Ok(())
458    }
459
460    pub fn delete_column(&mut self, col: usize) -> Result<()> {
461        let sheet = &mut self.sheets[self.current_sheet_index];
462
463        // If column is less than 1, return early with success
464        if col < 1 {
465            return Ok(());
466        }
467
468        // If column is outside the max range, return early with success
469        if col > sheet.max_cols {
470            return Ok(());
471        }
472
473        let mut has_data = false;
474        for row in &sheet.data {
475            if col < row.len() && !row[col].value.is_empty() {
476                has_data = true;
477                break;
478            }
479        }
480
481        for row in sheet.data.iter_mut() {
482            if col < row.len() {
483                row.remove(col);
484            }
485        }
486
487        self.recalculate_max_cols();
488        self.recalculate_max_rows();
489
490        if has_data {
491            self.is_modified = true;
492        }
493
494        Ok(())
495    }
496
497    // Delete a range of columns from the current sheet
498    pub fn delete_columns(&mut self, start_col: usize, end_col: usize) -> Result<()> {
499        let sheet = &mut self.sheets[self.current_sheet_index];
500
501        // If start_col is less than 1 or start_col > end_col, return early with success
502        if start_col < 1 || start_col > end_col {
503            return Ok(());
504        }
505
506        // If the entire range is outside max_cols, return early with success
507        if start_col > sheet.max_cols {
508            return Ok(());
509        }
510
511        // If start_col is valid but end_col exceeds max_cols, adjust end_col to max_cols
512        let effective_end_col = end_col.min(sheet.max_cols);
513
514        let mut has_data = false;
515        for row in &sheet.data {
516            for col in start_col..=effective_end_col {
517                if col < row.len() && !row[col].value.is_empty() {
518                    has_data = true;
519                    break;
520                }
521            }
522            if has_data {
523                break;
524            }
525        }
526
527        for row in sheet.data.iter_mut() {
528            for col in (start_col..=effective_end_col).rev() {
529                if col < row.len() {
530                    row.remove(col);
531                }
532            }
533        }
534
535        self.recalculate_max_cols();
536        self.recalculate_max_rows();
537
538        if has_data {
539            self.is_modified = true;
540        }
541
542        Ok(())
543    }
544
545    pub fn is_modified(&self) -> bool {
546        self.is_modified
547    }
548
549    pub fn set_modified(&mut self, modified: bool) {
550        self.is_modified = modified;
551    }
552
553    pub fn get_file_path(&self) -> &str {
554        &self.file_path
555    }
556
557    pub fn is_lazy_loading(&self) -> bool {
558        self.lazy_loading
559    }
560
561    pub fn is_sheet_loaded(&self, sheet_index: usize) -> bool {
562        if !self.lazy_loading || sheet_index >= self.sheets.len() {
563            return true;
564        }
565
566        self.sheets[sheet_index].is_loaded
567    }
568
569    pub fn save(&mut self) -> Result<()> {
570        if !self.is_modified {
571            println!("No changes to save.");
572            return Ok(());
573        }
574
575        self.ensure_all_sheets_loaded()?;
576
577        // Create a new workbook with rust_xlsxwriter
578        let mut workbook = XlsxWorkbook::new();
579
580        let now = Local::now();
581        let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
582        let path = Path::new(&self.file_path);
583        let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet");
584        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("xlsx");
585        let parent_dir = path.parent().unwrap_or_else(|| Path::new(""));
586        let new_filename = format!("{}_{}.{}", file_stem, timestamp, extension);
587        let new_filepath = parent_dir.join(new_filename);
588
589        // Create formats
590        let number_format = Format::new().set_num_format("General");
591        let date_format = Format::new().set_num_format("yyyy-mm-dd");
592
593        // Process each sheet
594        for sheet in &self.sheets {
595            let worksheet = workbook.add_worksheet().set_name(&sheet.name)?;
596
597            // Set column widths
598            for col in 0..sheet.max_cols {
599                worksheet.set_column_width(col as u16, 15)?;
600            }
601
602            // Write cell data
603            for row in 1..sheet.data.len() {
604                if row <= sheet.max_rows {
605                    for col in 1..sheet.data[0].len() {
606                        if col <= sheet.max_cols {
607                            let cell = &sheet.data[row][col];
608
609                            // Skip empty cells
610                            if cell.value.is_empty() {
611                                continue;
612                            }
613
614                            let row_idx = (row - 1) as u32;
615                            let col_idx = (col - 1) as u16;
616
617                            // Write cell based on its type
618                            match cell.cell_type {
619                                CellType::Number => {
620                                    if let Ok(num) = cell.value.parse::<f64>() {
621                                        worksheet.write_number_with_format(
622                                            row_idx,
623                                            col_idx,
624                                            num,
625                                            &number_format,
626                                        )?;
627                                    } else {
628                                        worksheet.write_string(row_idx, col_idx, &cell.value)?;
629                                    }
630                                }
631                                CellType::Date => {
632                                    worksheet.write_string_with_format(
633                                        row_idx,
634                                        col_idx,
635                                        &cell.value,
636                                        &date_format,
637                                    )?;
638                                }
639                                CellType::Boolean => {
640                                    if let Ok(b) = cell.value.parse::<bool>() {
641                                        worksheet.write_boolean(row_idx, col_idx, b)?;
642                                    } else {
643                                        worksheet.write_string(row_idx, col_idx, &cell.value)?;
644                                    }
645                                }
646                                CellType::Text => {
647                                    if cell.is_formula {
648                                        let formula = rust_xlsxwriter::Formula::new(&cell.value);
649                                        worksheet.write_formula(row_idx, col_idx, formula)?;
650                                    } else {
651                                        worksheet.write_string(row_idx, col_idx, &cell.value)?;
652                                    }
653                                }
654                                CellType::Empty => {}
655                            }
656                        }
657                    }
658                }
659            }
660        }
661
662        workbook.save(&new_filepath)?;
663        self.is_modified = false;
664
665        Ok(())
666    }
667
668    pub fn insert_sheet_at_index(&mut self, sheet: Sheet, index: usize) -> Result<()> {
669        if index > self.sheets.len() {
670            anyhow::bail!(
671                "Cannot insert sheet at index {}: index out of bounds (max index: {})",
672                index,
673                self.sheets.len()
674            );
675        }
676
677        if index <= self.current_sheet_index {
678            self.current_sheet_index += 1;
679        }
680
681        self.sheets.insert(index, sheet);
682        self.is_modified = true;
683        Ok(())
684    }
685
686    pub fn recalculate_max_cols(&mut self) {
687        let sheet = &mut self.sheets[self.current_sheet_index];
688
689        // Find maximum non-empty column across all rows
690        let actual_max_col = sheet
691            .data
692            .iter()
693            .map(|row| {
694                // Find last non-empty cell in this row
695                row.iter()
696                    .enumerate()
697                    .rev()
698                    .find(|(_, cell)| !cell.value.is_empty())
699                    .map(|(idx, _)| idx)
700                    .unwrap_or(0)
701            })
702            .max()
703            .unwrap_or(0);
704
705        sheet.max_cols = actual_max_col.max(1);
706    }
707
708    pub fn recalculate_max_rows(&mut self) {
709        let sheet = &mut self.sheets[self.current_sheet_index];
710
711        // Find last row with any non-empty cells
712        let actual_max_row = sheet
713            .data
714            .iter()
715            .enumerate()
716            .rev()
717            .find(|(_, row)| row.iter().any(|cell| !cell.value.is_empty()))
718            .map(|(idx, _)| idx)
719            .unwrap_or(0);
720
721        sheet.max_rows = actual_max_row.max(1);
722    }
723
724    fn ensure_all_sheets_loaded(&mut self) -> Result<()> {
725        if !self.lazy_loading {
726            return Ok(());
727        }
728
729        let pending_sheets: Vec<(usize, String)> = self
730            .sheets
731            .iter()
732            .enumerate()
733            .filter(|(_, sheet)| !sheet.is_loaded)
734            .map(|(index, sheet)| (index, sheet.name.clone()))
735            .collect();
736
737        for (index, name) in pending_sheets {
738            self.ensure_sheet_loaded(index, &name)?;
739        }
740
741        Ok(())
742    }
743
744    fn validate_sheet_name(&self, name: &str) -> Result<()> {
745        if name.is_empty() {
746            anyhow::bail!("Sheet name cannot be empty");
747        }
748
749        if name.chars().count() > 31 {
750            anyhow::bail!("Sheet name cannot exceed 31 characters");
751        }
752
753        if name.starts_with('\'') || name.ends_with('\'') {
754            anyhow::bail!("Sheet name cannot start or end with apostrophes");
755        }
756
757        if name
758            .chars()
759            .any(|c| matches!(c, '[' | ']' | ':' | '*' | '?' | '/' | '\\'))
760        {
761            anyhow::bail!("Sheet name cannot contain any of these characters: [ ] : * ? / \\");
762        }
763
764        if self
765            .sheets
766            .iter()
767            .any(|sheet| sheet.name.eq_ignore_ascii_case(name))
768        {
769            anyhow::bail!("Sheet '{}' already exists", name);
770        }
771
772        Ok(())
773    }
774
775    #[cfg(test)]
776    pub(crate) fn from_sheets_for_test(sheets: Vec<Sheet>) -> Self {
777        let loaded_sheets = (0..sheets.len()).collect();
778
779        Self {
780            sheets,
781            current_sheet_index: 0,
782            file_path: "test.xlsx".to_string(),
783            is_modified: false,
784            calamine_workbook: CalamineWorkbook::None,
785            lazy_loading: false,
786            loaded_sheets,
787        }
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::Workbook;
794    use crate::excel::Sheet;
795
796    fn blank_sheet(name: &str) -> Sheet {
797        Sheet::blank(name.to_string())
798    }
799
800    #[test]
801    fn adds_blank_sheet_after_current_sheet() {
802        let mut workbook =
803            Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1"), blank_sheet("Sheet2")]);
804
805        let sheet_name = workbook.add_sheet("Added", 1).unwrap();
806
807        assert_eq!(sheet_name, "Added");
808        assert_eq!(
809            workbook.get_sheet_names(),
810            vec!["Sheet1", "Added", "Sheet2"]
811        );
812
813        let added_sheet = workbook.get_sheet_by_index(1).unwrap();
814        assert_eq!(added_sheet.name, "Added");
815        assert_eq!(added_sheet.max_rows, 1);
816        assert_eq!(added_sheet.max_cols, 1);
817        assert!(added_sheet.is_loaded);
818        assert_eq!(added_sheet.data.len(), 2);
819        assert_eq!(added_sheet.data[1].len(), 2);
820    }
821
822    #[test]
823    fn rejects_duplicate_sheet_names_case_insensitively() {
824        let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Summary")]);
825
826        let error = workbook.add_sheet("summary", 1).unwrap_err().to_string();
827
828        assert!(error.contains("already exists"));
829    }
830
831    #[test]
832    fn rejects_invalid_sheet_names() {
833        let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]);
834
835        assert!(workbook.add_sheet("", 1).is_err());
836        assert!(workbook.add_sheet("Bad/Name", 1).is_err());
837        assert!(workbook.add_sheet("'quoted", 1).is_err());
838        assert!(workbook
839            .add_sheet("this-sheet-name-is-definitely-too-long", 1)
840            .is_err());
841    }
842
843    #[test]
844    fn counts_sheet_name_length_by_characters() {
845        let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]);
846        let valid_name = "表".repeat(31);
847        let invalid_name = "表".repeat(32);
848
849        assert!(workbook.add_sheet(&valid_name, 1).is_ok());
850        assert!(workbook.add_sheet(&invalid_name, 2).is_err());
851    }
852}