use std::fmt;
pub const ATTEMPTED_FORMATS: [&str; 4] = [
"RFC 2822 (e.g. `Wed, 01 Jul 2026 07:07:07 +0000`)",
"long form (e.g. `July 1, 2026` or `1 July 2026`)",
"ISO 8601 date (e.g. `2026-07-01`)",
"ISO 8601 datetime (e.g. `2026-07-01T07:07:07Z`)",
];
const MONTH_ABBR: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
"Nov", "Dec",
];
const MONTH_FULL: [&str; 12] = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DateFormat {
Rfc2822,
LongForm,
IsoDate,
IsoDateTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FlexibleDate {
pub year: i32,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
pub offset_minutes: i32,
pub format: DateFormat,
}
impl FlexibleDate {
#[must_use]
pub fn to_rfc2822(&self) -> String {
let month = MONTH_ABBR
.get(usize::from(self.month.saturating_sub(1)))
.copied()
.unwrap_or(MONTH_ABBR[0]);
format!(
"{}, {:02} {} {:04} {:02}:{:02}:{:02} {}",
self.weekday_abbr(),
self.day,
month,
self.year,
self.hour,
self.minute,
self.second,
self.offset_string("")
)
}
#[must_use]
pub fn to_rfc3339(&self) -> String {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}",
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
self.offset_string(":")
)
}
#[must_use]
pub fn to_w3c_date(&self) -> String {
self.to_rfc3339()
}
#[must_use]
pub fn to_iso_date(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
fn weekday_abbr(&self) -> &'static str {
const OFFSETS: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
const NAMES: [&str; 7] =
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
let mut y = i64::from(self.year);
if self.month < 3 {
y -= 1;
}
let month_idx = usize::from(self.month.saturating_sub(1)) % 12;
let idx = (y + y.div_euclid(4) - y.div_euclid(100)
+ y.div_euclid(400)
+ OFFSETS[month_idx]
+ i64::from(self.day))
.rem_euclid(7) as usize;
NAMES[idx]
}
fn offset_string(&self, sep: &str) -> String {
let sign = if self.offset_minutes < 0 { '-' } else { '+' };
let abs = self.offset_minutes.unsigned_abs();
format!("{sign}{:02}{sep}{:02}", abs / 60, abs % 60)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DateParseError {
input: String,
}
impl DateParseError {
fn new(input: &str) -> Self {
let mut owned: String = input.chars().take(64).collect();
if owned.len() < input.len() {
owned.push('…');
}
Self { input: owned }
}
#[must_use]
pub const fn attempted_formats(&self) -> &'static [&'static str] {
&ATTEMPTED_FORMATS
}
#[must_use]
pub fn input(&self) -> &str {
&self.input
}
}
impl fmt::Display for DateParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"could not parse date {:?}; attempted formats: {}",
self.input,
ATTEMPTED_FORMATS.join(", ")
)
}
}
impl std::error::Error for DateParseError {}
#[must_use]
pub const fn is_leap_year(year: i32) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
#[must_use]
pub const fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
_ => 0,
}
}
pub fn parse_flexible_date(
input: &str,
) -> Result<FlexibleDate, DateParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(DateParseError::new(input));
}
parse_rfc2822(trimmed)
.or_else(|| parse_long_form(trimmed))
.or_else(|| parse_iso8601(trimmed))
.ok_or_else(|| DateParseError::new(input))
}
#[allow(clippy::too_many_arguments)]
fn build_date(
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
offset_minutes: i32,
format: DateFormat,
) -> Option<FlexibleDate> {
if !(1..=9999).contains(&year) {
return None;
}
if month == 0 || month > 12 {
return None;
}
if day == 0 || day > days_in_month(year, month) {
return None;
}
if hour > 23 || minute > 59 || second > 59 {
return None;
}
Some(FlexibleDate {
year,
month,
day,
hour,
minute,
second,
offset_minutes,
format,
})
}
fn month_from_name(name: &str) -> Option<u8> {
let lower = name.to_ascii_lowercase();
MONTH_FULL
.iter()
.position(|full| {
*full == lower || (lower.len() == 3 && full.starts_with(&lower))
})
.map(|idx| idx as u8 + 1)
}
fn parse_hms(token: &str) -> Option<(u8, u8, u8)> {
let mut parts = token.split(':');
let hour: u8 = parts.next().unwrap_or_default().parse().ok()?;
let minute: u8 = parts.next()?.parse().ok()?;
let second: u8 = match parts.next() {
Some(sec) => sec.parse().ok()?,
None => 0,
};
if parts.next().is_some() {
return None;
}
Some((hour, minute, second))
}
fn parse_zone(token: &str) -> Option<i32> {
match token.to_ascii_uppercase().as_str() {
"Z" | "UT" | "GMT" | "UTC" => return Some(0),
"EST" => return Some(-5 * 60),
"EDT" => return Some(-4 * 60),
"CST" => return Some(-6 * 60),
"CDT" => return Some(-5 * 60),
"MST" => return Some(-7 * 60),
"MDT" => return Some(-6 * 60),
"PST" => return Some(-8 * 60),
"PDT" => return Some(-7 * 60),
_ => {}
}
let bytes = token.as_bytes();
let sign = match bytes.first()? {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let digits = &bytes[1..];
let (hh, mm) = match digits {
[h1, h2, m1, m2] => (ascii_pair(*h1, *h2)?, ascii_pair(*m1, *m2)?),
[h1, h2, b':', m1, m2] => {
(ascii_pair(*h1, *h2)?, ascii_pair(*m1, *m2)?)
}
_ => return None,
};
if hh > 23 || mm > 59 {
return None;
}
Some(sign * (i32::from(hh) * 60 + i32::from(mm)))
}
const fn ascii_pair(first: u8, second: u8) -> Option<u8> {
if first.is_ascii_digit() && second.is_ascii_digit() {
Some((first - b'0') * 10 + (second - b'0'))
} else {
None
}
}
fn ascii_number(bytes: &[u8]) -> Option<u32> {
if bytes.is_empty() {
return None;
}
bytes.iter().try_fold(0u32, |acc, b| {
if b.is_ascii_digit() {
acc.checked_mul(10)?.checked_add(u32::from(b - b'0'))
} else {
None
}
})
}
fn parse_rfc2822(input: &str) -> Option<FlexibleDate> {
let rest = match input.split_once(',') {
Some((weekday, tail))
if !weekday.is_empty()
&& weekday.len() <= 9
&& weekday.chars().all(|c| c.is_ascii_alphabetic()) =>
{
tail.trim_start()
}
_ => input,
};
let tokens: Vec<&str> = rest.split_whitespace().collect();
if !(4..=5).contains(&tokens.len()) {
return None;
}
let day: u8 = tokens[0].parse().ok()?;
let month = month_from_name(tokens[1])?;
if tokens[2].len() != 4 {
return None;
}
let year = ascii_number(tokens[2].as_bytes())? as i32;
let (hour, minute, second) = parse_hms(tokens[3])?;
let offset = match tokens.get(4) {
Some(zone) => parse_zone(zone)?,
None => 0,
};
build_date(
year,
month,
day,
hour,
minute,
second,
offset,
DateFormat::Rfc2822,
)
}
fn parse_long_form(input: &str) -> Option<FlexibleDate> {
let cleaned = input.replace(',', " ");
let tokens: Vec<&str> = cleaned.split_whitespace().collect();
if tokens.len() != 3 {
return None;
}
let (month, day_token) = if let Some(m) = month_from_name(tokens[0]) {
(m, tokens[1])
} else if let Some(m) = month_from_name(tokens[1]) {
(m, tokens[0])
} else {
return None;
};
if day_token.len() > 2 {
return None;
}
let day: u8 = day_token.parse().ok()?;
if tokens[2].len() != 4 {
return None;
}
let year = ascii_number(tokens[2].as_bytes())? as i32;
build_date(year, month, day, 0, 0, 0, 0, DateFormat::LongForm)
}
fn parse_iso8601(input: &str) -> Option<FlexibleDate> {
let bytes = input.as_bytes();
if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
let year = ascii_number(&bytes[0..4])? as i32;
let month = ascii_number(&bytes[5..7])? as u8;
let day = ascii_number(&bytes[8..10])? as u8;
if bytes.len() == 10 {
return build_date(year, month, day, 0, 0, 0, 0, DateFormat::IsoDate);
}
if !matches!(bytes[10], b'T' | b't' | b' ') {
return None;
}
let rest = input.get(11..).unwrap_or("");
let (time_part, zone_part) = match rest.find(['Z', 'z', '+', '-']) {
Some(pos) => rest.split_at(pos),
None => (rest, ""),
};
let clock = match time_part.split_once('.') {
Some((clock, frac)) => {
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
clock
}
None => time_part,
};
let (hour, minute, second) = parse_hms(clock)?;
let offset = if zone_part.is_empty() {
0
} else {
parse_zone(zone_part)?
};
build_date(
year,
month,
day,
hour,
minute,
second,
offset,
DateFormat::IsoDateTime,
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn rfc2822_spec_example() {
let dt = parse_flexible_date("Wed, 01 Jul 2026 07:07:07 +0000")
.expect("spec A4 example parses");
assert_eq!(dt.format, DateFormat::Rfc2822);
assert_eq!(dt.to_rfc3339(), "2026-07-01T07:07:07+00:00");
assert_eq!(dt.to_rfc2822(), "Wed, 01 Jul 2026 07:07:07 +0000");
assert_eq!(dt.to_iso_date(), "2026-07-01");
}
#[test]
fn rfc2822_wrong_weekday_is_tolerated() {
let dt = parse_flexible_date("Thu, 11 Apr 2026 06:06:06 +0000")
.expect("wrong weekday still parses");
assert_eq!(dt.to_rfc3339(), "2026-04-11T06:06:06+00:00");
assert_eq!(dt.to_rfc2822(), "Sat, 11 Apr 2026 06:06:06 +0000");
}
#[test]
fn rfc2822_no_weekday_no_zone() {
let dt = parse_flexible_date("11 Apr 2026 06:06:06")
.expect("weekday and zone are optional");
assert_eq!(dt.offset_minutes, 0);
assert_eq!(dt.to_rfc3339(), "2026-04-11T06:06:06+00:00");
}
#[test]
fn rfc2822_optional_seconds() {
let dt = parse_flexible_date("11 Apr 2026 06:06 +0000")
.expect("RFC 2822 seconds are optional");
assert_eq!(dt.second, 0);
}
#[test]
fn rfc2822_positive_hhmm_offset() {
let dt = parse_flexible_date("Fri, 25 Dec 2026 18:30:45 +0530")
.expect("+hhmm offset parses");
assert_eq!(dt.offset_minutes, 330);
assert_eq!(dt.to_rfc3339(), "2026-12-25T18:30:45+05:30");
assert_eq!(dt.to_rfc2822(), "Fri, 25 Dec 2026 18:30:45 +0530");
}
#[test]
fn rfc2822_negative_hhmm_offset() {
let dt = parse_flexible_date("Sat, 04 Jul 2026 09:15:00 -0700")
.expect("-hhmm offset parses");
assert_eq!(dt.offset_minutes, -420);
assert_eq!(dt.to_rfc3339(), "2026-07-04T09:15:00-07:00");
}
#[test]
fn rfc2822_named_zones() {
let gmt = parse_flexible_date("11 Apr 2026 06:06:06 GMT").unwrap();
assert_eq!(gmt.offset_minutes, 0);
let est = parse_flexible_date("11 Apr 2026 06:06:06 EST").unwrap();
assert_eq!(est.offset_minutes, -300);
assert!(parse_flexible_date("11 Apr 2026 06:06:06 XYZ").is_err());
}
#[test]
fn rfc2822_full_month_name_with_time() {
let dt = parse_flexible_date("1 July 2026 06:06:06 +0000")
.expect("full month name with time parses");
assert_eq!(dt.month, 7);
assert_eq!(dt.format, DateFormat::Rfc2822);
}
#[test]
fn long_form_month_first_single_digit_day() {
let dt = parse_flexible_date("July 1, 2026")
.expect("spec A4 long form parses");
assert_eq!(dt.format, DateFormat::LongForm);
assert_eq!((dt.year, dt.month, dt.day), (2026, 7, 1));
assert_eq!(dt.to_rfc2822(), "Wed, 01 Jul 2026 00:00:00 +0000");
assert_eq!(dt.to_w3c_date(), "2026-07-01T00:00:00+00:00");
}
#[test]
fn long_form_day_first() {
let dt = parse_flexible_date("1 July 2026")
.expect("day-first long form parses");
assert_eq!((dt.year, dt.month, dt.day), (2026, 7, 1));
}
#[test]
fn long_form_case_insensitive_and_no_comma() {
let dt = parse_flexible_date("december 25 2026").unwrap();
assert_eq!((dt.month, dt.day), (12, 25));
}
#[test]
fn long_form_rejects_bad_month_and_day() {
assert!(parse_flexible_date("Juvember 1, 2026").is_err());
assert!(parse_flexible_date("July 32, 2026").is_err());
assert!(parse_flexible_date("July 0, 2026").is_err());
}
#[test]
fn iso_date_only() {
let dt = parse_flexible_date("2026-07-01").expect("ISO date parses");
assert_eq!(dt.format, DateFormat::IsoDate);
assert_eq!(dt.to_rfc3339(), "2026-07-01T00:00:00+00:00");
assert_eq!(dt.to_iso_date(), "2026-07-01");
}
#[test]
fn iso_datetime_zulu() {
let dt = parse_flexible_date("2026-07-01T07:07:07Z")
.expect("Z-suffixed datetime parses");
assert_eq!(dt.format, DateFormat::IsoDateTime);
assert_eq!(dt.to_rfc3339(), "2026-07-01T07:07:07+00:00");
}
#[test]
fn iso_datetime_with_colon_offset() {
let dt = parse_flexible_date("2026-07-01T07:07:07+05:30").unwrap();
assert_eq!(dt.offset_minutes, 330);
let dt = parse_flexible_date("2026-07-01T07:07:07-07:00").unwrap();
assert_eq!(dt.offset_minutes, -420);
}
#[test]
fn iso_datetime_with_compact_offset() {
let dt = parse_flexible_date("2026-07-01T07:07:07+0530").unwrap();
assert_eq!(dt.offset_minutes, 330);
}
#[test]
fn iso_datetime_fractional_seconds_ignored() {
let dt = parse_flexible_date("2026-07-01T07:07:07.123Z").unwrap();
assert_eq!(dt.second, 7);
assert!(parse_flexible_date("2026-07-01T07:07:07.abcZ").is_err());
}
#[test]
fn iso_datetime_without_offset_defaults_utc() {
let dt = parse_flexible_date("2026-07-01T07:07:07").unwrap();
assert_eq!(dt.offset_minutes, 0);
}
#[test]
fn iso_rejects_invalid_components() {
assert!(parse_flexible_date("2026-13-01").is_err());
assert!(parse_flexible_date("2026-00-01").is_err());
assert!(parse_flexible_date("2026-04-31").is_err());
assert!(parse_flexible_date("2026-07-01T24:00:00").is_err());
assert!(parse_flexible_date("2026-07-01T07:60:00").is_err());
assert!(parse_flexible_date("2026-07-01T07:07:07+2500").is_err());
}
#[test]
fn leap_day_validation() {
assert!(parse_flexible_date("2024-02-29").is_ok());
assert!(parse_flexible_date("2023-02-29").is_err());
assert!(parse_flexible_date("2000-02-29").is_ok());
assert!(parse_flexible_date("2100-02-29").is_err());
assert!(parse_flexible_date("Thu, 29 Feb 2024 12:00:00 +0000").is_ok());
assert!(parse_flexible_date("February 29, 2024").is_ok());
assert!(parse_flexible_date("February 29, 2023").is_err());
}
#[test]
fn days_in_month_table() {
assert_eq!(days_in_month(2026, 1), 31);
assert_eq!(days_in_month(2026, 4), 30);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2025, 2), 28);
assert_eq!(days_in_month(2026, 13), 0);
assert!(is_leap_year(2000));
assert!(!is_leap_year(1900));
}
#[test]
fn garbage_input_is_err() {
for garbage in [
"",
" ",
"not a date",
"yesterday",
"13/01/2026",
"2026",
"--",
] {
assert!(
parse_flexible_date(garbage).is_err(),
"{garbage:?} should not parse"
);
}
}
#[test]
fn error_names_all_attempted_formats() {
let err = parse_flexible_date("not a date").unwrap_err();
assert_eq!(err.input(), "not a date");
assert_eq!(err.attempted_formats().len(), 4);
let msg = err.to_string();
assert!(msg.contains("RFC 2822"), "message names RFC 2822: {msg}");
assert!(msg.contains("long form"), "message names long form: {msg}");
assert!(
msg.contains("ISO 8601 date"),
"message names ISO date: {msg}"
);
assert!(
msg.contains("ISO 8601 datetime"),
"message names ISO datetime: {msg}"
);
}
#[test]
fn error_truncates_long_input() {
let long = "x".repeat(200);
let err = parse_flexible_date(&long).unwrap_err();
assert!(err.input().chars().count() <= 65);
assert!(err.input().ends_with('…'));
}
#[test]
fn weekday_computation_across_calendar() {
let cases = [
("2026-07-01", "Wed"),
("2024-02-29", "Thu"),
("2000-01-01", "Sat"),
("1990-01-01", "Mon"),
("2100-12-31", "Fri"),
];
for (iso, weekday) in cases {
let dt = parse_flexible_date(iso).unwrap();
let rendered = dt.to_rfc2822();
assert!(
rendered.starts_with(weekday),
"{iso} should be {weekday}, got {rendered}"
);
}
}
#[test]
fn rfc2822_round_trips_through_itself() {
let dt = parse_flexible_date("2026-07-01T07:07:07+05:30").unwrap();
let reparsed = parse_flexible_date(&dt.to_rfc2822()).unwrap();
assert_eq!(dt.to_rfc3339(), reparsed.to_rfc3339());
}
#[test]
fn build_date_rejects_out_of_range_year() {
assert!(parse_flexible_date("0000-01-01").is_err());
}
#[test]
fn parse_hms_rejects_malformed_tokens() {
assert_eq!(parse_hms("xx:30"), None);
assert_eq!(parse_hms("12"), None);
assert_eq!(parse_hms("12:xx"), None);
assert_eq!(parse_hms("12:30:xx"), None);
assert_eq!(parse_hms("12:30:45:59"), None);
assert_eq!(parse_hms(""), None);
}
#[test]
fn parse_zone_maps_every_rfc2822_named_zone() {
let cases = [
("EST", -5 * 60),
("EDT", -4 * 60),
("CST", -6 * 60),
("CDT", -5 * 60),
("MST", -7 * 60),
("MDT", -6 * 60),
("PST", -8 * 60),
("PDT", -7 * 60),
];
for (name, minutes) in cases {
assert_eq!(parse_zone(name), Some(minutes), "{name}");
}
}
#[test]
fn parse_zone_rejects_malformed_offsets() {
assert_eq!(parse_zone(""), None);
assert_eq!(parse_zone("0500"), None);
assert_eq!(parse_zone("+aa30"), None);
assert_eq!(parse_zone("+12a0"), None);
assert_eq!(parse_zone("+aa:30"), None);
assert_eq!(parse_zone("+12:a0"), None);
assert_eq!(parse_zone("+123"), None);
assert_eq!(parse_zone("+2460"), None);
}
#[test]
fn ascii_number_rejects_empty_overflow_and_non_digits() {
assert_eq!(ascii_number(b""), None);
assert_eq!(ascii_number(b"99999999999"), None);
assert_eq!(ascii_number(b"12a4"), None);
assert_eq!(ascii_number(b"2026"), Some(2026));
}
#[test]
fn rfc2822_rejects_each_malformed_token() {
assert!(parse_flexible_date("aa Jul 2026 12:00").is_err());
assert!(parse_flexible_date("01 Foo 2026 12:00").is_err());
assert!(parse_flexible_date("01 Jul 26 12:00").is_err());
assert!(parse_flexible_date("01 Jul 2o26 12:00").is_err());
assert!(parse_flexible_date("01 Jul 2026 xx:00").is_err());
}
#[test]
fn long_form_rejects_each_malformed_token() {
assert!(parse_flexible_date("July 123 2026").is_err());
assert!(parse_flexible_date("July aa 2026").is_err());
assert!(parse_flexible_date("July 1 26").is_err());
assert!(parse_flexible_date("July 1 2o26").is_err());
}
#[test]
fn iso8601_rejects_each_malformed_component() {
assert!(parse_flexible_date("2o26-07-01").is_err());
assert!(parse_flexible_date("2026-o7-01").is_err());
assert!(parse_flexible_date("2026-07-o1").is_err());
assert!(parse_flexible_date("2026-07-01x12:00").is_err());
assert!(parse_flexible_date("2026-07-01T1a:00").is_err());
}
}