Skip to main content

frust_material/date_picker/
date.rs

1// Ported from `material_3_expressive` v1.0.8's date-picker date helpers (MIT,
2// © 2026 Paa Developments;
3// `tmp/material_3_expressive/lib/components/date_pickers/utils/m3e_date_picker_utils.dart`,
4// `models/m3e_date_picker_models.dart`, `models/m3e_calendar_labels.dart`,
5// retrieved 2026-08-20).
6// Upstream: <https://github.com/paadevelopments/material_3_expressive>
7//
8// Porting decision: the reference reaches Dart's built-in `DateTime` for every
9// calendar computation — `DateTime(year, month + 1, 0).day` for a month's
10// length, `.weekday` for the grid offset, `isBefore`/`isAfter` for bounds. This
11// crate's production dependencies are `frust` + `kurbo`/`peniko` only (no
12// `chrono`, no `time` — see `docs/PLUGINS_ARCHITECTURE.md`'s Design-System
13// Plugins), so the civil-calendar arithmetic is implemented here directly, over
14// a proleptic-Gregorian day count.
15
16//! The date-picker family's own civil-calendar value type and arithmetic.
17//!
18//! [`MaterialDate`] is a whole-day, time-zone-free, proleptic-Gregorian date:
19//! exactly the `DateTime(y, m, d)` shape the reference normalizes every input
20//! to through its `M3EDatePickerUtils.dateOnly`, with the time component gone
21//! rather than pinned to midnight.
22//!
23//! # No date library, by charter
24//!
25//! Everything a calendar grid needs is bounded and closed-form: how long a
26//! month is, whether a year leaps, and which weekday a month starts on. All
27//! three are derived here from one primitive — [`MaterialDate::epoch_day`],
28//! Howard Hinnant's `days_from_civil` (public domain, `chrono`-Compatible Howard
29//! Hinnant date algorithms, <https://howardhinnant.github.io/date_algorithms.html>)
30//! — which additionally gives day-difference and ordering for free.
31//!
32//! # Clamping, not failing
33//!
34//! [`MaterialDate::new`] never fails: the year is clamped to
35//! [`MIN_YEAR`]`..=`[`MAX_YEAR`], the month to `1..=12`, and the day to that
36//! month's own length. A calendar widget resolving a month page or a year cell
37//! has no useful error path — an out-of-range component means "the nearest real
38//! date", the same way [`MaterialDate::add_months`] shortens 31 January by a
39//! month into 28/29 February (the reference's own `normalizeSelectedDay`).
40//! [`MaterialDate::parse_compact`], by contrast, *does* reject — a user typing
41//! `13/40/2026` must see the field's format error, not a silently clamped date.
42
43/// The earliest year [`MaterialDate`] represents. Year 1 rather than year 0:
44/// the proleptic Gregorian calendar has no year 0, and a picker that could
45/// address one would format it as a date no locale can parse back.
46pub const MIN_YEAR: i32 = 1;
47
48/// The latest year [`MaterialDate`] represents — the four-digit ceiling
49/// [`MaterialDate::format_compact`]'s `yyyy` field can round-trip.
50pub const MAX_YEAR: i32 = 9999;
51
52/// Whether `year` is a leap year in the proleptic Gregorian calendar: divisible
53/// by 4, except centuries, except every fourth century.
54///
55/// ```
56/// use frust_material::date_picker::is_leap_year;
57///
58/// assert!(is_leap_year(2024));
59/// assert!(is_leap_year(2000));
60/// assert!(!is_leap_year(1900));
61/// assert!(!is_leap_year(2023));
62/// ```
63pub const fn is_leap_year(year: i32) -> bool {
64    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
65}
66
67/// How many days `month` (`1..=12`, clamped) has in `year` — the reference's
68/// `M3EDatePickerUtils.daysInMonth`.
69pub fn days_in_month(year: i32, month: u32) -> u32 {
70    match month.clamp(1, 12) {
71        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
72        4 | 6 | 9 | 11 => 30,
73        // February.
74        _ => {
75            if is_leap_year(year) {
76                29
77            } else {
78                28
79            }
80        }
81    }
82}
83
84/// A whole calendar day: year, month (`1..=12`), day (`1..=`[`days_in_month`]).
85///
86/// Named `MaterialDate` rather than a bare `Date` because this crate flat
87/// re-exports every widget/spec type at its root (`frust_material::MaterialDate`),
88/// where a bare `Date` would collide with any other catalog or app type of the
89/// same name — the same reason [`crate::MaterialTokens`]/[`crate::MaterialMotion`]
90/// carry the prefix.
91///
92/// Ordering is chronological ([`Ord`] over `(year, month, day)`, which is
93/// equivalent to ordering by [`Self::epoch_day`] because every field is
94/// normalized on construction), so `first_date <= day && day <= last_date` is
95/// the bounds test the whole family uses.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
97pub struct MaterialDate {
98    year: i32,
99    month: u32,
100    day: u32,
101}
102
103impl MaterialDate {
104    /// The nearest real date to `(year, month, day)`: the year clamped to
105    /// [`MIN_YEAR`]`..=`[`MAX_YEAR`], the month to `1..=12`, the day to that
106    /// month's own length. See the [module docs](self)' clamping note.
107    ///
108    /// ```
109    /// use frust_material::MaterialDate;
110    ///
111    /// // 31 February resolves to the 29th in a leap year, the 28th otherwise.
112    /// assert_eq!(MaterialDate::new(2024, 2, 31), MaterialDate::new(2024, 2, 29));
113    /// assert_eq!(MaterialDate::new(2023, 2, 31), MaterialDate::new(2023, 2, 28));
114    /// ```
115    pub fn new(year: i32, month: u32, day: u32) -> Self {
116        let year = year.clamp(MIN_YEAR, MAX_YEAR);
117        let month = month.clamp(1, 12);
118        let day = day.clamp(1, days_in_month(year, month));
119        Self { year, month, day }
120    }
121
122    /// This date's year.
123    pub fn year(self) -> i32 {
124        self.year
125    }
126
127    /// This date's month, `1..=12`.
128    pub fn month(self) -> u32 {
129        self.month
130    }
131
132    /// This date's day of the month, `1..=`[`days_in_month`].
133    pub fn day(self) -> u32 {
134        self.day
135    }
136
137    /// The first of this date's own month — the reference's
138    /// `M3EDatePickerUtils.getMonth(date.year, date.month)`, which is how every
139    /// "displayed month" value in this family is normalized.
140    pub fn first_of_month(self) -> Self {
141        Self {
142            year: self.year,
143            month: self.month,
144            day: 1,
145        }
146    }
147
148    /// Days since 1970-01-01 (negative before it) — Howard Hinnant's
149    /// `days_from_civil`. The one primitive [`Self::weekday_index`] and
150    /// [`Self::days_until`] are derived from.
151    ///
152    /// ```
153    /// use frust_material::MaterialDate;
154    ///
155    /// assert_eq!(MaterialDate::new(1970, 1, 1).epoch_day(), 0);
156    /// assert_eq!(MaterialDate::new(1969, 12, 31).epoch_day(), -1);
157    /// ```
158    pub fn epoch_day(self) -> i64 {
159        let y = i64::from(self.year) - i64::from(self.month <= 2);
160        let era = if y >= 0 { y } else { y - 399 } / 400;
161        // Year of era, 0..=399.
162        let yoe = y - era * 400;
163        let m = i64::from(self.month);
164        let d = i64::from(self.day);
165        // Day of year counted from 1 March, 0..=365.
166        let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
167        // Day of era, 0..=146096.
168        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
169        era * 146_097 + doe - 719_468
170    }
171
172    /// Signed day count from `self` to `other` (`other - self`).
173    pub fn days_until(self, other: Self) -> i64 {
174        other.epoch_day() - self.epoch_day()
175    }
176
177    /// This date's weekday as a **Sunday-first** index: `0` Sunday through `6`
178    /// Saturday.
179    ///
180    /// The reference reads Dart's `DateTime.weekday` (Monday-first, `1..=7`)
181    /// and folds it with `% 7` (`M3ECalendarLabels.firstWeekday`); this returns
182    /// the folded value directly, since every consumer here — the grid's
183    /// leading-blank count and the weekday header row — wants the Sunday-first
184    /// one.
185    ///
186    /// ```
187    /// use frust_material::MaterialDate;
188    ///
189    /// // 2000-01-01 was a Saturday; 2026-08-20 a Thursday.
190    /// assert_eq!(MaterialDate::new(2000, 1, 1).weekday_index(), 6);
191    /// assert_eq!(MaterialDate::new(2026, 8, 20).weekday_index(), 4);
192    /// ```
193    pub fn weekday_index(self) -> u32 {
194        (self.epoch_day() + 4).rem_euclid(7) as u32
195    }
196
197    /// How many days this date's own month has.
198    pub fn days_in_this_month(self) -> u32 {
199        days_in_month(self.year, self.month)
200    }
201
202    /// This date shifted by `delta` whole months, keeping the day of the month
203    /// where the target month is long enough and shortening it where it is not
204    /// — the reference's `addMonthsToMonthDate` + `normalizeSelectedDay` pair.
205    ///
206    /// ```
207    /// use frust_material::MaterialDate;
208    ///
209    /// let jan31 = MaterialDate::new(2026, 1, 31);
210    /// assert_eq!(jan31.add_months(1), MaterialDate::new(2026, 2, 28));
211    /// assert_eq!(jan31.add_months(-1), MaterialDate::new(2025, 12, 31));
212    /// ```
213    pub fn add_months(self, delta: i32) -> Self {
214        let total = i64::from(self.year) * 12 + i64::from(self.month) - 1 + i64::from(delta);
215        let year = total.div_euclid(12);
216        let month = total.rem_euclid(12) as u32 + 1;
217        // `new` clamps the year back into range, so a runaway `delta` saturates
218        // at the calendar's ends rather than wrapping.
219        let year = year.clamp(i64::from(MIN_YEAR), i64::from(MAX_YEAR)) as i32;
220        Self::new(year, month, self.day)
221    }
222
223    /// Whole months from `self`'s month to `other`'s — the reference's
224    /// `monthDelta`. Day-of-month is ignored on both sides.
225    pub fn month_delta(self, other: Self) -> i32 {
226        (other.year - self.year) * 12 + other.month as i32 - self.month as i32
227    }
228
229    /// `self` clamped into `first..=last` (the reference's `clampDate`). A
230    /// reversed pair (`last < first`) collapses onto `first`, so a caller that
231    /// hands the bounds in the wrong order gets a defined date rather than a
232    /// panic.
233    pub fn clamp_to(self, first: Self, last: Self) -> Self {
234        if self < first {
235            return first;
236        }
237        if self > last {
238            return if last < first { first } else { last };
239        }
240        self
241    }
242
243    /// Whether `self` lies within `first..=last`, inclusive — the reference's
244    /// `isSelectable` *bounds* half (its optional predicate is applied by the
245    /// widget, which owns the caller-supplied closure).
246    pub fn is_within(self, first: Self, last: Self) -> bool {
247        self >= first && self <= last
248    }
249
250    /// This date in the compact, parseable `mm/dd/yyyy` form — Flutter's
251    /// `MaterialLocalizations.formatCompactDate` for `en_US`, which is the
252    /// format the reference's input mode both renders and parses.
253    ///
254    /// See [`crate::DatePickerStrings`] for why this port ships one fixed
255    /// English/US format rather than a locale-resolved one.
256    ///
257    /// ```
258    /// use frust_material::MaterialDate;
259    ///
260    /// assert_eq!(MaterialDate::new(2026, 8, 20).format_compact(), "08/20/2026");
261    /// ```
262    pub fn format_compact(self) -> String {
263        format!("{:02}/{:02}/{:04}", self.month, self.day, self.year)
264    }
265
266    /// Parse the compact `mm/dd/yyyy` form back into a date, or `None` if the
267    /// text is not exactly that — Flutter's `parseCompactDate` for `en_US`,
268    /// which is what drives the input mode's *format* error.
269    ///
270    /// Rejects (rather than clamping — see the [module docs](self)): a part
271    /// count other than three, any non-ASCII-digit character, an empty part, a
272    /// month outside `1..=12`, a day outside the month's own length, and a year
273    /// outside [`MIN_YEAR`]`..=`[`MAX_YEAR`]. Surrounding whitespace is trimmed
274    /// first, so a paste with a trailing newline still parses.
275    ///
276    /// ```
277    /// use frust_material::MaterialDate;
278    ///
279    /// assert_eq!(MaterialDate::parse_compact("08/20/2026"), Some(MaterialDate::new(2026, 8, 20)));
280    /// assert_eq!(MaterialDate::parse_compact("8/2/2026"), Some(MaterialDate::new(2026, 8, 2)));
281    /// assert_eq!(MaterialDate::parse_compact("02/30/2026"), None);
282    /// assert_eq!(MaterialDate::parse_compact("2026-08-20"), None);
283    /// ```
284    pub fn parse_compact(text: &str) -> Option<Self> {
285        let mut parts = text.trim().split('/');
286        let month = parse_field(parts.next()?, 2)?;
287        let day = parse_field(parts.next()?, 2)?;
288        let year = parse_field(parts.next()?, 4)?;
289        if parts.next().is_some() {
290            return None;
291        }
292        let year = i32::try_from(year).ok()?;
293        if !(MIN_YEAR..=MAX_YEAR).contains(&year) || !(1..=12).contains(&month) {
294            return None;
295        }
296        if day < 1 || day > days_in_month(year, month) {
297            return None;
298        }
299        Some(Self { year, month, day })
300    }
301}
302
303/// One `/`-separated field of a compact date: `1..=max_digits` ASCII digits and
304/// nothing else.
305fn parse_field(text: &str, max_digits: usize) -> Option<u32> {
306    if text.is_empty() || text.len() > max_digits || !text.bytes().all(|b| b.is_ascii_digit()) {
307        return None;
308    }
309    text.parse::<u32>().ok()
310}
311
312/// A selected date range: a `start`, and an `end` that is `None` while the user
313/// is mid-selection — the reference's `M3EDateRange`.
314///
315/// # Scope: the model ships, the range *dialog* does not
316///
317/// The reference ships a full range picker
318/// (`M3EDateRangePickerDialog`/`M3ECalendarDateRangePicker`), presented as a
319/// vertically scrolling list of *every* month between `firstDate` and
320/// `lastDate`. This port carries the range **model** (this type, plus
321/// [`Self::extend`]'s selection rule) and the day cell's range visuals
322/// ([`crate::CalendarDatePicker::range`]), so an app can drive range selection
323/// against the single-month calendar; the scrolling multi-month dialog itself
324/// is a deliberate v1 cut, for the same reason [`crate::dialog`]'s selection
325/// list has no scroll physics — see [`mod@crate::date_picker`]'s scope section.
326#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
327pub struct DateRange {
328    /// The first day of the range.
329    pub start: MaterialDate,
330    /// The last day of the range, or `None` while only a start has been picked.
331    pub end: Option<MaterialDate>,
332}
333
334impl DateRange {
335    /// A range with only its `start` picked.
336    pub fn new(start: MaterialDate) -> Self {
337        Self { start, end: None }
338    }
339
340    /// A closed `start..=end` range. A reversed pair is swapped rather than
341    /// stored inverted, so [`Self::contains`] can never be vacuously false.
342    pub fn closed(start: MaterialDate, end: MaterialDate) -> Self {
343        if end < start {
344            Self {
345                start: end,
346                end: Some(start),
347            }
348        } else {
349            Self {
350                start,
351                end: Some(end),
352            }
353        }
354    }
355
356    /// Whether both ends are picked — the reference's `M3EDateRange.isComplete`.
357    pub fn is_complete(self) -> bool {
358        self.end.is_some()
359    }
360
361    /// Whether `day` lies inside the range, inclusive of both ends. An
362    /// unfinished range contains only its own start.
363    pub fn contains(self, day: MaterialDate) -> bool {
364        match self.end {
365            Some(end) => day >= self.start && day <= end,
366            None => day == self.start,
367        }
368    }
369
370    /// The range a tap on `date` produces, given `current` — the reference's
371    /// `_M3ECalendarDateRangePickerState._updateSelection`: a tap **closes** an
372    /// open range when it lands on or after its start, and otherwise **restarts**
373    /// a fresh open range at the tapped day.
374    ///
375    /// ```
376    /// use frust_material::{DateRange, MaterialDate};
377    ///
378    /// let d = |day| MaterialDate::new(2026, 8, day);
379    /// let opened = DateRange::extend(None, d(3));
380    /// assert_eq!(opened, DateRange::new(d(3)));
381    ///
382    /// // A later day closes it...
383    /// assert_eq!(DateRange::extend(Some(opened), d(9)).end, Some(d(9)));
384    /// // ...an earlier one restarts it.
385    /// assert_eq!(DateRange::extend(Some(opened), d(1)), DateRange::new(d(1)));
386    /// // A tap on a *closed* range always restarts.
387    /// let closed = DateRange::closed(d(3), d(9));
388    /// assert_eq!(DateRange::extend(Some(closed), d(5)), DateRange::new(d(5)));
389    /// ```
390    pub fn extend(current: Option<Self>, date: MaterialDate) -> Self {
391        match current {
392            Some(range) if range.end.is_none() && date >= range.start => Self {
393                start: range.start,
394                end: Some(date),
395            },
396            _ => Self::new(date),
397        }
398    }
399}
400
401#[cfg(test)]
402impl MaterialDate {
403    /// Test-only inverse of [`Self::epoch_day`] (Hinnant's `civil_from_days`),
404    /// used to walk a weekday cycle across month/year boundaries without
405    /// hand-writing every date.
406    fn from_epoch_day_for_test(z: i64) -> Self {
407        let z = z + 719_468;
408        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
409        let doe = z - era * 146_097;
410        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
411        let y = yoe + era * 400;
412        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
413        let mp = (5 * doy + 2) / 153;
414        let d = doy - (153 * mp + 2) / 5 + 1;
415        let m = if mp < 10 { mp + 3 } else { mp - 9 };
416        Self::new((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    // ---- leap years ----------------------------------------------------
425
426    #[test]
427    fn the_leap_rule_covers_all_three_century_cases() {
428        // Divisible by 400 leaps; by 100 (not 400) does not; by 4 does.
429        assert!(is_leap_year(2000));
430        assert!(is_leap_year(1600));
431        assert!(!is_leap_year(1900));
432        assert!(!is_leap_year(2100));
433        assert!(is_leap_year(2024));
434        assert!(is_leap_year(2028));
435        assert!(!is_leap_year(2023));
436        assert!(!is_leap_year(2025));
437    }
438
439    #[test]
440    fn february_follows_the_leap_rule_and_every_other_month_is_fixed() {
441        assert_eq!(days_in_month(2000, 2), 29);
442        assert_eq!(days_in_month(1900, 2), 28);
443        assert_eq!(days_in_month(2024, 2), 29);
444        assert_eq!(days_in_month(2023, 2), 28);
445
446        let lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
447        for (i, expected) in lengths.into_iter().enumerate() {
448            let month = i as u32 + 1;
449            assert_eq!(days_in_month(2023, month), expected, "month {month}");
450        }
451        // A leap year differs in exactly one month.
452        let leap_total: u32 = (1..=12).map(|m| days_in_month(2024, m)).sum();
453        let common_total: u32 = (1..=12).map(|m| days_in_month(2023, m)).sum();
454        assert_eq!(leap_total, 366);
455        assert_eq!(common_total, 365);
456    }
457
458    #[test]
459    fn an_out_of_range_month_clamps_rather_than_panicking() {
460        assert_eq!(days_in_month(2026, 0), days_in_month(2026, 1));
461        assert_eq!(days_in_month(2026, 13), days_in_month(2026, 12));
462    }
463
464    // ---- weekday -------------------------------------------------------
465
466    #[test]
467    fn weekdays_are_pinned_against_known_dates() {
468        // Sunday-first indices: 0 Sun, 1 Mon, 2 Tue, 3 Wed, 4 Thu, 5 Fri, 6 Sat.
469        let cases = [
470            (MaterialDate::new(1970, 1, 1), 4),   // Thursday (epoch)
471            (MaterialDate::new(2000, 1, 1), 6),   // Saturday
472            (MaterialDate::new(1900, 1, 1), 1),   // Monday
473            (MaterialDate::new(2024, 2, 29), 4),  // Thursday (leap day)
474            (MaterialDate::new(2026, 8, 20), 4),  // Thursday
475            (MaterialDate::new(2026, 3, 1), 0),   // Sunday
476            (MaterialDate::new(2025, 12, 25), 4), // Thursday
477            (MaterialDate::new(1969, 7, 20), 0),  // Sunday (Apollo 11)
478        ];
479        for (date, expected) in cases {
480            assert_eq!(date.weekday_index(), expected, "{date:?}");
481        }
482    }
483
484    #[test]
485    fn the_weekday_cycle_advances_by_one_per_day_across_a_month_boundary() {
486        let mut date = MaterialDate::new(2024, 2, 26);
487        let mut expected = date.weekday_index();
488        for _ in 0..10 {
489            assert_eq!(date.weekday_index(), expected);
490            date = MaterialDate::from_epoch_day_for_test(date.epoch_day() + 1);
491            expected = (expected + 1) % 7;
492        }
493        // Ten days on from 26 Feb 2024 (a leap year) is 7 March.
494        assert_eq!(date, MaterialDate::new(2024, 3, 7));
495    }
496
497    // ---- epoch day -----------------------------------------------------
498
499    #[test]
500    fn epoch_days_are_pinned_and_monotonic() {
501        assert_eq!(MaterialDate::new(1970, 1, 1).epoch_day(), 0);
502        assert_eq!(MaterialDate::new(1970, 1, 2).epoch_day(), 1);
503        assert_eq!(MaterialDate::new(1969, 12, 31).epoch_day(), -1);
504        assert_eq!(MaterialDate::new(2000, 1, 1).epoch_day(), 10_957);
505        assert_eq!(MaterialDate::new(2026, 1, 1).epoch_day(), 20_454);
506        assert_eq!(
507            MaterialDate::new(2023, 1, 1).days_until(MaterialDate::new(2024, 1, 1)),
508            365
509        );
510        assert_eq!(
511            MaterialDate::new(2024, 1, 1).days_until(MaterialDate::new(2025, 1, 1)),
512            366,
513            "2024 is a leap year"
514        );
515    }
516
517    #[test]
518    fn ordering_is_chronological() {
519        let a = MaterialDate::new(2026, 1, 31);
520        let b = MaterialDate::new(2026, 2, 1);
521        let c = MaterialDate::new(2027, 1, 1);
522        assert!(a < b && b < c);
523        assert!(a.is_within(a, c));
524        assert!(!c.is_within(a, b));
525        // Tuple ordering and epoch-day ordering agree on normalized values.
526        assert!(a.epoch_day() < b.epoch_day());
527    }
528
529    // ---- construction / clamping ---------------------------------------
530
531    #[test]
532    fn construction_clamps_every_component() {
533        assert_eq!(
534            MaterialDate::new(2024, 2, 31),
535            MaterialDate::new(2024, 2, 29)
536        );
537        assert_eq!(
538            MaterialDate::new(2023, 2, 31),
539            MaterialDate::new(2023, 2, 28)
540        );
541        assert_eq!(MaterialDate::new(2026, 0, 5).month(), 1);
542        assert_eq!(MaterialDate::new(2026, 99, 5).month(), 12);
543        assert_eq!(MaterialDate::new(2026, 4, 0).day(), 1);
544        assert_eq!(MaterialDate::new(-5, 1, 1).year(), MIN_YEAR);
545        assert_eq!(MaterialDate::new(50_000, 1, 1).year(), MAX_YEAR);
546    }
547
548    #[test]
549    fn first_of_month_keeps_the_month_and_drops_the_day() {
550        let d = MaterialDate::new(2026, 8, 20);
551        assert_eq!(d.first_of_month(), MaterialDate::new(2026, 8, 1));
552        assert_eq!(d.first_of_month().first_of_month(), d.first_of_month());
553    }
554
555    // ---- month arithmetic ----------------------------------------------
556
557    #[test]
558    fn add_months_wraps_the_year_and_shortens_a_too_long_day() {
559        let jan31 = MaterialDate::new(2026, 1, 31);
560        assert_eq!(jan31.add_months(1), MaterialDate::new(2026, 2, 28));
561        assert_eq!(jan31.add_months(11), MaterialDate::new(2026, 12, 31));
562        assert_eq!(jan31.add_months(12), MaterialDate::new(2027, 1, 31));
563        assert_eq!(jan31.add_months(-1), MaterialDate::new(2025, 12, 31));
564        assert_eq!(jan31.add_months(-13), MaterialDate::new(2024, 12, 31));
565        assert_eq!(jan31.add_months(0), jan31);
566        // A leap February keeps the 29th.
567        assert_eq!(
568            MaterialDate::new(2024, 1, 31).add_months(1),
569            MaterialDate::new(2024, 2, 29)
570        );
571    }
572
573    #[test]
574    fn add_months_saturates_at_the_calendar_ends() {
575        let last = MaterialDate::new(MAX_YEAR, 12, 31);
576        assert_eq!(last.add_months(1).year(), MAX_YEAR);
577        let first = MaterialDate::new(MIN_YEAR, 1, 1);
578        assert_eq!(first.add_months(-1).year(), MIN_YEAR);
579        // Even an absurd delta stays in range rather than wrapping.
580        assert_eq!(last.add_months(i32::MAX).year(), MAX_YEAR);
581        assert_eq!(first.add_months(i32::MIN).year(), MIN_YEAR);
582    }
583
584    #[test]
585    fn month_delta_counts_whole_months_and_ignores_the_day() {
586        let a = MaterialDate::new(2026, 1, 31);
587        let b = MaterialDate::new(2026, 3, 1);
588        assert_eq!(a.month_delta(b), 2);
589        assert_eq!(b.month_delta(a), -2);
590        assert_eq!(a.month_delta(a), 0);
591        assert_eq!(
592            MaterialDate::new(2020, 1, 1).month_delta(MaterialDate::new(2030, 1, 1)),
593            120
594        );
595    }
596
597    #[test]
598    fn clamp_to_pins_both_ends_and_survives_a_reversed_pair() {
599        let first = MaterialDate::new(2026, 1, 1);
600        let last = MaterialDate::new(2026, 12, 31);
601        assert_eq!(MaterialDate::new(2025, 6, 1).clamp_to(first, last), first);
602        assert_eq!(MaterialDate::new(2027, 6, 1).clamp_to(first, last), last);
603        let inside = MaterialDate::new(2026, 6, 1);
604        assert_eq!(inside.clamp_to(first, last), inside);
605        // Reversed bounds collapse onto `first` rather than producing a value
606        // outside both.
607        assert_eq!(MaterialDate::new(2027, 6, 1).clamp_to(last, first), last);
608    }
609
610    // ---- compact format / parse ----------------------------------------
611
612    #[test]
613    fn compact_formatting_zero_pads_every_field() {
614        assert_eq!(
615            MaterialDate::new(2026, 8, 20).format_compact(),
616            "08/20/2026"
617        );
618        assert_eq!(MaterialDate::new(2026, 1, 1).format_compact(), "01/01/2026");
619        assert_eq!(
620            MaterialDate::new(999, 12, 31).format_compact(),
621            "12/31/0999"
622        );
623    }
624
625    #[test]
626    fn compact_parsing_round_trips_every_formatted_date() {
627        for year in [1900, 1970, 2000, 2024, 2026] {
628            for month in 1..=12 {
629                for day in [1, 15, days_in_month(year, month)] {
630                    let d = MaterialDate::new(year, month, day);
631                    assert_eq!(
632                        MaterialDate::parse_compact(&d.format_compact()),
633                        Some(d),
634                        "{d:?}"
635                    );
636                }
637            }
638        }
639    }
640
641    #[test]
642    fn compact_parsing_accepts_unpadded_fields_and_trims_whitespace() {
643        let expected = Some(MaterialDate::new(2026, 8, 2));
644        assert_eq!(MaterialDate::parse_compact("8/2/2026"), expected);
645        assert_eq!(MaterialDate::parse_compact("  08/02/2026 \n"), expected);
646    }
647
648    #[test]
649    fn compact_parsing_rejects_every_malformed_shape() {
650        for bad in [
651            "",
652            "   ",
653            "2026-08-20",
654            "08/20",
655            "08/20/2026/1",
656            "08//2026",
657            "/20/2026",
658            "08/20/",
659            "aa/20/2026",
660            "08/2o/2026",
661            "+8/20/2026",
662            "08/20/ 2026",
663            "13/01/2026",  // month out of range
664            "00/01/2026",  // month zero
665            "02/30/2026",  // day past February
666            "02/29/2023",  // not a leap year
667            "04/31/2026",  // day past a 30-day month
668            "08/20/00000", // five-digit year
669            "08/20/0000",  // year below MIN_YEAR
670        ] {
671            assert_eq!(MaterialDate::parse_compact(bad), None, "accepted {bad:?}");
672        }
673        // 29 February *is* accepted in a leap year.
674        assert_eq!(
675            MaterialDate::parse_compact("02/29/2024"),
676            Some(MaterialDate::new(2024, 2, 29))
677        );
678    }
679
680    // ---- ranges ---------------------------------------------------------
681
682    #[test]
683    fn a_closed_range_orders_its_ends() {
684        let d = |day| MaterialDate::new(2026, 8, day);
685        let forward = DateRange::closed(d(3), d(9));
686        let reversed = DateRange::closed(d(9), d(3));
687        assert_eq!(forward, reversed);
688        assert!(forward.is_complete());
689        assert!(!DateRange::new(d(3)).is_complete());
690    }
691
692    #[test]
693    fn range_containment_is_inclusive_and_an_open_range_holds_only_its_start() {
694        let d = |day| MaterialDate::new(2026, 8, day);
695        let range = DateRange::closed(d(3), d(9));
696        assert!(range.contains(d(3)));
697        assert!(range.contains(d(6)));
698        assert!(range.contains(d(9)));
699        assert!(!range.contains(d(2)));
700        assert!(!range.contains(d(10)));
701
702        let open = DateRange::new(d(3));
703        assert!(open.contains(d(3)));
704        assert!(!open.contains(d(4)));
705    }
706
707    #[test]
708    fn extend_closes_forward_and_restarts_backward() {
709        let d = |day| MaterialDate::new(2026, 8, day);
710        let open = DateRange::extend(None, d(3));
711        assert_eq!(open, DateRange::new(d(3)));
712
713        // Same day closes the range onto itself (`!date.isBefore(start)`).
714        assert_eq!(DateRange::extend(Some(open), d(3)).end, Some(d(3)));
715        assert_eq!(DateRange::extend(Some(open), d(9)).end, Some(d(9)));
716        assert_eq!(DateRange::extend(Some(open), d(1)), DateRange::new(d(1)));
717
718        let closed = DateRange::closed(d(3), d(9));
719        assert_eq!(DateRange::extend(Some(closed), d(5)), DateRange::new(d(5)));
720        assert_eq!(
721            DateRange::extend(Some(closed), d(20)),
722            DateRange::new(d(20))
723        );
724    }
725}