Skip to main content

guise/input/
date.rs

1//! `Date` — a plain calendar date plus the math the date components need.
2//!
3//! Pure logic, no gpui: leap years, month lengths, weekday math, month grids
4//! for calendar layouts, and a small token formatter/parser. Algorithms for
5//! day-count conversion follow Howard Hinnant's civil-date derivations.
6
7use std::fmt;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// English month names, January first.
11pub const MONTH_NAMES: [&str; 12] = [
12    "January",
13    "February",
14    "March",
15    "April",
16    "May",
17    "June",
18    "July",
19    "August",
20    "September",
21    "October",
22    "November",
23    "December",
24];
25
26/// Day of week. `index()` is 0-based from Sunday.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Weekday {
29    Sunday,
30    Monday,
31    Tuesday,
32    Wednesday,
33    Thursday,
34    Friday,
35    Saturday,
36}
37
38impl Weekday {
39    pub const ALL: [Weekday; 7] = [
40        Weekday::Sunday,
41        Weekday::Monday,
42        Weekday::Tuesday,
43        Weekday::Wednesday,
44        Weekday::Thursday,
45        Weekday::Friday,
46        Weekday::Saturday,
47    ];
48
49    pub fn index(self) -> u32 {
50        self as u32
51    }
52
53    pub fn from_index(index: u32) -> Weekday {
54        Weekday::ALL[(index % 7) as usize]
55    }
56
57    pub fn name(self) -> &'static str {
58        match self {
59            Weekday::Sunday => "Sunday",
60            Weekday::Monday => "Monday",
61            Weekday::Tuesday => "Tuesday",
62            Weekday::Wednesday => "Wednesday",
63            Weekday::Thursday => "Thursday",
64            Weekday::Friday => "Friday",
65            Weekday::Saturday => "Saturday",
66        }
67    }
68
69    /// Two-letter header label ("Su", "Mo", …).
70    pub fn short(self) -> &'static str {
71        &self.name()[..2]
72    }
73}
74
75/// A calendar date. Construct with [`Date::new`], which validates.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
77pub struct Date {
78    year: i32,
79    month: u32,
80    day: u32,
81}
82
83/// True for Gregorian leap years.
84pub fn is_leap_year(year: i32) -> bool {
85    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
86}
87
88/// Number of days in the given month (1–12) of the given year.
89pub fn days_in_month(year: i32, month: u32) -> u32 {
90    match month {
91        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
92        4 | 6 | 9 | 11 => 30,
93        2 => {
94            if is_leap_year(year) {
95                29
96            } else {
97                28
98            }
99        }
100        _ => 0,
101    }
102}
103
104/// Days since 1970-01-01 (negative before it).
105fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
106    let y = i64::from(if month <= 2 { year - 1 } else { year });
107    let era = if y >= 0 { y } else { y - 399 } / 400;
108    let yoe = y - era * 400;
109    let mp = (i64::from(month) + 9) % 12;
110    let doy = (153 * mp + 2) / 5 + i64::from(day) - 1;
111    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
112    era * 146097 + doe - 719468
113}
114
115/// Inverse of [`days_from_civil`].
116fn civil_from_days(days: i64) -> (i32, u32, u32) {
117    let z = days + 719468;
118    let era = if z >= 0 { z } else { z - 146096 } / 146097;
119    let doe = z - era * 146097;
120    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
121    let y = yoe + era * 400;
122    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
123    let mp = (5 * doy + 2) / 153;
124    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
125    let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
126    let year = (if month <= 2 { y + 1 } else { y }) as i32;
127    (year, month, day)
128}
129
130impl Date {
131    /// A validated date, or `None` for impossible ones (Feb 30, month 13, …).
132    pub fn new(year: i32, month: u32, day: u32) -> Option<Date> {
133        if (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month) {
134            Some(Date { year, month, day })
135        } else {
136            None
137        }
138    }
139
140    /// Today in UTC (std has no timezone database; a calendar highlight is
141    /// the intended use, not civil timekeeping).
142    pub fn today() -> Date {
143        let secs = SystemTime::now()
144            .duration_since(UNIX_EPOCH)
145            .map(|d| d.as_secs() as i64)
146            .unwrap_or(0);
147        Date::from_days(secs.div_euclid(86_400))
148    }
149
150    pub fn year(self) -> i32 {
151        self.year
152    }
153
154    pub fn month(self) -> u32 {
155        self.month
156    }
157
158    pub fn day(self) -> u32 {
159        self.day
160    }
161
162    /// Days since 1970-01-01.
163    pub fn to_days(self) -> i64 {
164        days_from_civil(self.year, self.month, self.day)
165    }
166
167    /// The date `days` after 1970-01-01.
168    pub fn from_days(days: i64) -> Date {
169        let (year, month, day) = civil_from_days(days);
170        Date { year, month, day }
171    }
172
173    pub fn weekday(self) -> Weekday {
174        Weekday::from_index((self.to_days() + 4).rem_euclid(7) as u32)
175    }
176
177    pub fn add_days(self, days: i64) -> Date {
178        Date::from_days(self.to_days() + days)
179    }
180
181    /// Shift by whole months, clamping the day to the target month's length
182    /// (Jan 31 + 1 month = Feb 28/29).
183    pub fn add_months(self, months: i32) -> Date {
184        let total = self.year * 12 + (self.month as i32 - 1) + months;
185        let year = total.div_euclid(12);
186        let month = (total.rem_euclid(12) + 1) as u32;
187        let day = self.day.min(days_in_month(year, month));
188        Date { year, month, day }
189    }
190
191    pub fn month_name(self) -> &'static str {
192        MONTH_NAMES[(self.month - 1) as usize]
193    }
194
195    /// Render through a token pattern. Tokens: `YYYY`, `MM`, `M`, `DD`, `D`,
196    /// `MMM` (Jan), `MMMM` (January). Anything else passes through.
197    pub fn format(self, pattern: &str) -> String {
198        let mut out = String::with_capacity(pattern.len() + 4);
199        let bytes = pattern.as_bytes();
200        let mut i = 0;
201        while i < bytes.len() {
202            let run = |ch: u8| bytes[i..].iter().take_while(|&&b| b == ch).count();
203            match bytes[i] {
204                b'Y' => {
205                    let n = run(b'Y');
206                    out.push_str(&format!("{:04}", self.year));
207                    i += n;
208                }
209                b'M' => match run(b'M') {
210                    1 => {
211                        out.push_str(&self.month.to_string());
212                        i += 1;
213                    }
214                    2 => {
215                        out.push_str(&format!("{:02}", self.month));
216                        i += 2;
217                    }
218                    3 => {
219                        out.push_str(&self.month_name()[..3]);
220                        i += 3;
221                    }
222                    _ => {
223                        out.push_str(self.month_name());
224                        i += run(b'M');
225                    }
226                },
227                b'D' => {
228                    let n = run(b'D');
229                    if n >= 2 {
230                        out.push_str(&format!("{:02}", self.day));
231                    } else {
232                        out.push_str(&self.day.to_string());
233                    }
234                    i += n;
235                }
236                other => {
237                    out.push(other as char);
238                    i += 1;
239                }
240            }
241        }
242        out
243    }
244
245    /// Parse `"YYYY-MM-DD"` (also tolerates single-digit month/day).
246    pub fn parse_iso(s: &str) -> Option<Date> {
247        let mut parts = s.trim().split('-');
248        let year: i32 = parts.next()?.parse().ok()?;
249        let month: u32 = parts.next()?.parse().ok()?;
250        let day: u32 = parts.next()?.parse().ok()?;
251        if parts.next().is_some() {
252            return None;
253        }
254        Date::new(year, month, day)
255    }
256}
257
258impl fmt::Display for Date {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
261    }
262}
263
264/// The 42 cells (6 weeks × 7 days) a month calendar shows for `year`/`month`,
265/// starting each week on `week_start`. Leading/trailing cells come from the
266/// neighboring months; compare `.month()` against `month` to dim them.
267pub fn month_grid(year: i32, month: u32, week_start: Weekday) -> Vec<Date> {
268    let first = Date::new(year, month, 1).unwrap_or_else(|| Date::from_days(0));
269    let lead = (first.weekday().index() + 7 - week_start.index()) % 7;
270    let start = first.add_days(-i64::from(lead));
271    (0..42).map(|i| start.add_days(i)).collect()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn leap_years() {
280        assert!(is_leap_year(2024));
281        assert!(is_leap_year(2000));
282        assert!(!is_leap_year(1900));
283        assert!(!is_leap_year(2026));
284    }
285
286    #[test]
287    fn month_lengths() {
288        assert_eq!(days_in_month(2026, 1), 31);
289        assert_eq!(days_in_month(2026, 2), 28);
290        assert_eq!(days_in_month(2024, 2), 29);
291        assert_eq!(days_in_month(2026, 4), 30);
292        assert_eq!(days_in_month(2026, 13), 0);
293    }
294
295    #[test]
296    fn validation() {
297        assert!(Date::new(2026, 2, 29).is_none());
298        assert!(Date::new(2024, 2, 29).is_some());
299        assert!(Date::new(2026, 0, 1).is_none());
300        assert!(Date::new(2026, 12, 31).is_some());
301        assert!(Date::new(2026, 6, 0).is_none());
302    }
303
304    #[test]
305    fn epoch_round_trip() {
306        assert_eq!(Date::new(1970, 1, 1).unwrap().to_days(), 0);
307        assert_eq!(Date::from_days(0), Date::new(1970, 1, 1).unwrap());
308        for days in [-1_000_000, -365, -1, 0, 1, 365, 738_000, 1_000_000] {
309            assert_eq!(Date::from_days(days).to_days(), days);
310        }
311    }
312
313    #[test]
314    fn known_weekdays() {
315        // 1970-01-01 was a Thursday; 2026-07-14 is a Tuesday.
316        assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), Weekday::Thursday);
317        assert_eq!(Date::new(2026, 7, 14).unwrap().weekday(), Weekday::Tuesday);
318        assert_eq!(Date::new(2000, 1, 1).unwrap().weekday(), Weekday::Saturday);
319        assert_eq!(Date::new(1899, 12, 31).unwrap().weekday(), Weekday::Sunday);
320    }
321
322    #[test]
323    fn add_days_crosses_boundaries() {
324        let d = Date::new(2026, 12, 31).unwrap();
325        assert_eq!(d.add_days(1), Date::new(2027, 1, 1).unwrap());
326        assert_eq!(d.add_days(-365), Date::new(2025, 12, 31).unwrap());
327        let leap = Date::new(2024, 2, 28).unwrap();
328        assert_eq!(leap.add_days(1), Date::new(2024, 2, 29).unwrap());
329        assert_eq!(leap.add_days(2), Date::new(2024, 3, 1).unwrap());
330    }
331
332    #[test]
333    fn add_months_clamps() {
334        let jan31 = Date::new(2026, 1, 31).unwrap();
335        assert_eq!(jan31.add_months(1), Date::new(2026, 2, 28).unwrap());
336        assert_eq!(jan31.add_months(13), Date::new(2027, 2, 28).unwrap());
337        assert_eq!(jan31.add_months(-2), Date::new(2025, 11, 30).unwrap());
338        let jul = Date::new(2026, 7, 14).unwrap();
339        assert_eq!(jul.add_months(12), Date::new(2027, 7, 14).unwrap());
340        assert_eq!(jul.add_months(-7), Date::new(2025, 12, 14).unwrap());
341    }
342
343    #[test]
344    fn ordering() {
345        let a = Date::new(2026, 7, 14).unwrap();
346        let b = Date::new(2026, 7, 15).unwrap();
347        let c = Date::new(2027, 1, 1).unwrap();
348        assert!(a < b && b < c);
349    }
350
351    #[test]
352    fn grid_starts_on_week_start() {
353        // July 2026 starts on a Wednesday.
354        let grid = month_grid(2026, 7, Weekday::Sunday);
355        assert_eq!(grid.len(), 42);
356        assert_eq!(grid[0], Date::new(2026, 6, 28).unwrap());
357        assert_eq!(grid[3], Date::new(2026, 7, 1).unwrap());
358        assert_eq!(grid[41], Date::new(2026, 8, 8).unwrap());
359        for cell in &grid {
360            assert_eq!(
361                cell.weekday().index(),
362                (cell.to_days() + 4).rem_euclid(7) as u32
363            );
364        }
365
366        let monday = month_grid(2026, 7, Weekday::Monday);
367        assert_eq!(monday[0].weekday(), Weekday::Monday);
368        assert_eq!(monday[2], Date::new(2026, 7, 1).unwrap());
369    }
370
371    #[test]
372    fn grid_when_month_starts_on_week_start() {
373        // March 2026 starts on a Sunday: no leading cells.
374        let grid = month_grid(2026, 3, Weekday::Sunday);
375        assert_eq!(grid[0], Date::new(2026, 3, 1).unwrap());
376    }
377
378    #[test]
379    fn formatting() {
380        let d = Date::new(2026, 7, 4).unwrap();
381        assert_eq!(d.format("YYYY-MM-DD"), "2026-07-04");
382        assert_eq!(d.format("M/D/YYYY"), "7/4/2026");
383        assert_eq!(d.format("MMM D, YYYY"), "Jul 4, 2026");
384        assert_eq!(d.format("MMMM D"), "July 4");
385        assert_eq!(d.to_string(), "2026-07-04");
386    }
387
388    #[test]
389    fn iso_parsing() {
390        assert_eq!(Date::parse_iso("2026-07-04"), Date::new(2026, 7, 4));
391        assert_eq!(Date::parse_iso(" 2026-7-4 "), Date::new(2026, 7, 4));
392        assert_eq!(Date::parse_iso("2026-02-30"), None);
393        assert_eq!(Date::parse_iso("2026-07"), None);
394        assert_eq!(Date::parse_iso("2026-07-04-01"), None);
395        assert_eq!(Date::parse_iso("garbage"), None);
396    }
397
398    #[test]
399    fn weekday_helpers() {
400        assert_eq!(Weekday::Sunday.short(), "Su");
401        assert_eq!(Weekday::from_index(8), Weekday::Monday);
402        assert_eq!(Weekday::Saturday.index(), 6);
403    }
404}