spreadsheet-kit 0.11.1

Core spreadsheet automation primitives — shared types, edit normalization, and session traits for agent-facing surfaces
Documentation
use schemars::JsonSchema;
use serde::de;
use serde::{Deserialize, Serialize};
use std::fmt;

fn normalize_literal(value: &str) -> String {
    value
        .chars()
        .filter(|ch| ch.is_ascii_alphanumeric())
        .flat_map(|ch| ch.to_lowercase())
        .collect()
}

fn levenshtein_distance(left: &str, right: &str) -> usize {
    if left.is_empty() {
        return right.chars().count();
    }
    if right.is_empty() {
        return left.chars().count();
    }

    let right_chars: Vec<char> = right.chars().collect();
    let mut previous: Vec<usize> = (0..=right_chars.len()).collect();
    let mut current = vec![0; right_chars.len() + 1];

    for (i, left_ch) in left.chars().enumerate() {
        current[0] = i + 1;
        for (j, right_ch) in right_chars.iter().enumerate() {
            let substitution_cost = if left_ch == *right_ch { 0 } else { 1 };
            current[j + 1] = (previous[j + 1] + 1)
                .min(current[j] + 1)
                .min(previous[j] + substitution_cost);
        }
        std::mem::swap(&mut previous, &mut current);
    }

    previous[right_chars.len()]
}

fn suggest_literal<'a>(input: &str, valid: &'a [&'a str]) -> Option<&'a str> {
    let normalized_input = normalize_literal(input);
    let mut best: Option<(&str, usize)> = None;

    for candidate in valid {
        let distance = levenshtein_distance(&normalized_input, &normalize_literal(candidate));
        match best {
            Some((_, best_distance)) if distance >= best_distance => {}
            _ => best = Some((candidate, distance)),
        }
    }

    match best {
        Some((candidate, distance)) if distance <= 6 => Some(candidate),
        _ => None,
    }
}

fn enum_value_error(label: &str, input: &str, valid: &[&str], suggestion: Option<&str>) -> String {
    let valid_list = valid.join("|");
    match suggestion {
        Some(candidate) if !candidate.eq_ignore_ascii_case(input) => {
            format!("invalid {label} '{input}'. Did you mean '{candidate}'? valid: {valid_list}")
        }
        _ => format!("invalid {label} '{input}'. valid: {valid_list}"),
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum BatchMode {
    #[default]
    Apply,
    Preview,
}

impl BatchMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Apply => "apply",
            Self::Preview => "preview",
        }
    }

    pub fn is_preview(self) -> bool {
        matches!(self, Self::Preview)
    }
}

impl fmt::Display for BatchMode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for BatchMode {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: de::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.to_ascii_lowercase().as_str() {
            "apply" => Ok(Self::Apply),
            "preview" => Ok(Self::Preview),
            other => {
                let valid = ["apply", "preview"];
                let message =
                    enum_value_error("batch_mode", other, &valid, suggest_literal(other, &valid));
                Err(de::Error::custom(message))
            }
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ReplaceMatchMode {
    #[default]
    Exact,
    Contains,
}

impl ReplaceMatchMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Exact => "exact",
            Self::Contains => "contains",
        }
    }
}

impl<'de> Deserialize<'de> for ReplaceMatchMode {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: de::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.to_ascii_lowercase().as_str() {
            "exact" => Ok(Self::Exact),
            "contains" => Ok(Self::Contains),
            other => {
                let valid = ["exact", "contains"];
                let message =
                    enum_value_error("match_mode", other, &valid, suggest_literal(other, &valid));
                Err(de::Error::custom(message))
            }
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum FillDirection {
    Down,
    Right,
    #[default]
    Both,
}

impl FillDirection {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Down => "down",
            Self::Right => "right",
            Self::Both => "both",
        }
    }
}

impl<'de> Deserialize<'de> for FillDirection {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: de::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.to_ascii_lowercase().as_str() {
            "down" => Ok(Self::Down),
            "right" => Ok(Self::Right),
            "both" => Ok(Self::Both),
            other => {
                let valid = ["down", "right", "both"];
                let message = enum_value_error(
                    "fill_direction",
                    other,
                    &valid,
                    suggest_literal(other, &valid),
                );
                Err(de::Error::custom(message))
            }
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum FormulaRelativeMode {
    #[default]
    Excel,
    AbsCols,
    AbsRows,
}

impl<'de> Deserialize<'de> for FormulaRelativeMode {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: de::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.to_ascii_lowercase().as_str() {
            "excel" => Ok(Self::Excel),
            "abs_cols" | "abscols" | "columns_absolute" => Ok(Self::AbsCols),
            "abs_rows" | "absrows" | "rows_absolute" => Ok(Self::AbsRows),
            other => {
                let valid = ["excel", "abs_cols", "abs_rows"];
                let direct_suggestion = match other {
                    "fully_relative" | "fullyrelative" => Some("excel"),
                    _ => None,
                };
                let message = enum_value_error(
                    "relative_mode",
                    other,
                    &valid,
                    direct_suggestion.or_else(|| suggest_literal(other, &valid)),
                );
                Err(de::Error::custom(message))
            }
        }
    }
}

impl From<FormulaRelativeMode> for crate::formula::pattern::RelativeMode {
    fn from(value: FormulaRelativeMode) -> Self {
        match value {
            FormulaRelativeMode::Excel => Self::Excel,
            FormulaRelativeMode::AbsCols => Self::AbsCols,
            FormulaRelativeMode::AbsRows => Self::AbsRows,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PageOrientation {
    Portrait,
    Landscape,
}

impl<'de> Deserialize<'de> for PageOrientation {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: de::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.to_ascii_lowercase().as_str() {
            "portrait" => Ok(Self::Portrait),
            "landscape" => Ok(Self::Landscape),
            other => {
                let valid = ["portrait", "landscape"];
                let message = enum_value_error(
                    "page_orientation",
                    other,
                    &valid,
                    suggest_literal(other, &valid),
                );
                Err(de::Error::custom(message))
            }
        }
    }
}

impl PageOrientation {
    pub fn to_umya(self) -> umya_spreadsheet::OrientationValues {
        match self {
            Self::Portrait => umya_spreadsheet::OrientationValues::Portrait,
            Self::Landscape => umya_spreadsheet::OrientationValues::Landscape,
        }
    }
}