rulemorph 0.3.4

YAML-based declarative data transformation engine for CSV/JSON to JSON
Documentation
use crate::error::{TransformError, TransformErrorKind};

#[derive(Clone, Copy)]
pub(crate) struct CellWindow {
    pub(crate) start_row: usize,
    pub(crate) end_row: Option<usize>,
    pub(crate) start_col: usize,
    pub(crate) end_col: Option<usize>,
}

pub(crate) struct ParsedCellRef {
    pub(crate) col: usize,
    pub(crate) row: Option<usize>,
}

pub(crate) fn parse_cell_window(range: Option<&str>) -> Result<CellWindow, TransformError> {
    let Some(range) = range else {
        return Ok(CellWindow {
            start_row: 0,
            end_row: None,
            start_col: 0,
            end_col: None,
        });
    };
    let (start, end) = range
        .split_once(':')
        .ok_or_else(|| invalid("Excel range must use A:D or A1:D100 form"))?;
    let start = parse_cell_ref(start)?;
    let end = parse_cell_ref(end)?;
    if start.col > end.col {
        return Err(invalid("Excel range start column is after end column"));
    }
    if let (Some(start_row), Some(end_row)) = (start.row, end.row)
        && start_row > end_row
    {
        return Err(invalid("Excel range start row is after end row"));
    }
    Ok(CellWindow {
        start_row: start.row.unwrap_or(0),
        end_row: end.row,
        start_col: start.col,
        end_col: Some(end.col),
    })
}

pub(crate) fn parse_cell_ref(value: &str) -> Result<ParsedCellRef, TransformError> {
    let mut letters = String::new();
    let mut digits = String::new();
    for ch in value.chars() {
        if ch.is_ascii_alphabetic() && digits.is_empty() {
            letters.push(ch.to_ascii_uppercase());
        } else if ch.is_ascii_digit() {
            digits.push(ch);
        } else {
            return Err(invalid("Excel cell reference is invalid"));
        }
    }
    if letters.is_empty() {
        return Err(invalid("Excel cell reference requires a column"));
    }
    let col = column_letters_to_index(&letters)?;
    let row = if digits.is_empty() {
        None
    } else {
        let row = digits
            .parse::<usize>()
            .map_err(|_| invalid("Excel cell reference row is invalid"))?;
        if row == 0 {
            return Err(invalid("Excel cell reference row must be 1-based"));
        }
        Some(row - 1)
    };
    Ok(ParsedCellRef { col, row })
}

pub(crate) fn column_letters_to_index(value: &str) -> Result<usize, TransformError> {
    let mut index = 0usize;
    for ch in value.chars() {
        if !ch.is_ascii_alphabetic() {
            return Err(invalid("Excel column reference is invalid"));
        }
        let value = (ch.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
        index = index
            .checked_mul(26)
            .and_then(|index| index.checked_add(value))
            .ok_or_else(|| invalid("Excel column reference is too large"))?;
    }
    if index == 0 {
        return Err(invalid("Excel column reference is required"));
    }
    Ok(index - 1)
}

fn invalid(message: &str) -> TransformError {
    TransformError::new(TransformErrorKind::InvalidInput, message)
}