Skip to content

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 →