cronspeak 0.1.1

Convert cron expressions into clear, human-readable English descriptions (like cronstrue / cron-descriptor). Zero-dependency, no_std.
Documentation
//! Parse a cron expression into a validated [`Schedule`].

use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;

use crate::segment::{parse_field, Kind, Segment};

/// An error produced while parsing a cron expression.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CronError {
    /// The input was empty or only whitespace.
    Empty,
    /// The expression did not have exactly five fields.
    FieldCount {
        /// Number of fields expected (always 5).
        expected: usize,
        /// Number of fields actually found.
        got: usize,
    },
    /// A step (`*/n`) was zero or not a number.
    InvalidStep(String),
    /// A field value, name, or range was invalid or out of range.
    InvalidField(String),
}

impl fmt::Display for CronError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CronError::Empty => f.write_str("empty cron expression"),
            CronError::FieldCount { expected, got } => {
                write!(f, "expected {expected} fields, found {got}")
            }
            CronError::InvalidStep(s) => write!(f, "invalid step: {s:?}"),
            CronError::InvalidField(s) => write!(f, "invalid field: {s:?}"),
        }
    }
}

impl core::error::Error for CronError {}

/// A fully parsed, validated cron schedule (five fields).
pub(crate) struct Schedule {
    pub minute: Vec<Segment>,
    pub hour: Vec<Segment>,
    pub dom: Vec<Segment>,
    pub month: Vec<Segment>,
    pub dow: Vec<Segment>,
}

pub(crate) fn parse(expr: &str) -> Result<Schedule, CronError> {
    let trimmed = expr.trim();
    if trimmed.is_empty() {
        return Err(CronError::Empty);
    }

    let expanded = expand_macro(trimmed).unwrap_or(trimmed);
    let fields: Vec<&str> = expanded.split_whitespace().collect();
    if fields.len() != 5 {
        return Err(CronError::FieldCount {
            expected: 5,
            got: fields.len(),
        });
    }

    Ok(Schedule {
        minute: parse_field(fields[0], Kind::Minute)?,
        hour: parse_field(fields[1], Kind::Hour)?,
        dom: parse_field(fields[2], Kind::Dom)?,
        month: parse_field(fields[3], Kind::Month)?,
        dow: parse_field(fields[4], Kind::Dow)?,
    })
}

/// Expand a `@`-macro to its equivalent 5-field expression.
fn expand_macro(s: &str) -> Option<&'static str> {
    match s {
        "@yearly" | "@annually" => Some("0 0 1 1 *"),
        "@monthly" => Some("0 0 1 * *"),
        "@weekly" => Some("0 0 * * 0"),
        "@daily" | "@midnight" => Some("0 0 * * *"),
        "@hourly" => Some("0 * * * *"),
        _ => None,
    }
}