daaki-message 0.2.0

RFC 5322 email message parser and builder
Documentation
//! Property-based tests for lenient date parsing (Postel's law).
//!
//! Verifies that `DateTime::parse_rfc5322` accepts common real-world
//! date format variations. Would have caught bug A3 (comma after month
//! confusing the day-name parser).
//!
//! # References
//! - RFC 5322 Section 3.3 (date-time specification)

#![allow(clippy::unwrap_used, clippy::expect_used)]

use daaki_message::DateTime;
use proptest::prelude::*;

/// Generates a valid `DateTime` for testing (RFC 5322 Section 3.3).
fn arb_datetime() -> impl Strategy<Value = DateTime> {
    (
        2000u16..2030,
        1u8..=12,
        1u8..=28,
        0u8..=23,
        0u8..=59,
        0u8..=59,
        prop_oneof![Just(0i16), Just(330), Just(-480), Just(60), Just(-300)],
    )
        .prop_map(|(year, month, day, hour, minute, second, tz)| {
            DateTime::new(year, month, day, hour, minute, second, tz)
        })
}

const MONTHS: [&str; 12] = [
    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

/// Formats a timezone offset as `+HHMM` or `-HHMM`.
fn format_tz(tz_offset_minutes: i16) -> (char, i16, i16) {
    if tz_offset_minutes >= 0 {
        ('+', tz_offset_minutes / 60, tz_offset_minutes % 60)
    } else {
        ('-', (-tz_offset_minutes) / 60, (-tz_offset_minutes) % 60)
    }
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(256))]

    /// Standard RFC 5322 date string must parse correctly (RFC 5322 Section 3.3).
    #[test]
    fn standard_format_parses(dt in arb_datetime()) {
        let s = dt.to_rfc5322_string();
        let parsed = DateTime::parse_rfc5322(&s);
        prop_assert!(parsed.is_some(), "standard date must parse: {:?}", s);
        let p = parsed.unwrap();
        prop_assert_eq!(p.year(), dt.year());
        prop_assert_eq!(p.month(), dt.month());
        prop_assert_eq!(p.day(), dt.day());
    }

    /// Date without day-of-week must parse (RFC 5322 Section 3.3: day-of-week is optional).
    #[test]
    fn date_without_dow_parses(dt in arb_datetime()) {
        let m = MONTHS[(dt.month() - 1) as usize];
        let (sign, h, min) = format_tz(dt.tz_offset_minutes());
        let s = format!(
            "{} {} {} {:02}:{:02}:{:02} {}{:02}{:02}",
            dt.day(), m, dt.year(), dt.hour(), dt.minute(), dt.second(), sign, h, min
        );
        let parsed = DateTime::parse_rfc5322(&s);
        prop_assert!(parsed.is_some(), "date without day-of-week must parse: {:?}", s);
    }

    /// Date with comma after month (non-standard but seen in the wild) must parse.
    ///
    /// Regression: previously, `find(',')` consumed "13 Feb" thinking "13 Feb"
    /// was a day-name prefix (bug A3).
    ///
    /// # References
    /// - RFC 5322 Section 3.3 (Postel's law: be liberal in what you accept)
    #[test]
    fn date_with_comma_after_month(dt in arb_datetime()) {
        let m = MONTHS[(dt.month() - 1) as usize];
        let (sign, h, min) = format_tz(dt.tz_offset_minutes());
        let s = format!(
            "{} {}, {} {:02}:{:02}:{:02} {}{:02}{:02}",
            dt.day(), m, dt.year(), dt.hour(), dt.minute(), dt.second(), sign, h, min
        );
        let parsed = DateTime::parse_rfc5322(&s);
        prop_assert!(parsed.is_some(), "date with comma after month must parse: {:?}", s);
        let p = parsed.unwrap();
        prop_assert_eq!(p.day(), dt.day(), "day mismatch for {:?}", s);
        prop_assert_eq!(p.month(), dt.month(), "month mismatch for {:?}", s);
        prop_assert_eq!(p.year(), dt.year(), "year mismatch for {:?}", s);
    }

    /// Date with extra whitespace between fields must parse (Postel's law).
    ///
    /// # References
    /// - RFC 5322 Section 3.3 (date-time, obs-date allows extra whitespace)
    #[test]
    fn date_with_extra_whitespace(dt in arb_datetime()) {
        let m = MONTHS[(dt.month() - 1) as usize];
        let (sign, h, min) = format_tz(dt.tz_offset_minutes());
        // Extra spaces between fields
        let s = format!(
            "{}  {}  {}  {:02}:{:02}:{:02}  {}{:02}{:02}",
            dt.day(), m, dt.year(), dt.hour(), dt.minute(), dt.second(), sign, h, min
        );
        let parsed = DateTime::parse_rfc5322(&s);
        prop_assert!(parsed.is_some(), "date with extra whitespace must parse: {:?}", s);
    }
}