cronspeak 0.1.1

Convert cron expressions into clear, human-readable English descriptions (like cronstrue / cron-descriptor). Zero-dependency, no_std.
Documentation
//! A single parsed piece of a cron field, plus per-field parsing.

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

use crate::parse::CronError;

/// One comma-separated component of a cron field, already validated against the
/// field's allowed range.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Segment {
    /// `*` — every value in the field.
    All,
    /// `*/n` — every `n`th value across the whole field.
    Step(u32),
    /// A single value `a`.
    Value(u32),
    /// An inclusive range `a-b`.
    Range(u32, u32),
    /// A stepped range `a-b/n` (or `a/n`, where `b` is the field maximum).
    RangeStep(u32, u32, u32),
}

/// Which field is being parsed (controls range and whether names are accepted).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Kind {
    Minute,
    Hour,
    Dom,
    Month,
    Dow,
}

impl Kind {
    /// Inclusive `(min, max)` accepted for the field. Day-of-week allows 7
    /// (an alias for Sunday) on input.
    const fn bounds(self) -> (u32, u32) {
        match self {
            Kind::Minute => (0, 59),
            Kind::Hour => (0, 23),
            Kind::Dom => (1, 31),
            Kind::Month => (1, 12),
            Kind::Dow => (0, 7),
        }
    }

    /// The largest *displayable* value, used as the implicit upper bound of the
    /// `a/n` step form. Day-of-week tops out at Saturday (6), since 7 == Sunday.
    const fn display_max(self) -> u32 {
        match self {
            Kind::Dow => 6,
            _ => self.bounds().1,
        }
    }
}

/// Parse a whole cron field (which may be a comma-separated list) into segments.
pub(crate) fn parse_field(raw: &str, kind: Kind) -> Result<Vec<Segment>, CronError> {
    if raw.is_empty() {
        return Err(CronError::InvalidField(String::from(raw)));
    }
    raw.split(',').map(|part| parse_part(part, kind)).collect()
}

fn parse_part(part: &str, kind: Kind) -> Result<Segment, CronError> {
    let invalid = || CronError::InvalidField(String::from(part));

    if let Some((base, step_str)) = part.split_once('/') {
        let step: u32 = step_str
            .parse()
            .map_err(|_| CronError::InvalidStep(String::from(part)))?;
        if step == 0 {
            return Err(CronError::InvalidStep(String::from(part)));
        }
        if base == "*" {
            return Ok(if step == 1 {
                Segment::All
            } else {
                Segment::Step(step)
            });
        }
        if let Some((a, b)) = base.split_once('-') {
            let a = resolve(a, kind).map_err(|_| invalid())?;
            let b = resolve(b, kind).map_err(|_| invalid())?;
            if a > b {
                return Err(invalid());
            }
            return Ok(Segment::RangeStep(a, b, step));
        }
        let a = resolve(base, kind).map_err(|_| invalid())?;
        return Ok(Segment::RangeStep(a, kind.display_max(), step));
    }

    if part == "*" {
        return Ok(Segment::All);
    }

    if let Some((a, b)) = part.split_once('-') {
        let a = resolve(a, kind).map_err(|_| invalid())?;
        let b = resolve(b, kind).map_err(|_| invalid())?;
        if a > b {
            return Err(invalid());
        }
        return Ok(Segment::Range(a, b));
    }

    Ok(Segment::Value(resolve(part, kind).map_err(|_| invalid())?))
}

/// Resolve a single token (number or name) to a validated value.
///
/// Day-of-week 7 is kept as-is here (an alias for Sunday) and rendered as Sunday
/// at description time, so that ranges like `5-7` keep a valid `a <= b` order.
fn resolve(tok: &str, kind: Kind) -> Result<u32, CronError> {
    if tok.is_empty() {
        return Err(CronError::InvalidField(String::from(tok)));
    }
    let raw = match tok.parse::<u32>() {
        Ok(n) => n,
        Err(_) => {
            name_to_num(tok, kind).ok_or_else(|| CronError::InvalidField(String::from(tok)))?
        }
    };
    let (min, max) = kind.bounds();
    if raw < min || raw > max {
        return Err(CronError::InvalidField(String::from(tok)));
    }
    Ok(raw)
}

/// Map a month/day name to its number, accepting the canonical 3-letter
/// abbreviation or the full name (case-insensitive). Anything else is rejected.
fn name_to_num(tok: &str, kind: Kind) -> Option<u32> {
    let lower = tok.to_ascii_lowercase();
    let table: &[(&str, &str, u32)] = match kind {
        Kind::Month => &[
            ("jan", "january", 1),
            ("feb", "february", 2),
            ("mar", "march", 3),
            ("apr", "april", 4),
            ("may", "may", 5),
            ("jun", "june", 6),
            ("jul", "july", 7),
            ("aug", "august", 8),
            ("sep", "september", 9),
            ("oct", "october", 10),
            ("nov", "november", 11),
            ("dec", "december", 12),
        ],
        Kind::Dow => &[
            ("sun", "sunday", 0),
            ("mon", "monday", 1),
            ("tue", "tuesday", 2),
            ("wed", "wednesday", 3),
            ("thu", "thursday", 4),
            ("fri", "friday", 5),
            ("sat", "saturday", 6),
        ],
        _ => return None,
    };
    table
        .iter()
        .find(|(abbr, full, _)| lower == *abbr || lower == *full)
        .map(|(_, _, n)| *n)
}