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            show_grid_lines: None,
459            show_formulas: None,
460            show_row_col_headers: None,
461            zoom_scale: None,
462            view: None,
463            top_left_cell: None,
464            workbook_view_id: 0,
465            pane: None,
466            selection: vec![],
467        }],
468    });
469
470    if let Some(view) = sheet_views.sheet_views.first_mut() {
471        view.pane = Some(pane);
472        view.selection = vec![selection];
473    }
474
475    Ok(())
476}
477
478/// Remove any freeze or split panes from a worksheet.
479pub fn unset_panes(ws: &mut WorksheetXml) {
480    if let Some(ref mut sheet_views) = ws.sheet_views {
481        for view in &mut sheet_views.sheet_views {
482            view.pane = None;
483            // Reset selection to default (no pane attribute).
484            view.selection = vec![];
485        }
486    }
487}
488
489/// Get the current freeze pane cell reference, if any.
490///
491/// Returns the top-left cell of the unfrozen area (e.g., `"A2"` if row 1 is
492/// frozen), or `None` if no panes are configured.
493pub fn get_panes(ws: &WorksheetXml) -> Option<String> {
494    ws.sheet_views
495        .as_ref()
496        .and_then(|sv| sv.sheet_views.first())
497        .and_then(|view| view.pane.as_ref())
498        .and_then(|pane| pane.top_left_cell.clone())
499}
500
501/// View mode for a sheet.
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum ViewMode {
504    /// Normal editing view (default).
505    Normal,
506    /// Page break preview.
507    PageBreak,
508    /// Page layout view.
509    PageLayout,
510}
511
512impl ViewMode {
513    /// Convert to the OOXML attribute string value.
514    pub fn as_str(&self) -> &'static str {
515        match self {
516            ViewMode::Normal => "normal",
517            ViewMode::PageBreak => "pageBreakPreview",
518            ViewMode::PageLayout => "pageLayout",
519        }
520    }
521
522    /// Parse from an OOXML attribute string value.
523    pub fn from_xml_str(s: &str) -> Option<Self> {
524        match s {
525            "normal" => Some(ViewMode::Normal),
526            "pageBreakPreview" => Some(ViewMode::PageBreak),
527            "pageLayout" => Some(ViewMode::PageLayout),
528            _ => None,
529        }
530    }
531}
532
533/// Options controlling the display of a sheet view.
534#[derive(Debug, Clone, Default)]
535pub struct SheetViewOptions {
536    /// Whether gridlines are shown. Defaults to true.
537    pub show_gridlines: Option<bool>,
538    /// Whether formulas are shown instead of their results. Defaults to false.
539    pub show_formulas: Option<bool>,
540    /// Whether row and column headers are shown. Defaults to true.
541    pub show_row_col_headers: Option<bool>,
542    /// Zoom scale as a percentage (10-400). Defaults to 100.
543    pub zoom_scale: Option<u32>,
544    /// The view mode (Normal, PageBreak, PageLayout).
545    pub view_mode: Option<ViewMode>,
546    /// The top-left cell visible in the view (e.g. "A1").
547    pub top_left_cell: Option<String>,
548}
549
550/// Set sheet view options on a worksheet.
551///
552/// Only non-`None` fields are applied; existing values for `None` fields are
553/// preserved.
554pub fn set_sheet_view_options(ws: &mut WorksheetXml, opts: &SheetViewOptions) -> Result<()> {
555    if let Some(zoom) = opts.zoom_scale {
556        if !(10..=400).contains(&zoom) {
557            return Err(Error::InvalidArgument(format!(
558                "zoom scale {zoom} is outside the valid range 10-400"
559            )));
560        }
561    }
562
563    let sheet_views = ws.sheet_views.get_or_insert_with(|| SheetViews {
564        sheet_views: vec![SheetView {
565            tab_selected: None,
566            show_grid_lines: None,
567            show_formulas: None,
568            show_row_col_headers: None,
569            zoom_scale: None,
570            view: None,
571            top_left_cell: None,
572            workbook_view_id: 0,
573            pane: None,
574            selection: vec![],
575        }],
576    });
577
578    if let Some(view) = sheet_views.sheet_views.first_mut() {
579        if let Some(v) = opts.show_gridlines {
580            view.show_grid_lines = if v { None } else { Some(false) };
581        }
582        if let Some(v) = opts.show_formulas {
583            view.show_formulas = if v { Some(true) } else { None };
584        }
585        if let Some(v) = opts.show_row_col_headers {
586            view.show_row_col_headers = if v { None } else { Some(false) };
587        }
588        if let Some(zoom) = opts.zoom_scale {
589            view.zoom_scale = if zoom == 100 { None } else { Some(zoom) };
590        }
591        if let Some(ref mode) = opts.view_mode {
592            view.view = match mode {
593                ViewMode::Normal => None,
594                other => Some(other.as_str().to_string()),
595            };
596        }
597        if let Some(ref cell) = opts.top_left_cell {
598            view.top_left_cell = if cell.is_empty() {
599                None
600            } else {
601                Some(cell.clone())
602            };
603        }
604    }
605
606    Ok(())
607}
608
609/// Read the current sheet view options from a worksheet.
610pub fn get_sheet_view_options(ws: &WorksheetXml) -> SheetViewOptions {
611    let view = ws
612        .sheet_views
613        .as_ref()
614        .and_then(|sv| sv.sheet_views.first());
615
616    match view {
617        None => SheetViewOptions {
618            show_gridlines: Some(true),
619            show_formulas: Some(false),
620            show_row_col_headers: Some(true),
621            zoom_scale: Some(100),
622            view_mode: Some(ViewMode::Normal),
623            top_left_cell: None,
624        },
625        Some(v) => SheetViewOptions {
626            show_gridlines: Some(v.show_grid_lines.unwrap_or(true)),
627            show_formulas: Some(v.show_formulas.unwrap_or(false)),
628            show_row_col_headers: Some(v.show_row_col_headers.unwrap_or(true)),
629            zoom_scale: Some(v.zoom_scale.unwrap_or(100)),
630            view_mode: Some(
631                v.view
632                    .as_deref()
633                    .and_then(ViewMode::from_xml_str)
634                    .unwrap_or(ViewMode::Normal),
635            ),
636            top_left_cell: v.top_left_cell.clone(),
637        },
638    }
639}
640
641/// Sheet visibility state.
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub enum SheetVisibility {
644    /// The sheet tab is visible (default).
645    Visible,
646    /// The sheet tab is hidden but can be unhidden by the user.
647    Hidden,
648    /// The sheet tab is hidden and cannot be unhidden via the UI (only via code).
649    VeryHidden,
650}
651
652impl SheetVisibility {
653    /// Convert to the OOXML `state` attribute value.
654    pub fn as_xml_str(&self) -> Option<&'static str> {
655        match self {
656            SheetVisibility::Visible => None,
657            SheetVisibility::Hidden => Some("hidden"),
658            SheetVisibility::VeryHidden => Some("veryHidden"),
659        }
660    }
661
662    /// Parse from the OOXML `state` attribute value.
663    pub fn from_xml_str(s: Option<&str>) -> Self {
664        match s {
665            Some("hidden") => SheetVisibility::Hidden,
666            Some("veryHidden") => SheetVisibility::VeryHidden,
667            _ => SheetVisibility::Visible,
668        }
669    }
670}
671
672/// Rebuild content type overrides for worksheets so they match the current
673/// worksheet indices (sheet1.xml, sheet2.xml, ...).
674fn rebuild_content_type_overrides(content_types: &mut ContentTypes, sheet_count: usize) {
675    content_types
676        .overrides
677        .retain(|o| o.content_type != mime_types::WORKSHEET);
678
679    for i in 1..=sheet_count {
680        content_types.overrides.push(ContentTypeOverride {
681            part_name: format!("/xl/worksheets/sheet{}.xml", i),
682            content_type: mime_types::WORKSHEET.to_string(),
683        });
684    }
685}
686
687/// Rebuild worksheet relationship targets so they match the current worksheet indices.
688fn rebuild_worksheet_relationships(
689    workbook_xml: &mut WorkbookXml,
690    workbook_rels: &mut Relationships,
691) {
692    let sheet_rids: Vec<String> = workbook_xml
693        .sheets
694        .sheets
695        .iter()
696        .map(|s| s.r_id.clone())
697        .collect();
698
699    for (i, rid) in sheet_rids.iter().enumerate() {
700        if let Some(rel) = workbook_rels
701            .relationships
702            .iter_mut()
703            .find(|r| r.id == *rid)
704        {
705            rel.target = format!("worksheets/sheet{}.xml", i + 1);
706        }
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use sheetkit_xml::content_types::ContentTypes;
714    use sheetkit_xml::relationships;
715    use sheetkit_xml::workbook::WorkbookXml;
716    use sheetkit_xml::worksheet::WorksheetXml;
717
718    // -- Sheet protection tests --
719
720    #[test]
721    fn test_protect_sheet_no_password() {
722        let mut ws = WorksheetXml::default();
723        let config = SheetProtectionConfig::default();
724        protect_sheet(&mut ws, &config).unwrap();
725
726        assert!(ws.sheet_protection.is_some());
727        let prot = ws.sheet_protection.as_ref().unwrap();
728        assert_eq!(prot.sheet, Some(true));
729        assert_eq!(prot.objects, Some(true));
730        assert_eq!(prot.scenarios, Some(true));
731        assert!(prot.password.is_none());
732    }
733
734    #[test]
735    fn test_protect_sheet_with_password() {
736        let mut ws = WorksheetXml::default();
737        let config = SheetProtectionConfig {
738            password: Some("secret".to_string()),
739            ..SheetProtectionConfig::default()
740        };
741        protect_sheet(&mut ws, &config).unwrap();
742
743        let prot = ws.sheet_protection.as_ref().unwrap();
744        assert!(prot.password.is_some());
745        let pw = prot.password.as_ref().unwrap();
746        // Should be a 4-char uppercase hex string
747        assert_eq!(pw.len(), 4);
748        assert!(pw.chars().all(|c| c.is_ascii_hexdigit()));
749        // Should be deterministic
750        let expected = format!("{:04X}", legacy_password_hash("secret"));
751        assert_eq!(pw, &expected);
752    }
753
754    #[test]
755    fn test_unprotect_sheet() {
756        let mut ws = WorksheetXml::default();
757        let config = SheetProtectionConfig {
758            password: Some("test".to_string()),
759            ..SheetProtectionConfig::default()
760        };
761        protect_sheet(&mut ws, &config).unwrap();
762        assert!(ws.sheet_protection.is_some());
763
764        unprotect_sheet(&mut ws).unwrap();
765        assert!(ws.sheet_protection.is_none());
766    }
767
768    #[test]
769    fn test_is_sheet_protected() {
770        let mut ws = WorksheetXml::default();
771        assert!(!is_sheet_protected(&ws));
772
773        let config = SheetProtectionConfig::default();
774        protect_sheet(&mut ws, &config).unwrap();
775        assert!(is_sheet_protected(&ws));
776
777        unprotect_sheet(&mut ws).unwrap();
778        assert!(!is_sheet_protected(&ws));
779    }
780
781    #[test]
782    fn test_protect_sheet_with_permissions() {
783        let mut ws = WorksheetXml::default();
784        let config = SheetProtectionConfig {
785            password: None,
786            format_cells: true,
787            insert_rows: true,
788            delete_columns: true,
789            sort: true,
790            ..SheetProtectionConfig::default()
791        };
792        protect_sheet(&mut ws, &config).unwrap();
793
794        let prot = ws.sheet_protection.as_ref().unwrap();
795        assert_eq!(prot.format_cells, Some(true));
796        assert_eq!(prot.insert_rows, Some(true));
797        assert_eq!(prot.delete_columns, Some(true));
798        assert_eq!(prot.sort, Some(true));
799        // Fields not set should be None (meaning forbidden)
800        assert!(prot.format_columns.is_none());
801        assert!(prot.format_rows.is_none());
802        assert!(prot.insert_columns.is_none());
803        assert!(prot.insert_hyperlinks.is_none());
804        assert!(prot.delete_rows.is_none());
805        assert!(prot.auto_filter.is_none());
806        assert!(prot.pivot_tables.is_none());
807        assert!(prot.select_locked_cells.is_none());
808        assert!(prot.select_unlocked_cells.is_none());
809    }
810
811    // -- Tab color tests --
812
813    #[test]
814    fn test_set_tab_color() {
815        let mut ws = WorksheetXml::default();
816        set_tab_color(&mut ws, "FF0000").unwrap();
817
818        assert!(ws.sheet_pr.is_some());
819        let tab_color = ws.sheet_pr.as_ref().unwrap().tab_color.as_ref().unwrap();
820        assert_eq!(tab_color.rgb, Some("FF0000".to_string()));
821    }
822
823    #[test]
824    fn test_get_tab_color() {
825        let mut ws = WorksheetXml::default();
826        set_tab_color(&mut ws, "00FF00").unwrap();
827        assert_eq!(get_tab_color(&ws), Some("00FF00".to_string()));
828    }
829
830    #[test]
831    fn test_get_tab_color_none() {
832        let ws = WorksheetXml::default();
833        assert_eq!(get_tab_color(&ws), None);
834    }
835
836    // -- Default row height tests --
837
838    #[test]
839    fn test_set_default_row_height() {
840        let mut ws = WorksheetXml::default();
841        set_default_row_height(&mut ws, 20.0).unwrap();
842
843        assert!(ws.sheet_format_pr.is_some());
844        assert_eq!(
845            ws.sheet_format_pr.as_ref().unwrap().default_row_height,
846            20.0
847        );
848    }
849
850    #[test]
851    fn test_get_default_row_height() {
852        let ws = WorksheetXml::default();
853        assert_eq!(get_default_row_height(&ws), DEFAULT_ROW_HEIGHT);
854
855        let mut ws2 = WorksheetXml::default();
856        set_default_row_height(&mut ws2, 25.0).unwrap();
857        assert_eq!(get_default_row_height(&ws2), 25.0);
858    }
859
860    #[test]
861    fn test_set_default_row_height_exceeds_max() {
862        let mut ws = WorksheetXml::default();
863        let result = set_default_row_height(&mut ws, 500.0);
864        assert!(result.is_err());
865        assert!(matches!(
866            result.unwrap_err(),
867            Error::RowHeightExceeded { .. }
868        ));
869    }
870
871    // -- Default column width tests --
872
873    #[test]
874    fn test_set_default_col_width() {
875        let mut ws = WorksheetXml::default();
876        set_default_col_width(&mut ws, 12.0).unwrap();
877
878        assert!(ws.sheet_format_pr.is_some());
879        assert_eq!(
880            ws.sheet_format_pr.as_ref().unwrap().default_col_width,
881            Some(12.0)
882        );
883    }
884
885    #[test]
886    fn test_get_default_col_width() {
887        let ws = WorksheetXml::default();
888        assert_eq!(get_default_col_width(&ws), None);
889
890        let mut ws2 = WorksheetXml::default();
891        set_default_col_width(&mut ws2, 18.5).unwrap();
892        assert_eq!(get_default_col_width(&ws2), Some(18.5));
893    }
894
895    #[test]
896    fn test_set_default_col_width_exceeds_max() {
897        let mut ws = WorksheetXml::default();
898        let result = set_default_col_width(&mut ws, 300.0);
899        assert!(result.is_err());
900        assert!(matches!(
901            result.unwrap_err(),
902            Error::ColumnWidthExceeded { .. }
903        ));
904    }
905
906    // -- Existing tests below --
907
908    #[test]
909    fn test_validate_empty_name() {
910        let result = validate_sheet_name("");
911        assert!(result.is_err());
912        let err_msg = result.unwrap_err().to_string();
913        assert!(
914            err_msg.contains("empty"),
915            "Error should mention empty: {err_msg}"
916        );
917    }
918
919    #[test]
920    fn test_validate_too_long_name() {
921        let long_name = "a".repeat(32);
922        let result = validate_sheet_name(&long_name);
923        assert!(result.is_err());
924        let err_msg = result.unwrap_err().to_string();
925        assert!(
926            err_msg.contains("exceeds"),
927            "Error should mention exceeds: {err_msg}"
928        );
929    }
930
931    #[test]
932    fn test_validate_exactly_max_length_is_ok() {
933        let name = "a".repeat(MAX_SHEET_NAME_LENGTH);
934        assert!(validate_sheet_name(&name).is_ok());
935    }
936
937    #[test]
938    fn test_validate_invalid_chars() {
939        for ch in SHEET_NAME_INVALID_CHARS {
940            let name = format!("Sheet{}", ch);
941            let result = validate_sheet_name(&name);
942            assert!(result.is_err(), "Name with '{}' should be invalid", ch);
943        }
944    }
945
946    #[test]
947    fn test_validate_single_quote_boundary() {
948        assert!(validate_sheet_name("'Sheet").is_err());
949        assert!(validate_sheet_name("Sheet'").is_err());
950        assert!(validate_sheet_name("'Sheet'").is_err());
951        // Single quote in the middle is OK
952        assert!(validate_sheet_name("She'et").is_ok());
953    }
954
955    #[test]
956    fn test_validate_valid_name() {
957        assert!(validate_sheet_name("Sheet1").is_ok());
958        assert!(validate_sheet_name("My Data").is_ok());
959        assert!(validate_sheet_name("Q1-2024").is_ok());
960        assert!(validate_sheet_name("Sheet (2)").is_ok());
961    }
962
963    #[test]
964    fn test_next_rid() {
965        let rels = vec![
966            Relationship {
967                id: "rId1".to_string(),
968                rel_type: "".to_string(),
969                target: "".to_string(),
970                target_mode: None,
971            },
972            Relationship {
973                id: "rId3".to_string(),
974                rel_type: "".to_string(),
975                target: "".to_string(),
976                target_mode: None,
977            },
978        ];
979        assert_eq!(next_rid(&rels), "rId4");
980    }
981
982    #[test]
983    fn test_next_rid_empty() {
984        assert_eq!(next_rid(&[]), "rId1");
985    }
986
987    #[test]
988    fn test_next_sheet_id() {
989        let sheets = vec![
990            SheetEntry {
991                name: "Sheet1".to_string(),
992                sheet_id: 1,
993                state: None,
994                r_id: "rId1".to_string(),
995            },
996            SheetEntry {
997                name: "Sheet2".to_string(),
998                sheet_id: 5,
999                state: None,
1000                r_id: "rId2".to_string(),
1001            },
1002        ];
1003        assert_eq!(next_sheet_id(&sheets), 6);
1004    }
1005
1006    #[test]
1007    fn test_next_sheet_id_empty() {
1008        assert_eq!(next_sheet_id(&[]), 1);
1009    }
1010
1011    /// Helper to create default test workbook internals.
1012    fn test_workbook_parts() -> (
1013        WorkbookXml,
1014        Relationships,
1015        ContentTypes,
1016        Vec<(String, WorksheetXml)>,
1017    ) {
1018        let workbook_xml = WorkbookXml::default();
1019        let workbook_rels = relationships::workbook_rels();
1020        let content_types = ContentTypes::default();
1021        let worksheets = vec![("Sheet1".to_string(), WorksheetXml::default())];
1022        (workbook_xml, workbook_rels, content_types, worksheets)
1023    }
1024
1025    #[test]
1026    fn test_add_sheet_basic() {
1027        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1028
1029        let idx = add_sheet(
1030            &mut wb_xml,
1031            &mut wb_rels,
1032            &mut ct,
1033            &mut ws,
1034            "Sheet2",
1035            WorksheetXml::default(),
1036        )
1037        .unwrap();
1038
1039        assert_eq!(idx, 1);
1040        assert_eq!(ws.len(), 2);
1041        assert_eq!(ws[1].0, "Sheet2");
1042        assert_eq!(wb_xml.sheets.sheets.len(), 2);
1043        assert_eq!(wb_xml.sheets.sheets[1].name, "Sheet2");
1044
1045        let ws_rels: Vec<_> = wb_rels
1046            .relationships
1047            .iter()
1048            .filter(|r| r.rel_type == rel_types::WORKSHEET)
1049            .collect();
1050        assert_eq!(ws_rels.len(), 2);
1051
1052        let ws_overrides: Vec<_> = ct
1053            .overrides
1054            .iter()
1055            .filter(|o| o.content_type == mime_types::WORKSHEET)
1056            .collect();
1057        assert_eq!(ws_overrides.len(), 2);
1058    }
1059
1060    #[test]
1061    fn test_add_sheet_duplicate_returns_error() {
1062        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1063
1064        let result = add_sheet(
1065            &mut wb_xml,
1066            &mut wb_rels,
1067            &mut ct,
1068            &mut ws,
1069            "Sheet1",
1070            WorksheetXml::default(),
1071        );
1072
1073        assert!(result.is_err());
1074        assert!(
1075            matches!(result.unwrap_err(), Error::SheetAlreadyExists { name } if name == "Sheet1")
1076        );
1077    }
1078
1079    #[test]
1080    fn test_add_sheet_invalid_name_returns_error() {
1081        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1082
1083        let result = add_sheet(
1084            &mut wb_xml,
1085            &mut wb_rels,
1086            &mut ct,
1087            &mut ws,
1088            "Bad[Name",
1089            WorksheetXml::default(),
1090        );
1091
1092        assert!(result.is_err());
1093        assert!(matches!(result.unwrap_err(), Error::InvalidSheetName(_)));
1094    }
1095
1096    #[test]
1097    fn test_delete_sheet_basic() {
1098        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1099
1100        add_sheet(
1101            &mut wb_xml,
1102            &mut wb_rels,
1103            &mut ct,
1104            &mut ws,
1105            "Sheet2",
1106            WorksheetXml::default(),
1107        )
1108        .unwrap();
1109
1110        assert_eq!(ws.len(), 2);
1111
1112        delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "Sheet1").unwrap();
1113
1114        assert_eq!(ws.len(), 1);
1115        assert_eq!(ws[0].0, "Sheet2");
1116        assert_eq!(wb_xml.sheets.sheets.len(), 1);
1117        assert_eq!(wb_xml.sheets.sheets[0].name, "Sheet2");
1118
1119        let ws_rels: Vec<_> = wb_rels
1120            .relationships
1121            .iter()
1122            .filter(|r| r.rel_type == rel_types::WORKSHEET)
1123            .collect();
1124        assert_eq!(ws_rels.len(), 1);
1125
1126        let ws_overrides: Vec<_> = ct
1127            .overrides
1128            .iter()
1129            .filter(|o| o.content_type == mime_types::WORKSHEET)
1130            .collect();
1131        assert_eq!(ws_overrides.len(), 1);
1132    }
1133
1134    #[test]
1135    fn test_delete_last_sheet_returns_error() {
1136        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1137
1138        let result = delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "Sheet1");
1139        assert!(result.is_err());
1140    }
1141
1142    #[test]
1143    fn test_delete_nonexistent_sheet_returns_error() {
1144        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1145
1146        let result = delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "Nonexistent");
1147        assert!(result.is_err());
1148        assert!(
1149            matches!(result.unwrap_err(), Error::SheetNotFound { name } if name == "Nonexistent")
1150        );
1151    }
1152
1153    #[test]
1154    fn test_rename_sheet_basic() {
1155        let (mut wb_xml, _, _, mut ws) = test_workbook_parts();
1156
1157        rename_sheet(&mut wb_xml, &mut ws, "Sheet1", "MySheet").unwrap();
1158
1159        assert_eq!(ws[0].0, "MySheet");
1160        assert_eq!(wb_xml.sheets.sheets[0].name, "MySheet");
1161    }
1162
1163    #[test]
1164    fn test_rename_sheet_to_existing_returns_error() {
1165        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1166
1167        add_sheet(
1168            &mut wb_xml,
1169            &mut wb_rels,
1170            &mut ct,
1171            &mut ws,
1172            "Sheet2",
1173            WorksheetXml::default(),
1174        )
1175        .unwrap();
1176
1177        let result = rename_sheet(&mut wb_xml, &mut ws, "Sheet1", "Sheet2");
1178        assert!(result.is_err());
1179        assert!(
1180            matches!(result.unwrap_err(), Error::SheetAlreadyExists { name } if name == "Sheet2")
1181        );
1182    }
1183
1184    #[test]
1185    fn test_rename_nonexistent_sheet_returns_error() {
1186        let (mut wb_xml, _, _, mut ws) = test_workbook_parts();
1187
1188        let result = rename_sheet(&mut wb_xml, &mut ws, "Nope", "NewName");
1189        assert!(result.is_err());
1190        assert!(matches!(result.unwrap_err(), Error::SheetNotFound { name } if name == "Nope"));
1191    }
1192
1193    #[test]
1194    fn test_copy_sheet_basic() {
1195        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1196
1197        let idx = copy_sheet(
1198            &mut wb_xml,
1199            &mut wb_rels,
1200            &mut ct,
1201            &mut ws,
1202            "Sheet1",
1203            "Sheet1 Copy",
1204        )
1205        .unwrap();
1206
1207        assert_eq!(idx, 1);
1208        assert_eq!(ws.len(), 2);
1209        assert_eq!(ws[1].0, "Sheet1 Copy");
1210        // The copied worksheet data should be a clone of the source
1211        assert_eq!(ws[1].1, ws[0].1);
1212    }
1213
1214    #[test]
1215    fn test_copy_nonexistent_sheet_returns_error() {
1216        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1217
1218        let result = copy_sheet(
1219            &mut wb_xml,
1220            &mut wb_rels,
1221            &mut ct,
1222            &mut ws,
1223            "Nonexistent",
1224            "Copy",
1225        );
1226        assert!(result.is_err());
1227    }
1228
1229    #[test]
1230    fn test_copy_sheet_to_existing_name_returns_error() {
1231        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1232
1233        let result = copy_sheet(
1234            &mut wb_xml,
1235            &mut wb_rels,
1236            &mut ct,
1237            &mut ws,
1238            "Sheet1",
1239            "Sheet1",
1240        );
1241        assert!(result.is_err());
1242    }
1243
1244    #[test]
1245    fn test_find_sheet_index() {
1246        let ws: Vec<(String, WorksheetXml)> = vec![
1247            ("Sheet1".to_string(), WorksheetXml::default()),
1248            ("Sheet2".to_string(), WorksheetXml::default()),
1249        ];
1250
1251        assert_eq!(find_sheet_index(&ws, "Sheet1"), Some(0));
1252        assert_eq!(find_sheet_index(&ws, "Sheet2"), Some(1));
1253        assert_eq!(find_sheet_index(&ws, "Sheet3"), None);
1254    }
1255
1256    #[test]
1257    fn test_active_sheet_index_default() {
1258        let wb_xml = WorkbookXml::default();
1259        assert_eq!(active_sheet_index(&wb_xml), 0);
1260    }
1261
1262    #[test]
1263    fn test_set_active_sheet_index() {
1264        let mut wb_xml = WorkbookXml::default();
1265        set_active_sheet_index(&mut wb_xml, 2);
1266
1267        assert_eq!(active_sheet_index(&wb_xml), 2);
1268    }
1269
1270    #[test]
1271    fn test_multiple_add_delete_consistency() {
1272        let (mut wb_xml, mut wb_rels, mut ct, mut ws) = test_workbook_parts();
1273
1274        add_sheet(
1275            &mut wb_xml,
1276            &mut wb_rels,
1277            &mut ct,
1278            &mut ws,
1279            "A",
1280            WorksheetXml::default(),
1281        )
1282        .unwrap();
1283        add_sheet(
1284            &mut wb_xml,
1285            &mut wb_rels,
1286            &mut ct,
1287            &mut ws,
1288            "B",
1289            WorksheetXml::default(),
1290        )
1291        .unwrap();
1292        add_sheet(
1293            &mut wb_xml,
1294            &mut wb_rels,
1295            &mut ct,
1296            &mut ws,
1297            "C",
1298            WorksheetXml::default(),
1299        )
1300        .unwrap();
1301
1302        assert_eq!(ws.len(), 4);
1303
1304        delete_sheet(&mut wb_xml, &mut wb_rels, &mut ct, &mut ws, "B").unwrap();
1305
1306        assert_eq!(ws.len(), 3);
1307        let names: Vec<&str> = ws.iter().map(|(n, _)| n.as_str()).collect();
1308        assert_eq!(names, vec!["Sheet1", "A", "C"]);
1309
1310        assert_eq!(wb_xml.sheets.sheets.len(), 3);
1311        let ws_rels: Vec<_> = wb_rels
1312            .relationships
1313            .iter()
1314            .filter(|r| r.rel_type == rel_types::WORKSHEET)
1315            .collect();
1316        assert_eq!(ws_rels.len(), 3);
1317        let ws_overrides: Vec<_> = ct
1318            .overrides
1319            .iter()
1320            .filter(|o| o.content_type == mime_types::WORKSHEET)
1321            .collect();
1322        assert_eq!(ws_overrides.len(), 3);
1323    }
1324
1325    // -- Freeze pane tests --
1326
1327    #[test]
1328    fn test_set_panes_freeze_row() {
1329        let mut ws = WorksheetXml::default();
1330        set_panes(&mut ws, "A2").unwrap();
1331
1332        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1333            .pane
1334            .as_ref()
1335            .unwrap();
1336        assert_eq!(pane.y_split, Some(1));
1337        assert!(pane.x_split.is_none());
1338        assert_eq!(pane.top_left_cell, Some("A2".to_string()));
1339        assert_eq!(pane.active_pane, Some("bottomLeft".to_string()));
1340        assert_eq!(pane.state, Some("frozen".to_string()));
1341    }
1342
1343    #[test]
1344    fn test_set_panes_freeze_col() {
1345        let mut ws = WorksheetXml::default();
1346        set_panes(&mut ws, "B1").unwrap();
1347
1348        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1349            .pane
1350            .as_ref()
1351            .unwrap();
1352        assert_eq!(pane.x_split, Some(1));
1353        assert!(pane.y_split.is_none());
1354        assert_eq!(pane.top_left_cell, Some("B1".to_string()));
1355        assert_eq!(pane.active_pane, Some("topRight".to_string()));
1356        assert_eq!(pane.state, Some("frozen".to_string()));
1357    }
1358
1359    #[test]
1360    fn test_set_panes_freeze_both() {
1361        let mut ws = WorksheetXml::default();
1362        set_panes(&mut ws, "B2").unwrap();
1363
1364        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1365            .pane
1366            .as_ref()
1367            .unwrap();
1368        assert_eq!(pane.x_split, Some(1));
1369        assert_eq!(pane.y_split, Some(1));
1370        assert_eq!(pane.top_left_cell, Some("B2".to_string()));
1371        assert_eq!(pane.active_pane, Some("bottomRight".to_string()));
1372        assert_eq!(pane.state, Some("frozen".to_string()));
1373    }
1374
1375    #[test]
1376    fn test_set_panes_freeze_multiple_rows() {
1377        let mut ws = WorksheetXml::default();
1378        set_panes(&mut ws, "A4").unwrap();
1379
1380        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1381            .pane
1382            .as_ref()
1383            .unwrap();
1384        assert_eq!(pane.y_split, Some(3));
1385        assert!(pane.x_split.is_none());
1386        assert_eq!(pane.top_left_cell, Some("A4".to_string()));
1387        assert_eq!(pane.active_pane, Some("bottomLeft".to_string()));
1388    }
1389
1390    #[test]
1391    fn test_set_panes_freeze_multiple_cols() {
1392        let mut ws = WorksheetXml::default();
1393        set_panes(&mut ws, "D1").unwrap();
1394
1395        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1396            .pane
1397            .as_ref()
1398            .unwrap();
1399        assert_eq!(pane.x_split, Some(3));
1400        assert!(pane.y_split.is_none());
1401        assert_eq!(pane.top_left_cell, Some("D1".to_string()));
1402        assert_eq!(pane.active_pane, Some("topRight".to_string()));
1403    }
1404
1405    #[test]
1406    fn test_set_panes_a1_error() {
1407        let mut ws = WorksheetXml::default();
1408        let result = set_panes(&mut ws, "A1");
1409        assert!(result.is_err());
1410        assert!(matches!(
1411            result.unwrap_err(),
1412            Error::InvalidCellReference(_)
1413        ));
1414    }
1415
1416    #[test]
1417    fn test_set_panes_invalid_cell_error() {
1418        let mut ws = WorksheetXml::default();
1419        let result = set_panes(&mut ws, "ZZZZ1");
1420        assert!(result.is_err());
1421    }
1422
1423    #[test]
1424    fn test_unset_panes() {
1425        let mut ws = WorksheetXml::default();
1426        set_panes(&mut ws, "B2").unwrap();
1427        assert!(get_panes(&ws).is_some());
1428
1429        unset_panes(&mut ws);
1430        assert!(get_panes(&ws).is_none());
1431        // SheetViews should still exist but without pane.
1432        let view = &ws.sheet_views.as_ref().unwrap().sheet_views[0];
1433        assert!(view.pane.is_none());
1434        assert!(view.selection.is_empty());
1435    }
1436
1437    #[test]
1438    fn test_get_panes_none_when_not_set() {
1439        let ws = WorksheetXml::default();
1440        assert!(get_panes(&ws).is_none());
1441    }
1442
1443    #[test]
1444    fn test_get_panes_returns_value_after_set() {
1445        let mut ws = WorksheetXml::default();
1446        set_panes(&mut ws, "C5").unwrap();
1447        assert_eq!(get_panes(&ws), Some("C5".to_string()));
1448    }
1449
1450    #[test]
1451    fn test_set_panes_selection_has_pane_attribute() {
1452        let mut ws = WorksheetXml::default();
1453        set_panes(&mut ws, "B2").unwrap();
1454
1455        let selection = &ws.sheet_views.as_ref().unwrap().sheet_views[0].selection[0];
1456        assert_eq!(selection.pane, Some("bottomRight".to_string()));
1457        assert_eq!(selection.active_cell, Some("B2".to_string()));
1458        assert_eq!(selection.sqref, Some("B2".to_string()));
1459    }
1460
1461    #[test]
1462    fn test_set_panes_overwrites_previous() {
1463        let mut ws = WorksheetXml::default();
1464        set_panes(&mut ws, "A2").unwrap();
1465        assert_eq!(get_panes(&ws), Some("A2".to_string()));
1466
1467        set_panes(&mut ws, "C3").unwrap();
1468        assert_eq!(get_panes(&ws), Some("C3".to_string()));
1469
1470        let pane = ws.sheet_views.as_ref().unwrap().sheet_views[0]
1471            .pane
1472            .as_ref()
1473            .unwrap();
1474        assert_eq!(pane.x_split, Some(2));
1475        assert_eq!(pane.y_split, Some(2));
1476        assert_eq!(pane.active_pane, Some("bottomRight".to_string()));
1477    }
1478
1479    #[test]
1480    fn test_unset_panes_noop_when_no_views() {
1481        let mut ws = WorksheetXml::default();
1482        // Should not panic when there are no sheet views.
1483        unset_panes(&mut ws);
1484        assert!(get_panes(&ws).is_none());
1485    }
1486
1487    #[test]
1488    fn test_view_mode_as_str() {
1489        assert_eq!(ViewMode::Normal.as_str(), "normal");
1490        assert_eq!(ViewMode::PageBreak.as_str(), "pageBreakPreview");
1491        assert_eq!(ViewMode::PageLayout.as_str(), "pageLayout");
1492    }
1493
1494    #[test]
1495    fn test_view_mode_from_str() {
1496        assert_eq!(ViewMode::from_xml_str("normal"), Some(ViewMode::Normal));
1497        assert_eq!(
1498            ViewMode::from_xml_str("pageBreakPreview"),
1499            Some(ViewMode::PageBreak)
1500        );
1501        assert_eq!(
1502            ViewMode::from_xml_str("pageLayout"),
1503            Some(ViewMode::PageLayout)
1504        );
1505        assert_eq!(ViewMode::from_xml_str("unknown"), None);
1506    }
1507
1508    #[test]
1509    fn test_sheet_visibility_as_xml_str() {
1510        assert_eq!(SheetVisibility::Visible.as_xml_str(), None);
1511        assert_eq!(SheetVisibility::Hidden.as_xml_str(), Some("hidden"));
1512        assert_eq!(SheetVisibility::VeryHidden.as_xml_str(), Some("veryHidden"));
1513    }
1514
1515    #[test]
1516    fn test_sheet_visibility_from_xml_str() {
1517        assert_eq!(
1518            SheetVisibility::from_xml_str(None),
1519            SheetVisibility::Visible
1520        );
1521        assert_eq!(
1522            SheetVisibility::from_xml_str(Some("hidden")),
1523            SheetVisibility::Hidden
1524        );
1525        assert_eq!(
1526            SheetVisibility::from_xml_str(Some("veryHidden")),
1527            SheetVisibility::VeryHidden
1528        );
1529        assert_eq!(
1530            SheetVisibility::from_xml_str(Some("unknown")),
1531            SheetVisibility::Visible
1532        );
1533    }
1534
1535    #[test]
1536    fn test_set_sheet_view_options_basic() {
1537        let mut ws = WorksheetXml::default();
1538        let opts = SheetViewOptions {
1539            show_gridlines: Some(false),
1540            show_formulas: Some(true),
1541            zoom_scale: Some(200),
1542            view_mode: Some(ViewMode::PageBreak),
1543            top_left_cell: Some("B5".to_string()),
1544            show_row_col_headers: Some(false),
1545        };
1546        set_sheet_view_options(&mut ws, &opts).unwrap();
1547
1548        let result = get_sheet_view_options(&ws);
1549        assert_eq!(result.show_gridlines, Some(false));
1550        assert_eq!(result.show_formulas, Some(true));
1551        assert_eq!(result.zoom_scale, Some(200));
1552        assert_eq!(result.view_mode, Some(ViewMode::PageBreak));
1553        assert_eq!(result.top_left_cell, Some("B5".to_string()));
1554        assert_eq!(result.show_row_col_headers, Some(false));
1555    }
1556
1557    #[test]
1558    fn test_set_sheet_view_options_zoom_out_of_range() {
1559        let mut ws = WorksheetXml::default();
1560        let result = set_sheet_view_options(
1561            &mut ws,
1562            &SheetViewOptions {
1563                zoom_scale: Some(9),
1564                ..Default::default()
1565            },
1566        );
1567        assert!(result.is_err());
1568
1569        let result2 = set_sheet_view_options(
1570            &mut ws,
1571            &SheetViewOptions {
1572                zoom_scale: Some(401),
1573                ..Default::default()
1574            },
1575        );
1576        assert!(result2.is_err());
1577    }
1578
1579    #[test]
1580    fn test_set_sheet_view_options_preserves_existing() {
1581        let mut ws = WorksheetXml::default();
1582        set_sheet_view_options(
1583            &mut ws,
1584            &SheetViewOptions {
1585                zoom_scale: Some(150),
1586                ..Default::default()
1587            },
1588        )
1589        .unwrap();
1590        set_sheet_view_options(
1591            &mut ws,
1592            &SheetViewOptions {
1593                show_gridlines: Some(false),
1594                ..Default::default()
1595            },
1596        )
1597        .unwrap();
1598
1599        let result = get_sheet_view_options(&ws);
1600        assert_eq!(result.zoom_scale, Some(150));
1601        assert_eq!(result.show_gridlines, Some(false));
1602    }
1603
1604    #[test]
1605    fn test_get_sheet_view_options_defaults() {
1606        let ws = WorksheetXml::default();
1607        let opts = get_sheet_view_options(&ws);
1608        assert_eq!(opts.show_gridlines, Some(true));
1609        assert_eq!(opts.show_formulas, Some(false));
1610        assert_eq!(opts.show_row_col_headers, Some(true));
1611        assert_eq!(opts.zoom_scale, Some(100));
1612        assert_eq!(opts.view_mode, Some(ViewMode::Normal));
1613        assert!(opts.top_left_cell.is_none());
1614    }
1615
1616    #[test]
1617    fn test_set_sheet_view_options_does_not_break_panes() {
1618        let mut ws = WorksheetXml::default();
1619        set_panes(&mut ws, "B2").unwrap();
1620        set_sheet_view_options(
1621            &mut ws,
1622            &SheetViewOptions {
1623                zoom_scale: Some(120),
1624                ..Default::default()
1625            },
1626        )
1627        .unwrap();
1628
1629        assert_eq!(get_panes(&ws), Some("B2".to_string()));
1630        assert_eq!(get_sheet_view_options(&ws).zoom_scale, Some(120));
1631    }
1632}