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