Skip to main content

sheetkit_core/
sheet.rs

1//! Sheet management utilities.
2//!
3//! Contains validation helpers and internal functions used by [`crate::workbook::Workbook`]
4//! for creating, deleting, renaming, and copying worksheets.
5
6use sheetkit_xml::content_types::{mime_types, ContentTypeOverride, ContentTypes};
7use sheetkit_xml::relationships::{rel_types, Relationship, Relationships};
8use sheetkit_xml::workbook::{SheetEntry, WorkbookXml};
9use sheetkit_xml::worksheet::{
10    Pane, Selection, SheetFormatPr, SheetPr, SheetProtection, SheetView, SheetViews, TabColor,
11    WorksheetXml,
12};
13
14use crate::error::{Error, Result};
15use crate::protection::legacy_password_hash;
16use crate::utils::cell_ref::cell_name_to_coordinates;
17use crate::utils::constants::{
18    DEFAULT_ROW_HEIGHT, MAX_COLUMN_WIDTH, MAX_ROW_HEIGHT, MAX_SHEET_NAME_LENGTH,
19    SHEET_NAME_INVALID_CHARS,
20};
21
22/// Validate a sheet name according to Excel rules.
23///
24/// A valid sheet name must:
25/// - Be non-empty
26/// - Be at most [`MAX_SHEET_NAME_LENGTH`] (31) characters
27/// - Not contain any of the characters `: \ / ? * [ ]`
28/// - Not start or end with a single quote (`'`)
29pub fn validate_sheet_name(name: &str) -> Result<()> {
30    if name.is_empty() {
31        return Err(Error::InvalidSheetName("sheet name cannot be empty".into()));
32    }
33    if name.len() > MAX_SHEET_NAME_LENGTH {
34        return Err(Error::InvalidSheetName(format!(
35            "sheet name '{}' exceeds {} characters",
36            name, MAX_SHEET_NAME_LENGTH
37        )));
38    }
39    for ch in SHEET_NAME_INVALID_CHARS {
40        if name.contains(*ch) {
41            return Err(Error::InvalidSheetName(format!(
42                "sheet name '{}' contains invalid character '{}'",
43                name, ch
44            )));
45        }
46    }
47    if name.starts_with('\'') || name.ends_with('\'') {
48        return Err(Error::InvalidSheetName(format!(
49            "sheet name '{}' cannot start or end with a single quote",
50            name
51        )));
52    }
53    Ok(())
54}
55
56/// Generate the next available rId for workbook relationships.
57///
58/// Scans existing relationship IDs of the form `rIdN` and returns `rId{max+1}`.
59pub fn next_rid(existing_rels: &[Relationship]) -> String {
60    let max = existing_rels
61        .iter()
62        .filter_map(|r| r.id.strip_prefix("rId").and_then(|n| n.parse::<u32>().ok()))
63        .max()
64        .unwrap_or(0);
65    format!("rId{}", max + 1)
66}
67
68/// Generate the next available sheet ID.
69///
70/// Sheet IDs in a workbook are unique but not necessarily contiguous. This
71/// function returns one greater than the current maximum.
72pub fn next_sheet_id(existing_sheets: &[SheetEntry]) -> u32 {
73    existing_sheets
74        .iter()
75        .map(|s| s.sheet_id)
76        .max()
77        .unwrap_or(0)
78        + 1
79}
80
81/// Find the index (0-based) of a sheet by name.
82pub fn find_sheet_index(worksheets: &[(String, WorksheetXml)], name: &str) -> Option<usize> {
83    worksheets.iter().position(|(n, _)| n == name)
84}
85
86/// Add a new sheet. Returns the 0-based index of the new sheet.
87///
88/// This function performs all bookkeeping: adds entries to the sheet list,
89/// workbook relationships, and content type overrides.
90pub fn add_sheet(
91    workbook_xml: &mut WorkbookXml,
92    workbook_rels: &mut Relationships,
93    content_types: &mut ContentTypes,
94    worksheets: &mut Vec<(String, WorksheetXml)>,
95    name: &str,
96    worksheet_data: WorksheetXml,
97) -> Result<usize> {
98    validate_sheet_name(name)?;
99
100    if worksheets.iter().any(|(n, _)| n == name) {
101        return Err(Error::SheetAlreadyExists {
102            name: name.to_string(),
103        });
104    }
105
106    let rid = next_rid(&workbook_rels.relationships);
107    let sheet_id = next_sheet_id(&workbook_xml.sheets.sheets);
108    let sheet_number = worksheets.len() + 1;
109    let target = format!("worksheets/sheet{}.xml", sheet_number);
110
111    workbook_xml.sheets.sheets.push(SheetEntry {
112        name: name.to_string(),
113        sheet_id,
114        state: None,
115        r_id: rid.clone(),
116    });
117
118    workbook_rels.relationships.push(Relationship {
119        id: rid,
120        rel_type: rel_types::WORKSHEET.to_string(),
121        target: target.clone(),
122        target_mode: None,
123    });
124
125    content_types.overrides.push(ContentTypeOverride {
126        part_name: format!("/xl/{}", target),
127        content_type: mime_types::WORKSHEET.to_string(),
128    });
129
130    worksheets.push((name.to_string(), worksheet_data));
131
132    Ok(worksheets.len() - 1)
133}
134
135/// Delete a sheet by name.
136///
137/// Returns an error if the sheet does not exist or if it is the last remaining sheet.
138pub fn delete_sheet(
139    workbook_xml: &mut WorkbookXml,
140    workbook_rels: &mut Relationships,
141    content_types: &mut ContentTypes,
142    worksheets: &mut Vec<(String, WorksheetXml)>,
143    name: &str,
144) -> Result<()> {
145    let idx = find_sheet_index(worksheets, name).ok_or_else(|| Error::SheetNotFound {
146        name: name.to_string(),
147    })?;
148
149    if worksheets.len() <= 1 {
150        return Err(Error::InvalidSheetName(
151            "cannot delete the last sheet in a workbook".into(),
152        ));
153    }
154
155    let r_id = workbook_xml.sheets.sheets[idx].r_id.clone();
156
157    worksheets.remove(idx);
158    workbook_xml.sheets.sheets.remove(idx);
159    workbook_rels.relationships.retain(|r| r.id != r_id);
160
161    rebuild_content_type_overrides(content_types, worksheets.len());
162    rebuild_worksheet_relationships(workbook_xml, workbook_rels);
163
164    Ok(())
165}
166
167/// Rename a sheet.
168pub fn rename_sheet(
169    workbook_xml: &mut WorkbookXml,
170    worksheets: &mut [(String, WorksheetXml)],
171    old_name: &str,
172    new_name: &str,
173) -> Result<()> {
174    validate_sheet_name(new_name)?;
175
176    let idx = find_sheet_index(worksheets, old_name).ok_or_else(|| Error::SheetNotFound {
177        name: old_name.to_string(),
178    })?;
179
180    if worksheets.iter().any(|(n, _)| n == new_name) {
181        return Err(Error::SheetAlreadyExists {
182            name: new_name.to_string(),
183        });
184    }
185
186    worksheets[idx].0 = new_name.to_string();
187    workbook_xml.sheets.sheets[idx].name = new_name.to_string();
188
189    Ok(())
190}
191
192/// Copy a sheet, returning the 0-based index of the new copy.
193pub fn copy_sheet(
194    workbook_xml: &mut WorkbookXml,
195    workbook_rels: &mut Relationships,
196    content_types: &mut ContentTypes,
197    worksheets: &mut Vec<(String, WorksheetXml)>,
198    source_name: &str,
199    target_name: &str,
200) -> Result<usize> {
201    let source_idx =
202        find_sheet_index(worksheets, source_name).ok_or_else(|| Error::SheetNotFound {
203            name: source_name.to_string(),
204        })?;
205
206    let cloned_data = worksheets[source_idx].1.clone();
207
208    add_sheet(
209        workbook_xml,
210        workbook_rels,
211        content_types,
212        worksheets,
213        target_name,
214        cloned_data,
215    )
216}
217
218/// Get the active sheet index (0-based) from bookViews, defaulting to 0.
219pub fn active_sheet_index(workbook_xml: &WorkbookXml) -> usize {
220    workbook_xml
221        .book_views
222        .as_ref()
223        .and_then(|bv| bv.workbook_views.first())
224        .and_then(|v| v.active_tab)
225        .unwrap_or(0) as usize
226}
227
228/// Set the active sheet by index in bookViews.
229pub fn set_active_sheet_index(workbook_xml: &mut WorkbookXml, index: u32) {
230    use sheetkit_xml::workbook::{BookViews, WorkbookView};
231
232    let book_views = workbook_xml.book_views.get_or_insert_with(|| BookViews {
233        workbook_views: vec![WorkbookView {
234            x_window: None,
235            y_window: None,
236            window_width: None,
237            window_height: None,
238            active_tab: Some(0),
239        }],
240    });
241
242    if let Some(view) = book_views.workbook_views.first_mut() {
243        view.active_tab = Some(index);
244    }
245}
246
247/// Configuration for sheet protection.
248///
249/// All boolean fields default to `false`, meaning the corresponding action is
250/// forbidden when protection is enabled. Set a field to `true` to allow that
251/// action even when the sheet is protected.
252#[derive(Debug, Clone, Default)]
253pub struct SheetProtectionConfig {
254    /// Optional password. Hashed with the legacy Excel algorithm.
255    pub password: Option<String>,
256    /// Allow selecting locked cells.
257    pub select_locked_cells: bool,
258    /// Allow selecting unlocked cells.
259    pub select_unlocked_cells: bool,
260    /// Allow formatting cells.
261    pub format_cells: bool,
262    /// Allow formatting columns.
263    pub format_columns: bool,
264    /// Allow formatting rows.
265    pub format_rows: bool,
266    /// Allow inserting columns.
267    pub insert_columns: bool,
268    /// Allow inserting rows.
269    pub insert_rows: bool,
270    /// Allow inserting hyperlinks.
271    pub insert_hyperlinks: bool,
272    /// Allow deleting columns.
273    pub delete_columns: bool,
274    /// Allow deleting rows.
275    pub delete_rows: bool,
276    /// Allow sorting.
277    pub sort: bool,
278    /// Allow using auto-filter.
279    pub auto_filter: bool,
280    /// Allow using pivot tables.
281    pub pivot_tables: bool,
282}
283
284/// Protect a sheet with optional password and permission settings.
285///
286/// When a sheet is protected, users cannot edit cells unless specific
287/// permissions are granted via the config. The password is hashed using
288/// the legacy Excel algorithm.
289pub fn protect_sheet(ws: &mut WorksheetXml, config: &SheetProtectionConfig) -> Result<()> {
290    let hashed = config.password.as_ref().map(|p| {
291        let h = legacy_password_hash(p);
292        format!("{:04X}", h)
293    });
294
295    let to_opt = |v: bool| if v { Some(true) } else { None };
296
297    ws.sheet_protection = Some(SheetProtection {
298        password: hashed,
299        sheet: Some(true),
300        objects: Some(true),
301        scenarios: Some(true),
302        select_locked_cells: to_opt(config.select_locked_cells),
303        select_unlocked_cells: to_opt(config.select_unlocked_cells),
304        format_cells: to_opt(config.format_cells),
305        format_columns: to_opt(config.format_columns),
306        format_rows: to_opt(config.format_rows),
307        insert_columns: to_opt(config.insert_columns),
308        insert_rows: to_opt(config.insert_rows),
309        insert_hyperlinks: to_opt(config.insert_hyperlinks),
310        delete_columns: to_opt(config.delete_columns),
311        delete_rows: to_opt(config.delete_rows),
312        sort: to_opt(config.sort),
313        auto_filter: to_opt(config.auto_filter),
314        pivot_tables: to_opt(config.pivot_tables),
315    });
316
317    Ok(())
318}
319
320/// Remove sheet protection.
321pub fn unprotect_sheet(ws: &mut WorksheetXml) -> Result<()> {
322    ws.sheet_protection = None;
323    Ok(())
324}
325
326/// Check if a sheet is protected.
327pub fn is_sheet_protected(ws: &WorksheetXml) -> bool {
328    ws.sheet_protection
329        .as_ref()
330        .and_then(|p| p.sheet)
331        .unwrap_or(false)
332}
333
334/// Set the tab color of a sheet using an RGB hex string (e.g. "FF0000" for red).
335pub fn set_tab_color(ws: &mut WorksheetXml, rgb: &str) -> Result<()> {
336    let sheet_pr = ws.sheet_pr.get_or_insert_with(SheetPr::default);
337    sheet_pr.tab_color = Some(TabColor {
338        rgb: Some(rgb.to_string()),
339        theme: None,
340        indexed: None,
341    });
342    Ok(())
343}
344
345/// Get the tab color of a sheet as an RGB hex string.
346pub fn get_tab_color(ws: &WorksheetXml) -> Option<String> {
347    ws.sheet_pr
348        .as_ref()
349        .and_then(|pr| pr.tab_color.as_ref())
350        .and_then(|tc| tc.rgb.clone())
351}
352
353/// Set the default row height for a sheet.
354///
355/// Returns an error if the height exceeds [`MAX_ROW_HEIGHT`] (409).
356pub fn set_default_row_height(ws: &mut WorksheetXml, height: f64) -> Result<()> {
357    if height > MAX_ROW_HEIGHT {
358        return Err(Error::RowHeightExceeded {
359            height,
360            max: MAX_ROW_HEIGHT,
361        });
362    }
363    let fmt = ws.sheet_format_pr.get_or_insert(SheetFormatPr {
364        default_row_height: DEFAULT_ROW_HEIGHT,
365        default_col_width: None,
366        custom_height: None,
367        outline_level_row: None,
368        outline_level_col: None,
369    });
370    fmt.default_row_height = height;
371    Ok(())
372}
373
374/// Get the default row height for a sheet.
375///
376/// Returns [`DEFAULT_ROW_HEIGHT`] (15.0) if no sheet format properties are set.
377pub fn get_default_row_height(ws: &WorksheetXml) -> f64 {
378    ws.sheet_format_pr
379        .as_ref()
380        .map(|f| f.default_row_height)
381        .unwrap_or(DEFAULT_ROW_HEIGHT)
382}
383
384/// Set the default column width for a sheet.
385///
386/// Returns an error if the width exceeds [`MAX_COLUMN_WIDTH`] (255).
387pub fn set_default_col_width(ws: &mut WorksheetXml, width: f64) -> Result<()> {
388    if width > MAX_COLUMN_WIDTH {
389        return Err(Error::ColumnWidthExceeded {
390            width,
391            max: MAX_COLUMN_WIDTH,
392        });
393    }
394    let fmt = ws.sheet_format_pr.get_or_insert(SheetFormatPr {
395        default_row_height: DEFAULT_ROW_HEIGHT,
396        default_col_width: None,
397        custom_height: None,
398        outline_level_row: None,
399        outline_level_col: None,
400    });
401    fmt.default_col_width = Some(width);
402    Ok(())
403}
404
405/// Get the default column width for a sheet.
406///
407/// Returns `None` if no default column width has been set.
408pub fn get_default_col_width(ws: &WorksheetXml) -> Option<f64> {
409    ws.sheet_format_pr
410        .as_ref()
411        .and_then(|f| f.default_col_width)
412}
413
414/// Set freeze panes on a worksheet.
415///
416/// The cell reference indicates the top-left cell of the scrollable (unfrozen) area.
417/// For example, `"A2"` freezes row 1, `"B1"` freezes column A, and `"B2"` freezes
418/// both row 1 and column A.
419///
420/// Returns an error if the cell reference is invalid or is `"A1"` (which would
421/// freeze nothing).
422pub fn set_panes(ws: &mut WorksheetXml, cell: &str) -> Result<()> {
423    let (col, row) = cell_name_to_coordinates(cell)?;
424
425    if col == 1 && row == 1 {
426        return Err(Error::InvalidCellReference(
427            "freeze pane at A1 has no effect".to_string(),
428        ));
429    }
430
431    let x_split = col - 1;
432    let y_split = row - 1;
433
434    let active_pane = match (x_split > 0, y_split > 0) {
435        (true, true) => "bottomRight",
436        (true, false) => "topRight",
437        (false, true) => "bottomLeft",
438        (false, false) => unreachable!(),
439    };
440
441    let pane = Pane {
442        x_split: if x_split > 0 { Some(x_split) } else { None },
443        y_split: if y_split > 0 { Some(y_split) } else { None },
444        top_left_cell: Some(cell.to_string()),
445        active_pane: Some(active_pane.to_string()),
446        state: Some("frozen".to_string()),
447    };
448
449    let selection = Selection {
450        pane: Some(active_pane.to_string()),
451        active_cell: Some(cell.to_string()),
452        sqref: Some(cell.to_string()),
453    };
454
455    let sheet_views = ws.sheet_views.get_or_insert_with(|| SheetViews {
456        sheet_views: vec![SheetView {
457            tab_selected: None,
458            zoom_scale: None,
459            workbook_view_id: 0,
460            pane: None,
461            selection: vec![],
462        }],
463    });
464
465    if let Some(view) = sheet_views.sheet_views.first_mut() {
466        view.pane = Some(pane);
467        view.selection = vec![selection];
468    }
469
470    Ok(())
471}
472
473/// Remove any freeze or split panes from a worksheet.
474pub fn unset_panes(ws: &mut WorksheetXml) {
475    if let Some(ref mut sheet_views) = ws.sheet_views {
476        for view in &mut sheet_views.sheet_views {
477            view.pane = None;
478            // Reset selection to default (no pane attribute).
479            view.selection = vec![];
480        }
481    }
482}
483
484/// Get the current freeze pane cell reference, if any.
485///
486/// Returns the top-left cell of the unfrozen area (e.g., `"A2"` if row 1 is
487/// frozen), or `None` if no panes are configured.
488pub fn get_panes(ws: &WorksheetXml) -> Option<String> {
489    ws.sheet_views
490        .as_ref()
491        .and_then(|sv| sv.sheet_views.first())
492        .and_then(|view| view.pane.as_ref())
493        .and_then(|pane| pane.top_left_cell.clone())
494}
495
496/// Rebuild content type overrides for worksheets so they match the current
497/// worksheet indices (sheet1.xml, sheet2.xml, ...).
498fn rebuild_content_type_overrides(content_types: &mut ContentTypes, sheet_count: usize) {
499    content_types
500        .overrides
501        .retain(|o| o.content_type != mime_types::WORKSHEET);
502
503    for i in 1..=sheet_count {
504        content_types.overrides.push(ContentTypeOverride {
505            part_name: format!("/xl/worksheets/sheet{}.xml", i),
506            content_type: mime_types::WORKSHEET.to_string(),
507        });
508    }
509}
510
511/// Rebuild worksheet relationship targets so they match the current worksheet indices.
512fn rebuild_worksheet_relationships(
513    workbook_xml: &mut WorkbookXml,
514    workbook_rels: &mut Relationships,
515) {
516    let sheet_rids: Vec<String> = workbook_xml
517        .sheets
518        .sheets
519        .iter()
520        .map(|s| s.r_id.clone())
521        .collect();
522
523    for (i, rid) in sheet_rids.iter().enumerate() {
524        if let Some(rel) = workbook_rels
525            .relationships
526            .iter_mut()
527            .find(|r| r.id == *rid)
528        {
529            rel.target = format!("worksheets/sheet{}.xml", i + 1);
530        }
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use sheetkit_xml::content_types::ContentTypes;
538    use sheetkit_xml::relationships;
539    use sheetkit_xml::workbook::WorkbookXml;
540    use sheetkit_xml::worksheet::WorksheetXml;
541
542    // -- Sheet protection tests --
543
544    #[test]
545    fn test_protect_sheet_no_password() {
546        let mut ws = WorksheetXml::default();
547        let config = SheetProtectionConfig::default();
548        protect_sheet(&mut ws, &config).unwrap();
549
550        assert!(ws.sheet_protection.is_some());
551        let prot = ws.sheet_protection.as_ref().unwrap();
552        assert_eq!(prot.sheet, Some(true));
553        assert_eq!(prot.objects, Some(true));
554        assert_eq!(prot.scenarios, Some(true));
555        assert!(prot.password.is_none());
556    }
557
558    #[test]
559    fn test_protect_sheet_with_password() {
560        let mut ws = WorksheetXml::default();
561        let config = SheetProtectionConfig {
562            password: Some("secret".to_string()),
563            ..SheetProtectionConfig::default()
564        };
565        protect_sheet(&mut ws, &config).unwrap();
566
567        let prot = ws.sheet_protection.as_ref().unwrap();
568        assert!(prot.password.is_some());
569        let pw = prot.password.as_ref().unwrap();
570        // Should be a 4-char uppercase hex string
571        assert_eq!(pw.len(), 4);
572        assert!(pw.chars().all(|c| c.is_ascii_hexdigit()));
573        // Should be deterministic
574        let expected = format!("{:04X}", legacy_password_hash("secret"));
575        assert_eq!(pw, &expected);
576    }
577
578    #[test]
579    fn test_unprotect_sheet() {
580        let mut ws = WorksheetXml::default();
581        let config = SheetProtectionConfig {
582            password: Some("test".to_string()),
583            ..SheetProtectionConfig::default()
584        };
585        protect_sheet(&mut ws, &config).unwrap();
586        assert!(ws.sheet_protection.is_some());
587
588        unprotect_sheet(&mut ws).unwrap();
589        assert!(ws.sheet_protection.is_none());
590    }
591
592    #[test]
593    fn test_is_sheet_protected() {
594        let mut ws = WorksheetXml::default();
595        assert!(!is_sheet_protected(&ws));
596
597        let config = SheetProtectionConfig::default();
598        protect_sheet(&mut ws, &config).unwrap();
599        assert!(is_sheet_protected(&ws));
600
601        unprotect_sheet(&mut ws).unwrap();
602        assert!(!is_sheet_protected(&ws));
603    }
604
605    #[test]
606    fn test_protect_sheet_with_permissions() {
607        let mut ws = WorksheetXml::default();
608        let config = SheetProtectionConfig {
609            password: None,
610            format_cells: true,
611            insert_rows: true,
612            delete_columns: true,
613            sort: true,
614            ..SheetProtectionConfig::default()
615        };
616        protect_sheet(&mut ws, &config).unwrap();
617
618        let prot = ws.sheet_protection.as_ref().unwrap();
619        assert_eq!(prot.format_cells, Some(true));
620        assert_eq!(prot.insert_rows, Some(true));
621        assert_eq!(prot.delete_columns, Some(true));
622        assert_eq!(prot.sort, Some(true));
623        // Fields not set should be None (meaning forbidden)
624        assert!(prot.format_columns.is_none());
625        assert!(prot.format_rows.is_none());
626        assert!(prot.insert_columns.is_none());
627        assert!(prot.insert_hyperlinks.is_none());
628        assert!(prot.delete_rows.is_none());
629        assert!(prot.auto_filter.is_none());
630        assert!(prot.pivot_tables.is_none());
631        assert!(prot.select_locked_cells.is_none());
632        assert!(prot.select_unlocked_cells.is_none());
633    }
634
635    // -- Tab color tests --
636
637    #[test]
638    fn test_set_tab_color() {
639        let mut ws = WorksheetXml::default();
640        set_tab_color(&mut ws, "FF0000").unwrap();
641
642        assert!(ws.sheet_pr.is_some());
643        let tab_color = ws.sheet_pr.as_ref().unwrap().tab_color.as_ref().unwrap();
644        assert_eq!(tab_color.rgb, Some("FF0000".to_string()));
645    }
646
647    #[test]
648    fn test_get_tab_color() {
649        let mut ws = WorksheetXml::default();
650        set_tab_color(&mut ws, "00FF00").unwrap();
651        assert_eq!(get_tab_color(&ws), Some("00FF00".to_string()));
652    }
653
654    #[test]
655    fn test_get_tab_color_none() {
656        let ws = WorksheetXml::default();
657        assert_eq!(get_tab_color(&ws), None);
658    }
659
660    // -- Default row height tests --
661
662    #[test]
663    fn test_set_default_row_height() {
664        let mut ws = WorksheetXml::default();
665        set_default_row_height(&mut ws, 20.0).unwrap();
666
667        assert!(ws.sheet_format_pr.is_some());
668        assert_eq!(
669            ws.sheet_format_pr.as_ref().unwrap().default_row_height,
670            20.0
671        );
672    }
673
674    #[test]
675    fn test_get_default_row_height() {
676        let ws = WorksheetXml::default();
677        assert_eq!(get_default_row_height(&ws), DEFAULT_ROW_HEIGHT);
678
679        let mut ws2 = WorksheetXml::default();
680        set_default_row_height(&mut ws2, 25.0).unwrap();
681        assert_eq!(get_default_row_height(&ws2), 25.0);
682    }
683
684    #[test]
685    fn test_set_default_row_height_exceeds_max() {
686        let mut ws = WorksheetXml::default();
687        let result = set_default_row_height(&mut ws, 500.0);
688        assert!(result.is_err());
689        assert!(matches!(
690            result.unwrap_err(),
691            Error::RowHeightExceeded { .. }
692        ));
693    }
694
695    // -- Default column width tests --
696
697    #[test]
698    fn test_set_default_col_width() {
699        let mut ws = WorksheetXml::default();
700        set_default_col_width(&mut ws, 12.0).unwrap();
701
702        assert!(ws.sheet_format_pr.is_some());
703        assert_eq!(
704            ws.sheet_format_pr.as_ref().unwrap().default_col_width,
705            Some(12.0)
706        );
707    }
708
709    #[test]
710    fn test_get_default_col_width() {
711        let ws = WorksheetXml::default();
712        assert_eq!(get_default_col_width(&ws), None);
713
714        let mut ws2 = WorksheetXml::default();
715        set_default_col_width(&mut ws2, 18.5).unwrap();
716        assert_eq!(get_default_col_width(&ws2), Some(18.5));
717    }
718
719    #[test]
720    fn test_set_default_col_width_exceeds_max() {
721        let mut ws = WorksheetXml::default();
722        let result = set_default_col_width(&mut ws, 300.0);
723        assert!(result.is_err());
724        assert!(matches!(
725            result.unwrap_err(),
726            Error::ColumnWidthExceeded { .. }
727        ));
728    }
729
730    // -- Existing tests below --
731
732    #[test]
733    fn test_validate_empty_name() {
734        let result = validate_sheet_name("");
735        assert!(result.is_err());
736        let err_msg = result.unwrap_err().to_string();
737        assert!(
738            err_msg.contains("empty"),
739            "Error should mention empty: {err_msg}"
740        );
741    }
742
743    #[test]
744    fn test_validate_too_long_name() {
745        let long_name = "a".repeat(32);
746        let result = validate_sheet_name(&long_name);
747        assert!(result.is_err());
748        let err_msg = result.unwrap_err().to_string();
749        assert!(
750            err_msg.contains("exceeds"),
751            "Error should mention exceeds: {err_msg}"
752        );
753    }
754
755    #[test]
756    fn test_validate_exactly_max_length_is_ok() {
757        let name = "a".repeat(MAX_SHEET_NAME_LENGTH);
758        assert!(validate_sheet_name(&name).is_ok());
759    }
760
761    #[test]
762    fn test_validate_invalid_chars() {
763        for ch in SHEET_NAME_INVALID_CHARS {
764            let name = format!("Sheet{}", ch);
765            let result = validate_sheet_name(&name);
766            assert!(result.is_err(), "Name with '{}' should be invalid", ch);
767        }
768    }
769
770    #[test]
771    fn test_validate_single_quote_boundary() {
772        assert!(validate_sheet_name("'Sheet").is_err());
773        assert!(validate_sheet_name("Sheet'").is_err());
774        assert!(validate_sheet_name("'Sheet'").is_err());
775        // Single quote in the middle is OK
776        assert!(validate_sheet_name("She'et").is_ok());
777    }
778
779    #[test]
780    fn test_validate_valid_name() {
781        assert!(validate_sheet_name("Sheet1").is_ok());
782        assert!(validate_sheet_name("My Data").is_ok());
783        assert!(validate_sheet_name("Q1-2024").is_ok());
784        assert!(validate_sheet_name("Sheet (2)").is_ok());
785    }
786
787    #[test]
788    fn test_next_rid() {
789        let rels = vec![
790            Relationship {
791                id: "rId1".to_string(),
792                rel_type: "".to_string(),
793                target: "".to_string(),
794                target_mode: None,
795            },
796            Relationship {
797                id: "rId3".to_string(),
798                rel_type: "".to_string(),
799                target: "".to_string(),
800                target_mode: None,
801            },
802        ];
803        assert_eq!(next_rid(&rels), "rId4");
804    }
805
806    #[test]
807    fn test_next_rid_empty() {
808        assert_eq!(next_rid(&[]), "rId1");
809    }
810
811    #[test]
812    fn test_next_sheet_id() {
813        let sheets = vec![
814            SheetEntry {
815                name: "Sheet1".to_string(),
816                sheet_id: 1,
817                state: None,
818                r_id: "rId1".to_string(),
819            },
820            SheetEntry {
821                name: "Sheet2".to_string(),
822                sheet_id: 5,
823                state: None,
824                r_id: "rId2".to_string(),
825            },
826        ];
827        assert_eq!(next_sheet_id(&sheets), 6);
828    }
829
830    #[test]
831    fn test_next_sheet_id_empty() {
832        assert_eq!(next_sheet_id(&[]), 1);
833    }
834
835    /// Helper to create default test workbook internals.
836    fn test_workbook_parts() -> (
837        WorkbookXml,
838        Relationships,
839        ContentTypes,
840        Vec<(String, WorksheetXml)>,
841    ) {
842        let workbook_xml = WorkbookXml::default();
843        let workbook_rels = relationships::workbook_rels();
844        let content_types = ContentTypes::default();
845        let worksheets = vec![("Sheet1".to_string(), WorksheetXml::default())];
846        (workbook_xml, workbook_rels, content_types, worksheets)
847    }
848
849    #[test]
850    fn test_add_sheet_basic() {
851        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
852
853        let idx = add_sheet(
854            &mut wb_xml,
855            &mut wb_rels,
856            &mut ct,
857            &mut ws,
858            "Sheet2",
859            WorksheetXml::default(),
860        )
861        .unwrap();
862
863        assert_eq!(idx, 1);
864        assert_eq!(ws.len(), 2);
865        assert_eq!(ws[1].0, "Sheet2");
866        assert_eq!(wb_xml.sheets.sheets.len(), 2);
867        assert_eq!(wb_xml.sheets.sheets[1].name, "Sheet2");
868
869        let ws_rels: Vec<_> = wb_rels
870            .relationships
871            .iter()
872            .filter(|r| r.rel_type == rel_types::WORKSHEET)
873            .collect();
874        assert_eq!(ws_rels.len(), 2);
875
876        let ws_overrides: Vec<_> = ct
877            .overrides
878            .iter()
879            .filter(|o| o.content_type == mime_types::WORKSHEET)
880            .collect();
881        assert_eq!(ws_overrides.len(), 2);
882    }
883
884    #[test]
885    fn test_add_sheet_duplicate_returns_error() {
886        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
887
888        let result = add_sheet(
889            &mut wb_xml,
890            &mut wb_rels,
891            &mut ct,
892            &mut ws,
893            "Sheet1",
894            WorksheetXml::default(),
895        );
896
897        assert!(result.is_err());
898        assert!(
899            matches!(result.unwrap_err(), Error::SheetAlreadyExists { name } if name == "Sheet1")
900        );
901    }
902
903    #[test]
904    fn test_add_sheet_invalid_name_returns_error() {
905        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
906
907        let result = add_sheet(
908            &mut wb_xml,
909            &mut wb_rels,
910            &mut ct,
911            &mut ws,
912            "Bad[Name",
913            WorksheetXml::default(),
914        );
915
916        assert!(result.is_err());
917        assert!(matches!(result.unwrap_err(), Error::InvalidSheetName(_)));
918    }
919
920    #[test]
921    fn test_delete_sheet_basic() {
922        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
923
924        add_sheet(
925            &mut wb_xml,
926            &mut wb_rels,
927            &mut ct,
928            &mut ws,
929            "Sheet2",
930            WorksheetXml::default(),
931        )
932        .unwrap();
933
934        assert_eq!(ws.len(), 2);
935
936        delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "Sheet1").unwrap();
937
938        assert_eq!(ws.len(), 1);
939        assert_eq!(ws[0].0, "Sheet2");
940        assert_eq!(wb_xml.sheets.sheets.len(), 1);
941        assert_eq!(wb_xml.sheets.sheets[0].name, "Sheet2");
942
943        let ws_rels: Vec<_> = wb_rels
944            .relationships
945            .iter()
946            .filter(|r| r.rel_type == rel_types::WORKSHEET)
947            .collect();
948        assert_eq!(ws_rels.len(), 1);
949
950        let ws_overrides: Vec<_> = ct
951            .overrides
952            .iter()
953            .filter(|o| o.content_type == mime_types::WORKSHEET)
954            .collect();
955        assert_eq!(ws_overrides.len(), 1);
956    }
957
958    #[test]
959    fn test_delete_last_sheet_returns_error() {
960        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
961
962        let result = delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "Sheet1");
963        assert!(result.is_err());
964    }
965
966    #[test]
967    fn test_delete_nonexistent_sheet_returns_error() {
968        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
969
970        let result = delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "Nonexistent");
971        assert!(result.is_err());
972        assert!(
973            matches!(result.unwrap_err(), Error::SheetNotFound { name } if name == "Nonexistent")
974        );
975    }
976
977    #[test]
978    fn test_rename_sheet_basic() {
979        let (mut wb_xml, _, _, mut ws) = test_workbook_parts();
980
981        rename_sheet(&mut wb_xml, &mut ws, "Sheet1", "MySheet").unwrap();
982
983        assert_eq!(ws[0].0, "MySheet");
984        assert_eq!(wb_xml.sheets.sheets[0].name, "MySheet");
985    }
986
987    #[test]
988    fn test_rename_sheet_to_existing_returns_error() {
989        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
990
991        add_sheet(
992            &mut wb_xml,
993            &mut wb_rels,
994            &mut ct,
995            &mut ws,
996            "Sheet2",
997            WorksheetXml::default(),
998        )
999        .unwrap();
1000
1001        let result = rename_sheet(&mut wb_xml, &mut ws, "Sheet1", "Sheet2");
1002        assert!(result.is_err());
1003        assert!(
1004            matches!(result.unwrap_err(), Error::SheetAlreadyExists { name } if name == "Sheet2")
1005        );
1006    }
1007
1008    #[test]
1009    fn test_rename_nonexistent_sheet_returns_error() {
1010        let (mut wb_xml, _, _, mut ws) = test_workbook_parts();
1011
1012        let result = rename_sheet(&mut wb_xml, &mut ws, "Nope", "NewName");
1013        assert!(result.is_err());
1014        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { name } if name == "Nope"));
1015    }
1016
1017    #[test]
1018    fn test_copy_sheet_basic() {
1019        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1020
1021        let idx = copy_sheet(
1022            &mut wb_xml,
1023            &mut wb_rels,
1024            &mut ct,
1025            &mut ws,
1026            "Sheet1",
1027            "Sheet1 Copy",
1028        )
1029        .unwrap();
1030
1031        assert_eq!(idx, 1);
1032        assert_eq!(ws.len(), 2);
1033        assert_eq!(ws[1].0, "Sheet1 Copy");
1034        // The copied worksheet data should be a clone of the source
1035        assert_eq!(ws[1].1, ws[0].1);
1036    }
1037
1038    #[test]
1039    fn test_copy_nonexistent_sheet_returns_error() {
1040        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1041
1042        let result = copy_sheet(
1043            &mut wb_xml,
1044            &mut wb_rels,
1045            &mut ct,
1046            &mut ws,
1047            "Nonexistent",
1048            "Copy",
1049        );
1050        assert!(result.is_err());
1051    }
1052
1053    #[test]
1054    fn test_copy_sheet_to_existing_name_returns_error() {
1055        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1056
1057        let result = copy_sheet(
1058            &mut wb_xml,
1059            &mut wb_rels,
1060            &mut ct,
1061            &mut ws,
1062            "Sheet1",
1063            "Sheet1",
1064        );
1065        assert!(result.is_err());
1066    }
1067
1068    #[test]
1069    fn test_find_sheet_index() {
1070        let ws: Vec<(String, WorksheetXml)> = vec![
1071            ("Sheet1".to_string(), WorksheetXml::default()),
1072            ("Sheet2".to_string(), WorksheetXml::default()),
1073        ];
1074
1075        assert_eq!(find_sheet_index(&ws, "Sheet1"), Some(0));
1076        assert_eq!(find_sheet_index(&ws, "Sheet2"), Some(1));
1077        assert_eq!(find_sheet_index(&ws, "Sheet3"), None);
1078    }
1079
1080    #[test]
1081    fn test_active_sheet_index_default() {
1082        let wb_xml = WorkbookXml::default();
1083        assert_eq!(active_sheet_index(&wb_xml), 0);
1084    }
1085
1086    #[test]
1087    fn test_set_active_sheet_index() {
1088        let mut wb_xml = WorkbookXml::default();
1089        set_active_sheet_index(&mut wb_xml, 2);
1090
1091        assert_eq!(active_sheet_index(&wb_xml), 2);
1092    }
1093
1094    #[test]
1095    fn test_multiple_add_delete_consistency() {
1096        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1097
1098        add_sheet(
1099            &mut wb_xml,
1100            &mut wb_rels,
1101            &mut ct,
1102            &mut ws,
1103            "A",
1104            WorksheetXml::default(),
1105        )
1106        .unwrap();
1107        add_sheet(
1108            &mut wb_xml,
1109            &mut wb_rels,
1110            &mut ct,
1111            &mut ws,
1112            "B",
1113            WorksheetXml::default(),
1114        )
1115        .unwrap();
1116        add_sheet(
1117            &mut wb_xml,
1118            &mut wb_rels,
1119            &mut ct,
1120            &mut ws,
1121            "C",
1122            WorksheetXml::default(),
1123        )
1124        .unwrap();
1125
1126        assert_eq!(ws.len(), 4);
1127
1128        delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "B").unwrap();
1129
1130        assert_eq!(ws.len(), 3);
1131        let names: Vec<&str> = ws.iter().map(|(n, _)| n.as_str()).collect();
1132        assert_eq!(names, vec!["Sheet1", "A", "C"]);
1133
1134        assert_eq!(wb_xml.sheets.sheets.len(), 3);
1135        let ws_rels: Vec<_> = wb_rels
1136            .relationships
1137            .iter()
1138            .filter(|r| r.rel_type == rel_types::WORKSHEET)
1139            .collect();
1140        assert_eq!(ws_rels.len(), 3);
1141        let ws_overrides: Vec<_> = ct
1142            .overrides
1143            .iter()
1144            .filter(|o| o.content_type == mime_types::WORKSHEET)
1145            .collect();
1146        assert_eq!(ws_overrides.len(), 3);
1147    }
1148
1149    // -- Freeze pane tests --
1150
1151    #[test]
1152    fn test_set_panes_freeze_row() {
1153        let mut ws = WorksheetXml::default();
1154        set_panes(&mut ws, "A2").unwrap();
1155
1156        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1157            .pane
1158            .as_ref()
1159            .unwrap();
1160        assert_eq!(pane.y_split, Some(1));
1161        assert!(pane.x_split.is_none());
1162        assert_eq!(pane.top_left_cell, Some("A2".to_string()));
1163        assert_eq!(pane.active_pane, Some("bottomLeft".to_string()));
1164        assert_eq!(pane.state, Some("frozen".to_string()));
1165    }
1166
1167    #[test]
1168    fn test_set_panes_freeze_col() {
1169        let mut ws = WorksheetXml::default();
1170        set_panes(&mut ws, "B1").unwrap();
1171
1172        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1173            .pane
1174            .as_ref()
1175            .unwrap();
1176        assert_eq!(pane.x_split, Some(1));
1177        assert!(pane.y_split.is_none());
1178        assert_eq!(pane.top_left_cell, Some("B1".to_string()));
1179        assert_eq!(pane.active_pane, Some("topRight".to_string()));
1180        assert_eq!(pane.state, Some("frozen".to_string()));
1181    }
1182
1183    #[test]
1184    fn test_set_panes_freeze_both() {
1185        let mut ws = WorksheetXml::default();
1186        set_panes(&mut ws, "B2").unwrap();
1187
1188        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1189            .pane
1190            .as_ref()
1191            .unwrap();
1192        assert_eq!(pane.x_split, Some(1));
1193        assert_eq!(pane.y_split, Some(1));
1194        assert_eq!(pane.top_left_cell, Some("B2".to_string()));
1195        assert_eq!(pane.active_pane, Some("bottomRight".to_string()));
1196        assert_eq!(pane.state, Some("frozen".to_string()));
1197    }
1198
1199    #[test]
1200    fn test_set_panes_freeze_multiple_rows() {
1201        let mut ws = WorksheetXml::default();
1202        set_panes(&mut ws, "A4").unwrap();
1203
1204        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1205            .pane
1206            .as_ref()
1207            .unwrap();
1208        assert_eq!(pane.y_split, Some(3));
1209        assert!(pane.x_split.is_none());
1210        assert_eq!(pane.top_left_cell, Some("A4".to_string()));
1211        assert_eq!(pane.active_pane, Some("bottomLeft".to_string()));
1212    }
1213
1214    #[test]
1215    fn test_set_panes_freeze_multiple_cols() {
1216        let mut ws = WorksheetXml::default();
1217        set_panes(&mut ws, "D1").unwrap();
1218
1219        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1220            .pane
1221            .as_ref()
1222            .unwrap();
1223        assert_eq!(pane.x_split, Some(3));
1224        assert!(pane.y_split.is_none());
1225        assert_eq!(pane.top_left_cell, Some("D1".to_string()));
1226        assert_eq!(pane.active_pane, Some("topRight".to_string()));
1227    }
1228
1229    #[test]
1230    fn test_set_panes_a1_error() {
1231        let mut ws = WorksheetXml::default();
1232        let result = set_panes(&mut ws, "A1");
1233        assert!(result.is_err());
1234        assert!(matches!(
1235            result.unwrap_err(),
1236            Error::InvalidCellReference(_)
1237        ));
1238    }
1239
1240    #[test]
1241    fn test_set_panes_invalid_cell_error() {
1242        let mut ws = WorksheetXml::default();
1243        let result = set_panes(&mut ws, "ZZZZ1");
1244        assert!(result.is_err());
1245    }
1246
1247    #[test]
1248    fn test_unset_panes() {
1249        let mut ws = WorksheetXml::default();
1250        set_panes(&mut ws, "B2").unwrap();
1251        assert!(get_panes(&ws).is_some());
1252
1253        unset_panes(&mut ws);
1254        assert!(get_panes(&ws).is_none());
1255        // SheetViews should still exist but without pane.
1256        let view = &ws.sheet_views.as_ref().unwrap().sheet_views[0];
1257        assert!(view.pane.is_none());
1258        assert!(view.selection.is_empty());
1259    }
1260
1261    #[test]
1262    fn test_get_panes_none_when_not_set() {
1263        let ws = WorksheetXml::default();
1264        assert!(get_panes(&ws).is_none());
1265    }
1266
1267    #[test]
1268    fn test_get_panes_returns_value_after_set() {
1269        let mut ws = WorksheetXml::default();
1270        set_panes(&mut ws, "C5").unwrap();
1271        assert_eq!(get_panes(&ws), Some("C5".to_string()));
1272    }
1273
1274    #[test]
1275    fn test_set_panes_selection_has_pane_attribute() {
1276        let mut ws = WorksheetXml::default();
1277        set_panes(&mut ws, "B2").unwrap();
1278
1279        let selection = &ws.sheet_views.as_ref().unwrap().sheet_views[0].selection[0];
1280        assert_eq!(selection.pane, Some("bottomRight".to_string()));
1281        assert_eq!(selection.active_cell, Some("B2".to_string()));
1282        assert_eq!(selection.sqref, Some("B2".to_string()));
1283    }
1284
1285    #[test]
1286    fn test_set_panes_overwrites_previous() {
1287        let mut ws = WorksheetXml::default();
1288        set_panes(&mut ws, "A2").unwrap();
1289        assert_eq!(get_panes(&ws), Some("A2".to_string()));
1290
1291        set_panes(&mut ws, "C3").unwrap();
1292        assert_eq!(get_panes(&ws), Some("C3".to_string()));
1293
1294        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1295            .pane
1296            .as_ref()
1297            .unwrap();
1298        assert_eq!(pane.x_split, Some(2));
1299        assert_eq!(pane.y_split, Some(2));
1300        assert_eq!(pane.active_pane, Some("bottomRight".to_string()));
1301    }
1302
1303    #[test]
1304    fn test_unset_panes_noop_when_no_views() {
1305        let mut ws = WorksheetXml::default();
1306        // Should not panic when there are no sheet views.
1307        unset_panes(&mut ws);
1308        assert!(get_panes(&ws).is_none());
1309    }
1310}