cronspeak 0.1.1

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

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

use crate::names::{capitalize, clock, day_name, human_list, month_name};
use crate::parse::Schedule;
use crate::segment::Segment;
use crate::{Options, TimeFormat};

pub(crate) fn describe(schedule: &Schedule, options: Options) -> String {
    let fmt = options.format();
    let mut clauses: Vec<String> = Vec::new();
    clauses.push(time_clause(&schedule.minute, &schedule.hour, fmt));

    let has_day_of_month = !is_all(&schedule.dom);
    let has_weekday = !is_all(&schedule.dow);

    // When BOTH day fields are restricted, standard cron runs on EITHER match
    // (OR semantics), so the two clauses are joined with "or".
    if has_day_of_month && has_weekday {
        clauses.push(format!(
            "{} or {}",
            dom_text(&schedule.dom),
            dow_text(&schedule.dow, true)
        ));
    } else if has_day_of_month {
        clauses.push(dom_text(&schedule.dom));
    }

    if let Some(c) = month_clause(&schedule.month) {
        clauses.push(c);
    }

    if has_weekday && !has_day_of_month {
        clauses.push(dow_text(&schedule.dow, false));
    }

    clauses.join(", ")
}

// --- field shape helpers ---------------------------------------------------

fn is_all(field: &[Segment]) -> bool {
    matches!(field, [Segment::All])
}

fn single(field: &[Segment]) -> Option<u32> {
    match field {
        [Segment::Value(v)] => Some(*v),
        _ => None,
    }
}

fn step(field: &[Segment]) -> Option<u32> {
    match field {
        [Segment::Step(n)] => Some(*n),
        _ => None,
    }
}

/// If every segment is a plain `Value`, collect them.
fn all_values(field: &[Segment]) -> Option<Vec<u32>> {
    field
        .iter()
        .map(|s| match s {
            Segment::Value(v) => Some(*v),
            _ => None,
        })
        .collect()
}

// --- grammar helpers -------------------------------------------------------

/// Pluralize a unit noun by count: `(1, "minute") -> "minute"`, else `"minutes"`.
fn plural(n: u32, unit: &str) -> String {
    if n == 1 {
        String::from(unit)
    } else {
        format!("{unit}s")
    }
}

/// `"every minute"` for `n == 1`, otherwise `"every n minutes"`.
fn every_unit(n: u32, unit: &str) -> String {
    if n == 1 {
        format!("every {unit}")
    } else {
        format!("every {n} {unit}s")
    }
}

// --- time-of-day clause ----------------------------------------------------

fn time_clause(minute: &[Segment], hour: &[Segment], fmt: TimeFormat) -> String {
    if let (Some(m), Some(h)) = (single(minute), single(hour)) {
        return format!("At {}", clock(h, m, fmt));
    }
    // A fixed minute across an explicit list of hours: "At 12:00 AM and 12:00 PM".
    if let (Some(m), Some(hours)) = (single(minute), all_values(hour)) {
        let times: Vec<String> = hours.iter().map(|&h| clock(h, m, fmt)).collect();
        return format!("At {}", human_list(&times));
    }
    if is_all(minute) && is_all(hour) {
        return String::from("Every minute");
    }

    let minute_fixed = single(minute).is_some();

    if is_all(hour) {
        if let Some(n) = step(minute) {
            return format!("Every {n} minutes");
        }
        if let Some(m) = single(minute) {
            return format!("At {m} {} past the hour", plural(m, "minute"));
        }
        if let Some(vals) = all_values(minute) {
            return format!("At minutes {} past the hour", number_list(&vals));
        }
        return capitalize(&minute_phrase(minute));
    }
    if is_all(minute) {
        return format!("Every minute, {}", hour_phrase(hour, fmt, false));
    }
    format!(
        "{}, {}",
        capitalize(&minute_phrase(minute)),
        hour_phrase(hour, fmt, minute_fixed)
    )
}

fn minute_phrase(minute: &[Segment]) -> String {
    if is_all(minute) {
        return String::from("every minute");
    }
    if let Some(n) = step(minute) {
        return format!("every {n} minutes");
    }
    match minute {
        [Segment::Value(m)] => format!("at minute {m}"),
        [Segment::Range(a, b)] => format!("every minute from {a} through {b}"),
        [Segment::RangeStep(a, b, n)] => {
            format!("{} from {a} through {b}", every_unit(*n, "minute"))
        }
        _ => match all_values(minute) {
            Some(vals) => format!("at minutes {}", number_list(&vals)),
            None => String::from("at the listed minutes"),
        },
    }
}

/// Describe the active hour window. `minute_fixed` is true when the minute field
/// is a single value, so the window ends on the hour (`b:00`); otherwise the
/// minute can be anything in the hour, so the window extends to `b:59`.
fn hour_phrase(hour: &[Segment], fmt: TimeFormat, minute_fixed: bool) -> String {
    let end_min = if minute_fixed { 0 } else { 59 };
    match hour {
        [Segment::Step(n)] => format!("every {n} hours"),
        [Segment::Value(h)] => {
            format!(
                "between {} and {}",
                clock(*h, 0, fmt),
                clock(*h, end_min, fmt)
            )
        }
        [Segment::Range(a, b)] => {
            format!(
                "between {} and {}",
                clock(*a, 0, fmt),
                clock(*b, end_min, fmt)
            )
        }
        [Segment::RangeStep(a, b, n)] => format!(
            "every {n} hours between {} and {}",
            clock(*a, 0, fmt),
            clock(*b, 0, fmt)
        ),
        _ => match all_values(hour) {
            Some(vals) => {
                let times: Vec<String> = vals.iter().map(|&h| clock(h, 0, fmt)).collect();
                format!("at {}", human_list(&times))
            }
            None => String::from("during the listed hours"),
        },
    }
}

// --- date clauses ----------------------------------------------------------

fn dom_text(dom: &[Segment]) -> String {
    match dom {
        [Segment::Value(d)] => format!("on day {d} of the month"),
        [Segment::Range(a, b)] => format!("on days {a} through {b} of the month"),
        [Segment::Step(n)] => format!("every {n} days of the month"),
        [Segment::RangeStep(a, b, n)] => {
            format!(
                "{} from {a} through {b} of the month",
                every_unit(*n, "day")
            )
        }
        _ => match all_values(dom) {
            Some(vals) => format!("on days {} of the month", number_list(&vals)),
            None => String::from("on the listed days of the month"),
        },
    }
}

fn month_clause(month: &[Segment]) -> Option<String> {
    if is_all(month) {
        return None;
    }
    Some(match month {
        [Segment::Value(m)] => format!("only in {}", month_name(*m)),
        [Segment::Range(a, b)] => format!("{} through {}", month_name(*a), month_name(*b)),
        [Segment::Step(n)] => format!("every {n} months"),
        [Segment::RangeStep(a, b, n)] => format!(
            "{} from {} through {}",
            every_unit(*n, "month"),
            month_name(*a),
            month_name(*b)
        ),
        _ => match all_values(month) {
            Some(vals) => {
                let names: Vec<String> = vals.iter().map(|&m| month_name(m)).collect();
                format!("only in {}", human_list(&names))
            }
            None => String::from("in the listed months"),
        },
    })
}

/// Day-of-week clause. In `or_mode` (when day-of-month is also restricted) the
/// phrasing uses a leading "on" instead of "only on", so it reads naturally
/// after an "… or".
fn dow_text(dow: &[Segment], or_mode: bool) -> String {
    let only = if or_mode { "on" } else { "only on" };
    match dow {
        [Segment::Value(w)] => format!("{only} {}", day_name(*w)),
        [Segment::Range(a, b)] => {
            let body = format!("{} through {}", day_name(*a), day_name(*b));
            if or_mode {
                format!("on {body}")
            } else {
                body
            }
        }
        [Segment::Step(n)] => format!("every {n} days of the week"),
        [Segment::RangeStep(a, b, n)] => format!(
            "{} from {} through {}",
            every_unit(*n, "day"),
            day_name(*a),
            day_name(*b)
        ),
        _ => match all_values(dow) {
            Some(vals) => {
                let names: Vec<String> = vals.iter().map(|&w| day_name(w)).collect();
                format!("{only} {}", human_list(&names))
            }
            None => String::from("on the listed days of the week"),
        },
    }
}

/// Join numbers in English: `"1"`, `"1 and 15"`, `"1, 3 and 5"`.
fn number_list(vals: &[u32]) -> String {
    let items: Vec<String> = vals.iter().map(|v| format!("{v}")).collect();
    human_list(&items)
}