openprxl 0.1.0

A Rust spreadsheet library inspired by Python's openpyxl
Documentation
//! Workbook API.

use crate::error::{Error, Result};
use crate::pack;
use crate::style::{Style, StyleId, StyleManager};
use crate::worksheet::Worksheet;
use std::path::Path;

/// A spreadsheet workbook.
#[derive(Debug)]
pub struct Workbook {
    pub(crate) sheets: Vec<Worksheet>,
    pub(crate) active_sheet: usize,
    pub(crate) style_manager: StyleManager,
    pub(crate) title: Option<String>,
    pub(crate) creator: Option<String>,
    pub(crate) next_sheet_id: u32,
}

impl Default for Workbook {
    fn default() -> Self {
        let mut wb = Self {
            sheets: Vec::new(),
            active_sheet: 0,
            style_manager: StyleManager::default(),
            title: None,
            creator: Some("openprxl".to_string()),
            next_sheet_id: 1,
        };
        wb.create_sheet("Sheet").unwrap();
        wb
    }
}

impl Workbook {
    /// Create a new workbook with a default "Sheet" worksheet.
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a workbook without any worksheets.
    pub fn empty() -> Self {
        Self {
            sheets: Vec::new(),
            active_sheet: 0,
            style_manager: StyleManager::default(),
            title: None,
            creator: Some("openprxl".to_string()),
            next_sheet_id: 1,
        }
    }

    /// Register a style and return its id.
    pub fn register_style(&mut self, style: Style) -> StyleId {
        self.style_manager.register(style)
    }

    /// Get a registered style by id.
    pub fn style(&self, id: StyleId) -> Option<&Style> {
        self.style_manager.get(id)
    }

    /// Create a new worksheet with the given name.
    pub fn create_sheet<S: Into<String>>(&mut self, name: S) -> Result<&mut Worksheet> {
        let name = name.into();
        validate_sheet_name(&name)?;
        if self.sheets.iter().any(|s| s.name == name) {
            return Err(Error::InvalidWorksheetName(format!(
                "worksheet name already exists: {name}"
            )));
        }
        let id = self.next_sheet_id;
        self.next_sheet_id += 1;
        self.sheets.push(Worksheet::new(name, id));
        let idx = self.sheets.len() - 1;
        self.active_sheet = idx;
        Ok(&mut self.sheets[idx])
    }

    /// Get the number of worksheets.
    pub fn sheet_count(&self) -> usize {
        self.sheets.len()
    }

    /// Get a worksheet by index.
    pub fn sheet(&self, index: usize) -> Option<&Worksheet> {
        self.sheets.get(index)
    }

    /// Get a worksheet by index mutably.
    pub fn sheet_mut(&mut self, index: usize) -> Option<&mut Worksheet> {
        self.sheets.get_mut(index)
    }

    /// Get a worksheet by name.
    pub fn sheet_by_name(&self, name: &str) -> Option<&Worksheet> {
        self.sheets.iter().find(|s| s.name == name)
    }

    /// Get a worksheet by name mutably.
    pub fn sheet_by_name_mut(&mut self, name: &str) -> Option<&mut Worksheet> {
        self.sheets.iter_mut().find(|s| s.name == name)
    }

    /// Get the currently active worksheet.
    pub fn active_sheet(&self) -> &Worksheet {
        &self.sheets[self.active_sheet]
    }

    /// Get the currently active worksheet mutably.
    pub fn active_sheet_mut(&mut self) -> &mut Worksheet {
        let idx = self.active_sheet;
        &mut self.sheets[idx]
    }

    /// Set the active worksheet by index.
    pub fn set_active_sheet(&mut self, index: usize) -> Result<()> {
        if index >= self.sheets.len() {
            return Err(Error::WorksheetNotFound(format!(
                "index {index} out of range"
            )));
        }
        self.active_sheet = index;
        Ok(())
    }

    /// Remove a worksheet by index.
    pub fn remove_sheet(&mut self, index: usize) -> Result<Worksheet> {
        if index >= self.sheets.len() {
            return Err(Error::WorksheetNotFound(format!(
                "index {index} out of range"
            )));
        }
        let sheet = self.sheets.remove(index);
        if self.active_sheet >= self.sheets.len() && !self.sheets.is_empty() {
            self.active_sheet = self.sheets.len() - 1;
        }
        Ok(sheet)
    }

    /// Iterate over all worksheets.
    pub fn sheets(&self) -> &[Worksheet] {
        &self.sheets
    }

    /// Iterate over all worksheets mutably.
    pub fn sheets_mut(&mut self) -> &mut [Worksheet] {
        &mut self.sheets
    }

    /// Set the workbook title.
    pub fn set_title<S: Into<String>>(&mut self, title: S) {
        self.title = Some(title.into());
    }

    /// Get the workbook title.
    pub fn title(&self) -> Option<&str> {
        self.title.as_deref()
    }

    /// Set the workbook creator.
    pub fn set_creator<S: Into<String>>(&mut self, creator: S) {
        self.creator = Some(creator.into());
    }

    /// Get the workbook creator.
    pub fn creator(&self) -> Option<&str> {
        self.creator.as_deref()
    }

    /// Save the workbook to the given path.
    pub fn save<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
        pack::write_workbook(self, path.as_ref())
    }

    /// Load a workbook from the given path.
    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
        pack::read_workbook(path.as_ref())
    }
}

fn validate_sheet_name(name: &str) -> Result<()> {
    if name.is_empty() {
        return Err(Error::InvalidWorksheetName(
            "worksheet name cannot be empty".to_string(),
        ));
    }
    if name.len() > 31 {
        return Err(Error::InvalidWorksheetName(format!(
            "worksheet name too long: {name}"
        )));
    }
    let forbidden = ['/', '\\', '?', '*', ':', '[', ']'];
    if name.chars().any(|c| forbidden.contains(&c)) {
        return Err(Error::InvalidWorksheetName(format!(
            "worksheet name contains invalid characters: {name}"
        )));
    }
    Ok(())
}