pub const MIN_YEAR: i32 = 1;
pub const MAX_YEAR: i32 = 9999;
pub const fn is_leap_year(year: i32) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month.clamp(1, 12) {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if is_leap_year(year) {
29
} else {
28
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MaterialDate {
year: i32,
month: u32,
day: u32,
}
impl MaterialDate {
pub fn new(year: i32, month: u32, day: u32) -> Self {
let year = year.clamp(MIN_YEAR, MAX_YEAR);
let month = month.clamp(1, 12);
let day = day.clamp(1, days_in_month(year, month));
Self { year, month, day }
}
pub fn year(self) -> i32 {
self.year
}
pub fn month(self) -> u32 {
self.month
}
pub fn day(self) -> u32 {
self.day
}
pub fn first_of_month(self) -> Self {
Self {
year: self.year,
month: self.month,
day: 1,
}
}
pub fn epoch_day(self) -> i64 {
let y = i64::from(self.year) - i64::from(self.month <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = i64::from(self.month);
let d = i64::from(self.day);
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub fn days_until(self, other: Self) -> i64 {
other.epoch_day() - self.epoch_day()
}
pub fn weekday_index(self) -> u32 {
(self.epoch_day() + 4).rem_euclid(7) as u32
}
pub fn days_in_this_month(self) -> u32 {
days_in_month(self.year, self.month)
}
pub fn add_months(self, delta: i32) -> Self {
let total = i64::from(self.year) * 12 + i64::from(self.month) - 1 + i64::from(delta);
let year = total.div_euclid(12);
let month = total.rem_euclid(12) as u32 + 1;
let year = year.clamp(i64::from(MIN_YEAR), i64::from(MAX_YEAR)) as i32;
Self::new(year, month, self.day)
}
pub fn month_delta(self, other: Self) -> i32 {
(other.year - self.year) * 12 + other.month as i32 - self.month as i32
}
pub fn clamp_to(self, first: Self, last: Self) -> Self {
if self < first {
return first;
}
if self > last {
return if last < first { first } else { last };
}
self
}
pub fn is_within(self, first: Self, last: Self) -> bool {
self >= first && self <= last
}
pub fn format_compact(self) -> String {
format!("{:02}/{:02}/{:04}", self.month, self.day, self.year)
}
pub fn parse_compact(text: &str) -> Option<Self> {
let mut parts = text.trim().split('/');
let month = parse_field(parts.next()?, 2)?;
let day = parse_field(parts.next()?, 2)?;
let year = parse_field(parts.next()?, 4)?;
if parts.next().is_some() {
return None;
}
let year = i32::try_from(year).ok()?;
if !(MIN_YEAR..=MAX_YEAR).contains(&year) || !(1..=12).contains(&month) {
return None;
}
if day < 1 || day > days_in_month(year, month) {
return None;
}
Some(Self { year, month, day })
}
}
fn parse_field(text: &str, max_digits: usize) -> Option<u32> {
if text.is_empty() || text.len() > max_digits || !text.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
text.parse::<u32>().ok()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DateRange {
pub start: MaterialDate,
pub end: Option<MaterialDate>,
}
impl DateRange {
pub fn new(start: MaterialDate) -> Self {
Self { start, end: None }
}
pub fn closed(start: MaterialDate, end: MaterialDate) -> Self {
if end < start {
Self {
start: end,
end: Some(start),
}
} else {
Self {
start,
end: Some(end),
}
}
}
pub fn is_complete(self) -> bool {
self.end.is_some()
}
pub fn contains(self, day: MaterialDate) -> bool {
match self.end {
Some(end) => day >= self.start && day <= end,
None => day == self.start,
}
}
pub fn extend(current: Option<Self>, date: MaterialDate) -> Self {
match current {
Some(range) if range.end.is_none() && date >= range.start => Self {
start: range.start,
end: Some(date),
},
_ => Self::new(date),
}
}
}
#[cfg(test)]
impl MaterialDate {
fn from_epoch_day_for_test(z: i64) -> Self {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
Self::new((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_leap_rule_covers_all_three_century_cases() {
assert!(is_leap_year(2000));
assert!(is_leap_year(1600));
assert!(!is_leap_year(1900));
assert!(!is_leap_year(2100));
assert!(is_leap_year(2024));
assert!(is_leap_year(2028));
assert!(!is_leap_year(2023));
assert!(!is_leap_year(2025));
}
#[test]
fn february_follows_the_leap_rule_and_every_other_month_is_fixed() {
assert_eq!(days_in_month(2000, 2), 29);
assert_eq!(days_in_month(1900, 2), 28);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
let lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for (i, expected) in lengths.into_iter().enumerate() {
let month = i as u32 + 1;
assert_eq!(days_in_month(2023, month), expected, "month {month}");
}
let leap_total: u32 = (1..=12).map(|m| days_in_month(2024, m)).sum();
let common_total: u32 = (1..=12).map(|m| days_in_month(2023, m)).sum();
assert_eq!(leap_total, 366);
assert_eq!(common_total, 365);
}
#[test]
fn an_out_of_range_month_clamps_rather_than_panicking() {
assert_eq!(days_in_month(2026, 0), days_in_month(2026, 1));
assert_eq!(days_in_month(2026, 13), days_in_month(2026, 12));
}
#[test]
fn weekdays_are_pinned_against_known_dates() {
let cases = [
(MaterialDate::new(1970, 1, 1), 4), (MaterialDate::new(2000, 1, 1), 6), (MaterialDate::new(1900, 1, 1), 1), (MaterialDate::new(2024, 2, 29), 4), (MaterialDate::new(2026, 8, 20), 4), (MaterialDate::new(2026, 3, 1), 0), (MaterialDate::new(2025, 12, 25), 4), (MaterialDate::new(1969, 7, 20), 0), ];
for (date, expected) in cases {
assert_eq!(date.weekday_index(), expected, "{date:?}");
}
}
#[test]
fn the_weekday_cycle_advances_by_one_per_day_across_a_month_boundary() {
let mut date = MaterialDate::new(2024, 2, 26);
let mut expected = date.weekday_index();
for _ in 0..10 {
assert_eq!(date.weekday_index(), expected);
date = MaterialDate::from_epoch_day_for_test(date.epoch_day() + 1);
expected = (expected + 1) % 7;
}
assert_eq!(date, MaterialDate::new(2024, 3, 7));
}
#[test]
fn epoch_days_are_pinned_and_monotonic() {
assert_eq!(MaterialDate::new(1970, 1, 1).epoch_day(), 0);
assert_eq!(MaterialDate::new(1970, 1, 2).epoch_day(), 1);
assert_eq!(MaterialDate::new(1969, 12, 31).epoch_day(), -1);
assert_eq!(MaterialDate::new(2000, 1, 1).epoch_day(), 10_957);
assert_eq!(MaterialDate::new(2026, 1, 1).epoch_day(), 20_454);
assert_eq!(
MaterialDate::new(2023, 1, 1).days_until(MaterialDate::new(2024, 1, 1)),
365
);
assert_eq!(
MaterialDate::new(2024, 1, 1).days_until(MaterialDate::new(2025, 1, 1)),
366,
"2024 is a leap year"
);
}
#[test]
fn ordering_is_chronological() {
let a = MaterialDate::new(2026, 1, 31);
let b = MaterialDate::new(2026, 2, 1);
let c = MaterialDate::new(2027, 1, 1);
assert!(a < b && b < c);
assert!(a.is_within(a, c));
assert!(!c.is_within(a, b));
assert!(a.epoch_day() < b.epoch_day());
}
#[test]
fn construction_clamps_every_component() {
assert_eq!(
MaterialDate::new(2024, 2, 31),
MaterialDate::new(2024, 2, 29)
);
assert_eq!(
MaterialDate::new(2023, 2, 31),
MaterialDate::new(2023, 2, 28)
);
assert_eq!(MaterialDate::new(2026, 0, 5).month(), 1);
assert_eq!(MaterialDate::new(2026, 99, 5).month(), 12);
assert_eq!(MaterialDate::new(2026, 4, 0).day(), 1);
assert_eq!(MaterialDate::new(-5, 1, 1).year(), MIN_YEAR);
assert_eq!(MaterialDate::new(50_000, 1, 1).year(), MAX_YEAR);
}
#[test]
fn first_of_month_keeps_the_month_and_drops_the_day() {
let d = MaterialDate::new(2026, 8, 20);
assert_eq!(d.first_of_month(), MaterialDate::new(2026, 8, 1));
assert_eq!(d.first_of_month().first_of_month(), d.first_of_month());
}
#[test]
fn add_months_wraps_the_year_and_shortens_a_too_long_day() {
let jan31 = MaterialDate::new(2026, 1, 31);
assert_eq!(jan31.add_months(1), MaterialDate::new(2026, 2, 28));
assert_eq!(jan31.add_months(11), MaterialDate::new(2026, 12, 31));
assert_eq!(jan31.add_months(12), MaterialDate::new(2027, 1, 31));
assert_eq!(jan31.add_months(-1), MaterialDate::new(2025, 12, 31));
assert_eq!(jan31.add_months(-13), MaterialDate::new(2024, 12, 31));
assert_eq!(jan31.add_months(0), jan31);
assert_eq!(
MaterialDate::new(2024, 1, 31).add_months(1),
MaterialDate::new(2024, 2, 29)
);
}
#[test]
fn add_months_saturates_at_the_calendar_ends() {
let last = MaterialDate::new(MAX_YEAR, 12, 31);
assert_eq!(last.add_months(1).year(), MAX_YEAR);
let first = MaterialDate::new(MIN_YEAR, 1, 1);
assert_eq!(first.add_months(-1).year(), MIN_YEAR);
assert_eq!(last.add_months(i32::MAX).year(), MAX_YEAR);
assert_eq!(first.add_months(i32::MIN).year(), MIN_YEAR);
}
#[test]
fn month_delta_counts_whole_months_and_ignores_the_day() {
let a = MaterialDate::new(2026, 1, 31);
let b = MaterialDate::new(2026, 3, 1);
assert_eq!(a.month_delta(b), 2);
assert_eq!(b.month_delta(a), -2);
assert_eq!(a.month_delta(a), 0);
assert_eq!(
MaterialDate::new(2020, 1, 1).month_delta(MaterialDate::new(2030, 1, 1)),
120
);
}
#[test]
fn clamp_to_pins_both_ends_and_survives_a_reversed_pair() {
let first = MaterialDate::new(2026, 1, 1);
let last = MaterialDate::new(2026, 12, 31);
assert_eq!(MaterialDate::new(2025, 6, 1).clamp_to(first, last), first);
assert_eq!(MaterialDate::new(2027, 6, 1).clamp_to(first, last), last);
let inside = MaterialDate::new(2026, 6, 1);
assert_eq!(inside.clamp_to(first, last), inside);
assert_eq!(MaterialDate::new(2027, 6, 1).clamp_to(last, first), last);
}
#[test]
fn compact_formatting_zero_pads_every_field() {
assert_eq!(
MaterialDate::new(2026, 8, 20).format_compact(),
"08/20/2026"
);
assert_eq!(MaterialDate::new(2026, 1, 1).format_compact(), "01/01/2026");
assert_eq!(
MaterialDate::new(999, 12, 31).format_compact(),
"12/31/0999"
);
}
#[test]
fn compact_parsing_round_trips_every_formatted_date() {
for year in [1900, 1970, 2000, 2024, 2026] {
for month in 1..=12 {
for day in [1, 15, days_in_month(year, month)] {
let d = MaterialDate::new(year, month, day);
assert_eq!(
MaterialDate::parse_compact(&d.format_compact()),
Some(d),
"{d:?}"
);
}
}
}
}
#[test]
fn compact_parsing_accepts_unpadded_fields_and_trims_whitespace() {
let expected = Some(MaterialDate::new(2026, 8, 2));
assert_eq!(MaterialDate::parse_compact("8/2/2026"), expected);
assert_eq!(MaterialDate::parse_compact(" 08/02/2026 \n"), expected);
}
#[test]
fn compact_parsing_rejects_every_malformed_shape() {
for bad in [
"",
" ",
"2026-08-20",
"08/20",
"08/20/2026/1",
"08//2026",
"/20/2026",
"08/20/",
"aa/20/2026",
"08/2o/2026",
"+8/20/2026",
"08/20/ 2026",
"13/01/2026", "00/01/2026", "02/30/2026", "02/29/2023", "04/31/2026", "08/20/00000", "08/20/0000", ] {
assert_eq!(MaterialDate::parse_compact(bad), None, "accepted {bad:?}");
}
assert_eq!(
MaterialDate::parse_compact("02/29/2024"),
Some(MaterialDate::new(2024, 2, 29))
);
}
#[test]
fn a_closed_range_orders_its_ends() {
let d = |day| MaterialDate::new(2026, 8, day);
let forward = DateRange::closed(d(3), d(9));
let reversed = DateRange::closed(d(9), d(3));
assert_eq!(forward, reversed);
assert!(forward.is_complete());
assert!(!DateRange::new(d(3)).is_complete());
}
#[test]
fn range_containment_is_inclusive_and_an_open_range_holds_only_its_start() {
let d = |day| MaterialDate::new(2026, 8, day);
let range = DateRange::closed(d(3), d(9));
assert!(range.contains(d(3)));
assert!(range.contains(d(6)));
assert!(range.contains(d(9)));
assert!(!range.contains(d(2)));
assert!(!range.contains(d(10)));
let open = DateRange::new(d(3));
assert!(open.contains(d(3)));
assert!(!open.contains(d(4)));
}
#[test]
fn extend_closes_forward_and_restarts_backward() {
let d = |day| MaterialDate::new(2026, 8, day);
let open = DateRange::extend(None, d(3));
assert_eq!(open, DateRange::new(d(3)));
assert_eq!(DateRange::extend(Some(open), d(3)).end, Some(d(3)));
assert_eq!(DateRange::extend(Some(open), d(9)).end, Some(d(9)));
assert_eq!(DateRange::extend(Some(open), d(1)), DateRange::new(d(1)));
let closed = DateRange::closed(d(3), d(9));
assert_eq!(DateRange::extend(Some(closed), d(5)), DateRange::new(d(5)));
assert_eq!(
DateRange::extend(Some(closed), d(20)),
DateRange::new(d(20))
);
}
}