cronspeak 0.1.1

Convert cron expressions into clear, human-readable English descriptions (like cronstrue / cron-descriptor). Zero-dependency, no_std.
Documentation
//! End-to-end behavioral spec for the public `cronspeak` API.

use cronspeak::{describe, describe_with, CronError, Options, TimeFormat};

fn d(expr: &str) -> String {
    describe(expr).unwrap_or_else(|e| panic!("describe({expr:?}) failed: {e}"))
}

// ---------------------------------------------------------------------------
// Frequency / time-of-day
// ---------------------------------------------------------------------------

#[test]
fn every_minute() {
    assert_eq!(d("* * * * *"), "Every minute");
}

#[test]
fn every_n_minutes() {
    assert_eq!(d("*/5 * * * *"), "Every 5 minutes");
    assert_eq!(d("*/15 * * * *"), "Every 15 minutes");
}

#[test]
fn minutes_past_the_hour() {
    assert_eq!(d("0 * * * *"), "At 0 minutes past the hour");
    assert_eq!(d("5 * * * *"), "At 5 minutes past the hour");
}

#[test]
fn at_specific_time_12h() {
    assert_eq!(d("0 0 * * *"), "At 12:00 AM");
    assert_eq!(d("30 9 * * *"), "At 09:30 AM");
    assert_eq!(d("0 12 * * *"), "At 12:00 PM");
    assert_eq!(d("15 14 * * *"), "At 02:15 PM");
    assert_eq!(d("0 22 * * *"), "At 10:00 PM");
    assert_eq!(d("5 4 * * *"), "At 04:05 AM");
}

// ---------------------------------------------------------------------------
// Day-of-week / day-of-month / month
// ---------------------------------------------------------------------------

#[test]
fn weekdays_range() {
    assert_eq!(d("0 9 * * 1-5"), "At 09:00 AM, Monday through Friday");
    assert_eq!(d("0 22 * * 1-5"), "At 10:00 PM, Monday through Friday");
}

#[test]
fn single_weekday_numeric_and_named() {
    assert_eq!(d("0 0 * * 0"), "At 12:00 AM, only on Sunday");
    assert_eq!(d("0 0 * * 7"), "At 12:00 AM, only on Sunday");
    assert_eq!(d("0 0 * * sun"), "At 12:00 AM, only on Sunday");
    assert_eq!(d("5 4 * * sun"), "At 04:05 AM, only on Sunday");
}

#[test]
fn day_of_month() {
    assert_eq!(d("0 0 1 * *"), "At 12:00 AM, on day 1 of the month");
    assert_eq!(d("15 14 1 * *"), "At 02:15 PM, on day 1 of the month");
    assert_eq!(
        d("0 0 1,15 * *"),
        "At 12:00 AM, on days 1 and 15 of the month"
    );
}

#[test]
fn month_single_range_and_step() {
    assert_eq!(
        d("0 0 1 1 *"),
        "At 12:00 AM, on day 1 of the month, only in January"
    );
    assert_eq!(d("0 0 * jan-mar *"), "At 12:00 AM, January through March");
    assert_eq!(
        d("0 0 1 */2 *"),
        "At 12:00 AM, on day 1 of the month, every 2 months"
    );
}

#[test]
fn combined_step_range_weekday() {
    assert_eq!(
        d("*/15 9-17 * * 1-5"),
        "Every 15 minutes, between 09:00 AM and 05:59 PM, Monday through Friday"
    );
}

#[test]
fn hour_list_with_fixed_minute() {
    assert_eq!(d("0 0,12 * * *"), "At 12:00 AM and 12:00 PM");
    assert_eq!(d("30 9,17 * * *"), "At 09:30 AM and 05:30 PM");
}

#[test]
fn three_item_list_uses_oxford_style() {
    assert_eq!(
        d("0 0 * * 1,3,5"),
        "At 12:00 AM, only on Monday, Wednesday and Friday"
    );
}

// ---------------------------------------------------------------------------
// Macros
// ---------------------------------------------------------------------------

#[test]
fn macros() {
    assert_eq!(d("@hourly"), "At 0 minutes past the hour");
    assert_eq!(d("@daily"), "At 12:00 AM");
    assert_eq!(d("@midnight"), "At 12:00 AM");
    assert_eq!(d("@weekly"), "At 12:00 AM, only on Sunday");
    assert_eq!(d("@monthly"), "At 12:00 AM, on day 1 of the month");
    assert_eq!(
        d("@yearly"),
        "At 12:00 AM, on day 1 of the month, only in January"
    );
    assert_eq!(
        d("@annually"),
        "At 12:00 AM, on day 1 of the month, only in January"
    );
}

// ---------------------------------------------------------------------------
// 24-hour option
// ---------------------------------------------------------------------------

#[test]
fn twenty_four_hour_format() {
    let opts = Options::new().time_format(TimeFormat::H24);
    assert_eq!(
        describe_with("0 9 * * 1-5", &opts).unwrap(),
        "At 09:00, Monday through Friday"
    );
    assert_eq!(describe_with("0 0 * * *", &opts).unwrap(), "At 00:00");
    assert_eq!(describe_with("30 22 * * *", &opts).unwrap(), "At 22:30");
}

// ---------------------------------------------------------------------------
// Whitespace tolerance
// ---------------------------------------------------------------------------

#[test]
fn extra_whitespace_is_tolerated() {
    assert_eq!(
        d("  0   9   *   *   1-5  "),
        "At 09:00 AM, Monday through Friday"
    );
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

#[test]
fn rejects_empty() {
    assert!(matches!(describe(""), Err(CronError::Empty)));
    assert!(matches!(describe("   "), Err(CronError::Empty)));
}

#[test]
fn rejects_wrong_field_count() {
    assert!(matches!(
        describe("* * *"),
        Err(CronError::FieldCount { .. })
    ));
    assert!(matches!(
        describe("* * * * * *"),
        Err(CronError::FieldCount { .. })
    ));
}

#[test]
fn rejects_out_of_range() {
    assert!(describe("60 * * * *").is_err()); // minute max 59
    assert!(describe("* 24 * * *").is_err()); // hour max 23
    assert!(describe("* * 0 * *").is_err()); // day-of-month min 1
    assert!(describe("* * 32 * *").is_err()); // day-of-month max 31
    assert!(describe("* * * 13 *").is_err()); // month max 12
    assert!(describe("* * * * 8").is_err()); // day-of-week max 7
}

#[test]
fn rejects_invalid_tokens() {
    assert!(describe("a * * * *").is_err());
    assert!(matches!(
        describe("*/0 * * * *"),
        Err(CronError::InvalidStep(_))
    ));
    assert!(describe("5-1 * * * *").is_err()); // inverted range
}

// ---------------------------------------------------------------------------
// Regression tests from the adversarial pre-publish review
// ---------------------------------------------------------------------------

#[test]
fn dom_and_dow_both_restricted_use_or_semantics() {
    // Standard cron runs when EITHER day field matches (OR), not AND.
    assert_eq!(
        d("0 0 13 * 5"),
        "At 12:00 AM, on day 13 of the month or on Friday"
    );
    assert_eq!(
        d("0 0 13 6 5"),
        "At 12:00 AM, on day 13 of the month or on Friday, only in June"
    );
}

#[test]
fn dow_range_including_sunday_as_seven() {
    // 7 is an ascending alias for Sunday, so 5-7 / 6-7 / 1-7 are valid ranges.
    assert_eq!(d("0 0 * * 5-7"), "At 12:00 AM, Friday through Sunday");
    assert_eq!(d("0 0 * * 6-7"), "At 12:00 AM, Saturday through Sunday");
    assert_eq!(d("0 0 * * 1-7"), "At 12:00 AM, Monday through Sunday");
}

#[test]
fn dow_step_shorthand_renders_named_days() {
    assert_eq!(
        d("0 0 * * 0/2"),
        "At 12:00 AM, every 2 days from Sunday through Saturday"
    );
}

#[test]
fn hour_range_with_fixed_minute_uses_hour_bounds() {
    // Fires at the top of each hour 9..17, so the last firing is 5:00 PM.
    assert_eq!(
        d("0 9-17 * * *"),
        "At minute 0, between 09:00 AM and 05:00 PM"
    );
    // With an every-minute field the window genuinely extends to :59.
    assert_eq!(
        d("* 9-17 * * *"),
        "Every minute, between 09:00 AM and 05:59 PM"
    );
}

#[test]
fn pluralization_is_grammatical() {
    assert_eq!(d("1 * * * *"), "At 1 minute past the hour");
    assert_eq!(
        d("0 0 * * 0/1"),
        "At 12:00 AM, every day from Sunday through Saturday"
    );
}

#[test]
fn minute_list_across_all_hours_keeps_context() {
    assert_eq!(d("0,30 * * * *"), "At minutes 0 and 30 past the hour");
}

#[test]
fn rejects_garbage_names() {
    assert!(describe("0 0 1 janetjackson *").is_err());
    assert!(describe("0 0 * * saturnalia").is_err());
    assert!(describe("0 0 1 janx *").is_err());
}

#[test]
fn accepts_full_month_and_day_names() {
    assert_eq!(
        d("0 0 1 january *"),
        "At 12:00 AM, on day 1 of the month, only in January"
    );
    assert_eq!(d("0 0 * * sunday"), "At 12:00 AM, only on Sunday");
}

#[test]
fn invalid_field_payload_is_not_empty() {
    match describe("1- * * * *") {
        Err(CronError::InvalidField(s)) => assert!(!s.is_empty(), "payload should name the field"),
        other => panic!("expected InvalidField, got {other:?}"),
    }
}