pub const MONTH_NAMES: &[&str] =
&["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
pub const DAY_NAMES: &[&str] = &["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
pub const DAY_STRINGS: &[&str] = &[
"01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16",
"17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31",
];
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
_ => 30,
}
}
pub fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
pub fn month_name(month: u32) -> &'static str {
match month {
1..=12 => MONTH_NAMES[(month - 1) as usize],
_ => "",
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IsoDateError {
Malformed,
MonthOutOfRange,
DayOutOfRange,
}
pub fn parse_iso_date(text: &str) -> Option<(i32, u32, u32)> {
parse_iso_date_checked(text).ok()
}
pub fn parse_iso_date_checked(text: &str) -> Result<(i32, u32, u32), IsoDateError> {
let mut parts = text.split('-');
let year = parts.next().and_then(|v| v.parse::<i32>().ok()).ok_or(IsoDateError::Malformed)?;
let month = parts.next().and_then(|v| v.parse::<u32>().ok()).ok_or(IsoDateError::Malformed)?;
let day = parts.next().and_then(|v| v.parse::<u32>().ok()).ok_or(IsoDateError::Malformed)?;
if parts.next().is_some() {
return Err(IsoDateError::Malformed);
}
if !(1..=12).contains(&month) {
return Err(IsoDateError::MonthOutOfRange);
}
if !(1..=days_in_month(year, month)).contains(&day) {
return Err(IsoDateError::DayOutOfRange);
}
Ok((year, month, day))
}
pub fn format_iso_date(year: i32, month: u32, day: u32) -> alloc::string::String {
alloc::format!("{year:04}-{month:02}-{day:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leap_years() {
assert!(is_leap_year(2024));
assert!(is_leap_year(2000));
assert!(!is_leap_year(1900));
assert!(!is_leap_year(2025));
}
#[test]
fn february_length_follows_the_leap_rule() {
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2025, 2), 28);
assert_eq!(days_in_month(1900, 2), 28);
assert_eq!(days_in_month(2000, 2), 29);
}
#[test]
fn month_names_are_one_based_and_bounded() {
assert_eq!(month_name(1), "Jan");
assert_eq!(month_name(12), "Dec");
assert_eq!(month_name(0), "");
assert_eq!(month_name(13), "");
assert_eq!(MONTH_NAMES.len(), 12);
}
#[test]
fn day_strings_cover_every_month_length() {
assert_eq!(DAY_STRINGS.len(), 31);
assert_eq!(DAY_STRINGS[0], "01");
assert_eq!(DAY_STRINGS[30], "31");
}
#[test]
fn iso_round_trip() {
assert_eq!(parse_iso_date("2026-09-14"), Some((2026, 9, 14)));
assert_eq!(format_iso_date(2026, 9, 14), "2026-09-14");
assert_eq!(format_iso_date(7, 1, 2), "0007-01-02");
}
#[test]
fn iso_rejects_bad_shapes_and_impossible_dates() {
assert_eq!(parse_iso_date("not-a-date"), None);
assert_eq!(parse_iso_date("2026-09"), None);
assert_eq!(parse_iso_date("2026-09-14-1"), None);
assert_eq!(parse_iso_date_checked("2026-13-01"), Err(IsoDateError::MonthOutOfRange));
assert_eq!(parse_iso_date_checked("2026-00-01"), Err(IsoDateError::MonthOutOfRange));
assert_eq!(parse_iso_date_checked("2026-02-30"), Err(IsoDateError::DayOutOfRange));
assert_eq!(parse_iso_date_checked("2025-02-29"), Err(IsoDateError::DayOutOfRange));
assert_eq!(parse_iso_date_checked("2024-02-29"), Ok((2024, 2, 29)));
}
}