Cron Expression Explainer — Rust source
Translate any 5-field cron expression into plain English, build one field-by-field, and preview the next time it will fire. Supports steps, ranges, lists, and named days/months. Runs 100% in your browser.
This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! # cron-explainer — Rust polyglot showcase port.
//!
//! Language: Rust
//! CosmoDev polyglot showcase port of the "cron-explainer" tool.
//! Ported from src/lib/cron-explainer.ts — display source, part of CosmoDev's
//! polyglot tool pages (dev.cosmolabs.org). License: MIT.
//!
//! Pure 5-field cron parser, natural-language explainer, builder, and next-run
//! calculator. Fully deterministic: every function depends only on its inputs.
//!
//! ## Date handling (no stdlib datetime)
//!
//! Rust's standard library ships no civil-calendar type, and this showcase
//! stays stdlib-only (no `chrono`). The `next_run` scanner therefore works
//! against a tiny [`CivilTime`] value-object plus Howard Hinnant's
//! proleptic-Gregorian serial-day algorithms (`days_from_civil` /
//! `civil_from_days`). These are compact, era-correct, and the idiomatic
//! stdlib-only choice — `time::Date` overflow rules are reproduced by
//! ordinary integer arithmetic on the serial day count, so the scanner
//! behaves exactly like the JavaScript reference's `setUTC*` family.
//!
//! The public surface mirrors the TypeScript reference: `explain_cron`,
//! `build_cron`, `next_run`.
use std::collections::HashSet;
// ─── Field model ────────────────────────────────────────────────────────────
//
// The five cron fields in positional order, each with its numeric range and
// whether it accepts named tokens (JAN..DEC / SUN..SAT). `wrap_max` is true
// only for day-of-week, where 7 is an alias for 0 (Sunday).
/// Positional name of a cron field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldName {
Minute,
Hour,
DayOfMonth,
Month,
DayOfWeek,
}
impl FieldName {
/// The human label used in error messages and in pluralised descriptions.
fn as_str(self) -> &'static str {
match self {
FieldName::Minute => "minute",
FieldName::Hour => "hour",
FieldName::DayOfMonth => "day-of-month",
FieldName::Month => "month",
FieldName::DayOfWeek => "day-of-week",
}
}
}
/// Per-field metadata: numeric range plus parsing rules.
#[derive(Debug, Clone, Copy)]
struct FieldMeta {
name: FieldName,
min: i64,
max: i64,
named: bool,
wrap_max: bool,
}
/// The positional field table, indexed 0..4.
const FIELDS: [FieldMeta; 5] = [
FieldMeta {
name: FieldName::Minute,
min: 0,
max: 59,
named: false,
wrap_max: false,
},
FieldMeta {
name: FieldName::Hour,
min: 0,
max: 23,
named: false,
wrap_max: false,
},
FieldMeta {
name: FieldName::DayOfMonth,
min: 1,
max: 31,
named: false,
wrap_max: false,
},
FieldMeta {
name: FieldName::Month,
min: 1,
max: 12,
named: true,
wrap_max: false,
},
FieldMeta {
name: FieldName::DayOfWeek,
min: 0,
max: 7,
named: true,
wrap_max: true,
},
];
const MONTH_NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const DOW_NAMES: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
// Token tables as (token, value) pairs so iteration order is fixed (Rust maps
// are unordered). Order is irrelevant to the result here — no token is a
// substring of another — but a fixed order keeps the showcase deterministic.
const MONTH_TOKENS: [(&str, i64); 12] = [
("JAN", 1),
("FEB", 2),
("MAR", 3),
("APR", 4),
("MAY", 5),
("JUN", 6),
("JUL", 7),
("AUG", 8),
("SEP", 9),
("OCT", 10),
("NOV", 11),
("DEC", 12),
];
const DOW_TOKENS: [(&str, i64); 7] = [
("SUN", 0),
("MON", 1),
("TUE", 2),
("WED", 3),
("THU", 4),
("FRI", 5),
("SAT", 6),
];
fn pad2(n: i64) -> String {
if n < 10 {
format!("0{n}")
} else {
n.to_string()
}
}
fn month_name(m: i64) -> String {
MONTH_NAMES[(m - 1) as usize].to_string()
}
fn dow_name(d: i64) -> String {
DOW_NAMES[(d % 7) as usize].to_string()
}
/// Inclusive integer range, e.g. inclusive_range(1, 5) -> [1, 2, 3, 4, 5].
fn inclusive_range(lo: i64, hi: i64) -> Vec<i64> {
(lo..=hi).collect()
}
/// Parse a strictly-numeric token (ASCII digits only). Rejects named tokens,
/// signs, and surrounding garbage so malformed fields surface clearly.
fn parse_int_strict(s: &str, label: &str) -> Result<i64, String> {
let t = s.trim();
if t.is_empty() || !t.bytes().all(|b| b.is_ascii_digit()) {
return Err(format!("{label}: invalid number \"{s}\""));
}
t.parse::<i64>()
.map_err(|_| format!("{label}: invalid number \"{s}\""))
}
/// Replace named tokens (JAN..DEC / SUN..SAT) with their numeric values.
/// Global substring replacement means ranges like "JUN-AUG" and lists like
/// "MON,WED,FRI" normalise in a single pass over the field.
fn normalize(value: &str, meta: &FieldMeta) -> String {
let mut v = value.trim().to_uppercase();
if !meta.named {
return v;
}
let tokens = if matches!(meta.name, FieldName::Month) {
&MONTH_TOKENS[..]
} else {
&DOW_TOKENS[..]
};
for (tok, num) in tokens.iter() {
v = v.replace(tok, &num.to_string());
}
v
}
/// A field after expansion: the matched values plus the raw token and a flag
/// distinguishing a bare `*` (wildcard) from an explicit enumeration.
#[derive(Clone)]
struct ParsedField {
meta: FieldMeta,
raw: String,
values: Vec<i64>,
wildcard: bool,
}
/// Expand one field value into the explicit set of numbers it matches.
///
/// Handles `*`, `*/N`, `A-B`, `A-B/N`, `A` (single), `A/N` (A to field max),
/// and comma-separated lists of any of these. Returns the deduped, sorted
/// values plus a `wildcard` flag distinguishing a bare `*`.
fn expand_field(value: &str, meta: &FieldMeta) -> Result<ParsedField, String> {
let norm = normalize(value, meta);
if norm.is_empty() {
return Err(format!("{}: empty field", meta.name.as_str()));
}
if norm == "*" {
return Ok(ParsedField {
meta: *meta,
raw: value.to_string(),
values: inclusive_range(meta.min, meta.max),
wildcard: true,
});
}
let mut set: Vec<i64> = Vec::new();
for term in norm.split(',') {
if term.is_empty() {
return Err(format!("{}: empty list item", meta.name.as_str()));
}
let (base, step) = match term.find('/') {
Some(idx) => {
let base = &term[..idx];
let step = parse_int_strict(&term[idx + 1..], meta.name.as_str())?;
if step <= 0 {
return Err(format!(
"{}: step must be a positive number",
meta.name.as_str()
));
}
(base, step)
}
None => (term, 1i64),
};
let (lo, hi) = if base == "*" {
(meta.min, meta.max)
} else if let Some(dash) = base.find('-') {
let lo = parse_int_strict(&base[..dash], meta.name.as_str())?;
let hi = parse_int_strict(&base[dash + 1..], meta.name.as_str())?;
(lo, hi)
} else {
let lo = parse_int_strict(base, meta.name.as_str())?;
// "A/step" runs from A to the field max; a bare "A" is a single value.
let hi = if term.find('/').is_some() {
meta.max
} else {
lo
};
(lo, hi)
};
if lo > hi {
return Err(format!(
"{}: range start {lo} is greater than end {hi}",
meta.name.as_str()
));
}
if lo < meta.min {
return Err(format!(
"{}: value {lo} is below minimum {}",
meta.name.as_str(),
meta.min
));
}
if hi > meta.max {
return Err(format!(
"{}: value {hi} is above maximum {}",
meta.name.as_str(),
meta.max
));
}
let mut v = lo;
while v <= hi {
let resolved = if meta.wrap_max && v == meta.max {
meta.min
} else {
v
};
if !set.contains(&resolved) {
set.push(resolved);
}
v += step;
}
}
set.sort_unstable();
Ok(ParsedField {
meta: *meta,
raw: value.to_string(),
values: set,
wildcard: false,
})
}
/// Parse all five fields into ParsedFields, or return an error string.
fn parse_expr(expr: &str) -> Result<Vec<ParsedField>, String> {
let tokens: Vec<&str> = expr.split_whitespace().collect();
if tokens.len() != 5 {
return Err(format!(
"Expected 5 fields (minute hour day-of-month month day-of-week), got {}",
tokens.len()
));
}
let mut parts = Vec::with_capacity(5);
for (i, tok) in tokens.iter().enumerate() {
parts.push(expand_field(tok, &FIELDS[i])?);
}
Ok(parts)
}
/// True when a sorted value slice is a contiguous run (e.g. [3, 4, 5, 6]).
fn is_contiguous(values: &[i64]) -> bool {
values.windows(2).all(|w| w[1] - w[0] == 1)
}
/// Describe a single value in the field's own vocabulary.
fn single_value(n: i64, meta: &FieldMeta) -> String {
match meta.name {
FieldName::Minute => format!("minute {n}"),
FieldName::Hour => format!("hour {n}"),
FieldName::DayOfMonth => format!("day {n} of the month"),
FieldName::Month => month_name(n),
FieldName::DayOfWeek => dow_name(n),
}
}
/// Describe a parsed field as a human phrase (no leading preposition). `raw`
/// is consulted to distinguish step syntax (star/N or A-B/N) from plain lists,
/// since two different raw forms can expand to the same value set.
fn describe_field(p: &ParsedField) -> String {
let meta = &p.meta;
let raw = &p.raw;
let values = &p.values;
if p.wildcard {
return match meta.name {
FieldName::Minute => "every minute",
FieldName::Hour => "every hour",
FieldName::DayOfMonth => "every day of the month",
FieldName::Month => "every month",
FieldName::DayOfWeek => "every day of the week",
}
.to_string();
}
// Step syntax is reported as "every N <units>".
if raw.contains('/') && !values.is_empty() {
let slash_idx = raw.find('/').unwrap();
let step = parse_int_strict(&raw[slash_idx + 1..], meta.name.as_str()).unwrap_or(1);
let start = values[0];
// "day-of-month" -> "days of the month", "day-of-week" -> "days of the
// week"; the bare fields (minute/hour/month) pluralise by adding 's'.
let unit_plural: String = match meta.name {
FieldName::DayOfMonth => "days of the month".to_string(),
FieldName::DayOfWeek => "days of the week".to_string(),
_ => format!("{}s", meta.name.as_str()),
};
if start == meta.min {
return format!("every {step} {unit_plural}");
}
return format!(
"every {step} {unit_plural} starting at {}",
single_value(start, meta)
);
}
if values.len() == 1 {
return single_value(values[0], meta);
}
if is_contiguous(values) {
let (a, b) = (values[0], *values.last().unwrap());
if matches!(meta.name, FieldName::Month) {
return format!("{} through {}", month_name(a), month_name(b));
}
if matches!(meta.name, FieldName::DayOfWeek) {
return format!("{} through {}", dow_name(a), dow_name(b));
}
// minute/hour pluralise by adding 's' (month/dow take name ranges above).
let unit_plural: String = if matches!(meta.name, FieldName::DayOfMonth) {
"days".to_string()
} else {
format!("{}s", meta.name.as_str())
};
return format!("{unit_plural} {a} through {b}");
}
// Explicit list of discrete values.
let joined = values
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(", ");
match meta.name {
FieldName::Month => values
.iter()
.map(|v| month_name(*v))
.collect::<Vec<_>>()
.join(", "),
FieldName::DayOfWeek => values
.iter()
.map(|v| dow_name(*v))
.collect::<Vec<_>>()
.join(", "),
FieldName::Minute => format!("minutes {joined}"),
FieldName::Hour => format!("hours {joined}"),
FieldName::DayOfMonth => format!("days {joined} of the month"),
}
}
/// Prepend a preposition, but never before a phrase that already leads with
/// "every" (e.g. "every day of the week" reads wrong as "on every …").
fn prepend(prefix: &str, phrase: &str) -> String {
if phrase.starts_with("every") {
phrase.to_string()
} else {
format!("{prefix} {phrase}")
}
}
/// Compose the opening time-of-day clause from the minute and hour fields.
fn time_clause(minute: &ParsedField, hour: &ParsedField) -> String {
let m_all = minute.wildcard;
let h_all = hour.wildcard;
let m_single = !m_all && minute.values.len() == 1;
let h_single = !h_all && hour.values.len() == 1;
if m_all && h_all {
return "Every minute".to_string();
}
if m_all && h_single {
return format!("Every minute of hour {}", hour.values[0]);
}
if m_single && h_all {
return format!("At minute {} of every hour", minute.values[0]);
}
if m_single && h_single {
return format!("At {}:{}", pad2(hour.values[0]), pad2(minute.values[0]));
}
// Mixed: describe each non-wildcard field, hour first.
let mut clauses: Vec<String> = Vec::new();
if !h_all {
clauses.push(describe_field(hour));
}
if !m_all {
clauses.push(describe_field(minute));
}
let s = clauses.join(", ");
let mut c = s.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
}
fn compose_description(parts: &[ParsedField]) -> String {
let (minute, hour, dom, month, dow) = (&parts[0], &parts[1], &parts[2], &parts[3], &parts[4]);
let mut clauses = vec![time_clause(minute, hour)];
if !dom.wildcard {
clauses.push(prepend("on", &describe_field(dom)));
}
if !month.wildcard {
clauses.push(prepend("in", &describe_field(month)));
}
if !dow.wildcard {
clauses.push(prepend("on", &describe_field(dow)));
}
clauses.join(", ")
}
// ─── Public API ────────────────────────────────────────────────────────────
/// One entry of the per-field explanation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CronFieldInfo {
pub field: FieldName,
pub value: String, // raw field value as written in the expression
pub meaning: String, // human-readable description of what this field matches
}
/// The result of [`explain_cron`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CronExplanation {
pub valid: bool,
pub description: String, // "" when invalid
pub fields: Vec<CronFieldInfo>, // one per field; empty when invalid
pub error: Option<String>, // present only when valid is false
}
/// Parse and explain a 5-field cron expression in plain English.
///
/// ```
/// use cronexplainer::explain_cron;
/// assert_eq!(explain_cron("30 14 * * *").description, "At 14:30");
/// ```
pub fn explain_cron(expr: &str) -> CronExplanation {
match parse_expr(expr) {
Ok(parts) => {
let fields = parts
.iter()
.map(|p| CronFieldInfo {
field: p.meta.name,
value: p.raw.clone(),
meaning: describe_field(p),
})
.collect();
CronExplanation {
valid: true,
description: compose_description(&parts),
fields,
error: None,
}
}
Err(err) => CronExplanation {
valid: false,
description: String::new(),
fields: Vec::new(),
error: Some(err),
},
}
}
/// Per-field specs for [`build_cron`]. Empty / None fields default to `*`.
#[derive(Debug, Clone, Default)]
pub struct BuildCronOptions {
pub minute: Option<String>,
pub hour: Option<String>,
pub dom: Option<String>,
pub month: Option<String>,
pub dow: Option<String>,
}
/// Assemble a 5-field cron expression from per-field specs. Each field
/// defaults to `*` when empty/omitted; invalid fields return an error so
/// callers cannot build a malformed expression.
///
/// ```
/// use cronexplainer::{build_cron, BuildCronOptions};
/// let out = build_cron(BuildCronOptions {
/// minute: Some("30".into()), hour: Some("14".into()), ..Default::default()
/// }).unwrap();
/// assert_eq!(out, "30 14 * * *");
/// ```
pub fn build_cron(opts: BuildCronOptions) -> Result<String, String> {
let specs: [(FieldMeta, Option<String>); 5] = [
(FIELDS[0], opts.minute),
(FIELDS[1], opts.hour),
(FIELDS[2], opts.dom),
(FIELDS[3], opts.month),
(FIELDS[4], opts.dow),
];
let mut out: Vec<String> = Vec::with_capacity(5);
for (meta, value) in specs {
let v = value.unwrap_or_default().trim().to_string();
if v.is_empty() {
out.push("*".to_string());
continue;
}
expand_field(&v, &meta)?; // validates
out.push(v);
}
Ok(out.join(" "))
}
// ─── Civil-calendar helpers (Howard Hinnant, public domain) ─────────────────
//
// Convert between a proleptic Gregorian (year, month, day) and a serial day
// count anchored at 1970-01-01 = day 0. The formulas force non-negative
// operands before truncating division, so they are correct for all inputs and
// match Rust's (toward-zero) integer division.
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; // [0, 399]
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
era * 146_097 + doe - 719_468
}
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// Weekday (0 = Sunday .. 6 = Saturday) for a serial day count.
/// 1970-01-01 was a Thursday (4), which anchors the +4 offset.
fn weekday_from_days(z: i64) -> i64 {
((z % 7) + 11) % 7
}
/// A UTC date/time expressed as civil fields.
///
/// Rust has no stdlib datetime, so callers pass the UTC fields directly — the
/// same values the TypeScript reference reads off a `Date` via its `getUTC*`
/// accessors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CivilTime {
pub year: i64,
pub month: i64, // 1..=12
pub day: i64, // 1..=31
pub hour: i64, // 0..=23
pub minute: i64, // 0..=59
}
/// Internal mutable cursor: a serial day count plus minutes-since-midnight.
/// Advancing a field is ordinary integer arithmetic, and field overflow rolls
/// over exactly like `Date#setUTC*` (e.g. minute 60 → next hour, day 32 →
/// next month, Feb 30 → March).
struct UtcCursor {
days: i64,
min_of_day: i64, // 0..=1439
}
impl UtcCursor {
fn from_civil(c: CivilTime) -> Self {
UtcCursor {
days: days_from_civil(c.year, c.month, c.day),
min_of_day: c.hour * 60 + c.minute,
}
}
fn year(&self) -> i64 {
civil_from_days(self.days).0
}
fn month(&self) -> i64 {
civil_from_days(self.days).1
}
fn day(&self) -> i64 {
civil_from_days(self.days).2
}
fn weekday(&self) -> i64 {
weekday_from_days(self.days)
}
fn hour(&self) -> i64 {
self.min_of_day / 60
}
fn minute(&self) -> i64 {
self.min_of_day % 60
}
/// +1 minute, seconds conceptually zero (we never track sub-minute).
fn bump_minute(&mut self) {
self.min_of_day += 1;
if self.min_of_day >= 1440 {
self.min_of_day -= 1440;
self.days += 1;
}
}
/// setUTCMonth(+1, 1) + zero time → first day of next month, midnight.
fn advance_month_day1(&mut self) {
let (y, m, _) = civil_from_days(self.days);
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
self.days = days_from_civil(ny, nm, 1);
self.min_of_day = 0;
}
/// setUTCDate(+1) + zero time → next day, midnight.
fn advance_day(&mut self) {
self.days += 1;
self.min_of_day = 0;
}
/// setUTCHours(+1, 0, 0, 0) → next hour with minute zeroed (may roll day).
fn advance_hour_zero(&mut self) {
let new_hour = self.min_of_day / 60 + 1;
self.days += new_hour / 24;
self.min_of_day = (new_hour % 24) * 60;
}
fn to_civil(&self) -> CivilTime {
let (y, m, d) = civil_from_days(self.days);
CivilTime {
year: y,
month: m,
day: d,
hour: self.hour(),
minute: self.minute(),
}
}
}
/// Next time the expression fires, strictly after `after`, evaluated in UTC.
///
/// Implements standard Vixie-cron day matching: when BOTH day-of-month and
/// day-of-week are restricted, a match on either suffices (OR); otherwise both
/// must match (AND). Returns `None` if no firing occurs within ~3 years.
pub fn next_run(expr: &str, after: CivilTime) -> Option<CivilTime> {
let parts = parse_expr(expr).ok()?;
let (minute, hour, dom, month, dow) = (&parts[0], &parts[1], &parts[2], &parts[3], &parts[4]);
// Sets for O(1) membership tests.
let m_set: HashSet<i64> = minute.values.iter().copied().collect();
let h_set: HashSet<i64> = hour.values.iter().copied().collect();
let dom_set: HashSet<i64> = dom.values.iter().copied().collect();
let mon_set: HashSet<i64> = month.values.iter().copied().collect();
let dow_set: HashSet<i64> = dow.values.iter().copied().collect();
let dom_wild = dom.wildcard;
let dow_wild = dow.wildcard;
// Start at the top of the minute following `after`, seconds zeroed.
let mut cur = UtcCursor::from_civil(after);
cur.bump_minute();
let limit = cur.year() + 3; // hard stop ~3 years out
while cur.year() < limit {
if !mon_set.contains(&cur.month()) {
cur.advance_month_day1();
continue;
}
let dom_ok = dom_set.contains(&cur.day());
let dow_ok = dow_set.contains(&cur.weekday()); // 0 = Sunday .. 6 = Saturday
let day_ok = if dom_wild || dow_wild {
dom_ok && dow_ok
} else {
dom_ok || dow_ok
};
if !day_ok {
cur.advance_day();
continue;
}
if !h_set.contains(&cur.hour()) {
cur.advance_hour_zero();
continue;
}
if !m_set.contains(&cur.minute()) {
cur.bump_minute();
continue;
}
return Some(cur.to_civil());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explains_simple_time() {
assert_eq!(explain_cron("30 14 * * *").description, "At 14:30");
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →