Skip to main content

sley_core/date/
mod.rs

1//! Canonical civil-calendar arithmetic and date-token parsing (git `date.c`
2//! subset): the one home for the helpers previously duplicated across
3//! cli/log_cli.rs, rev/setup.rs, rev/lib.rs, and commands/am.rs.
4//!
5//! All functions take borrowed inputs; allocation is limited to returned
6//! values. The civil math is Howard Hinnant's proleptic-Gregorian pair
7//! (`days_from_civil`/`civil_from_days`), which matches git's date
8//! arithmetic over the full range.
9
10pub mod approxidate;
11
12/// True for Gregorian leap years (divisible by 4, except centuries not
13/// divisible by 400).
14pub fn is_leap_year(year: i64) -> bool {
15    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
16}
17
18/// Days in `month` (1-12) of `year`; 0 when the month index is out of range.
19pub fn days_in_month(year: i64, month: u32) -> u32 {
20    match month {
21        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
22        4 | 6 | 9 | 11 => 30,
23        2 if is_leap_year(year) => 29,
24        2 => 28,
25        _ => 0,
26    }
27}
28
29/// Days between 1970-01-01 and the given civil date (Howard Hinnant's
30/// algorithm). Month/day are not range-checked: callers that need validated
31/// input use [`parse_date_ymd`] or check [`days_in_month`] first.
32pub fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
33    let year = year - i64::from(month <= 2);
34    let era = if year >= 0 { year } else { year - 399 } / 400;
35    let year_of_era = year - era * 400;
36    let month = i64::from(month);
37    let day = i64::from(day);
38    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
39    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
40    era * 146_097 + day_of_era - 719_468
41}
42
43/// Inverse of [`days_from_civil`]: civil `(year, month, day)` for a day count.
44pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
45    let z = days + 719_468;
46    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
47    let day_of_era = z - era * 146_097;
48    let year_of_era =
49        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
50    let year = year_of_era + era * 400;
51    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
52    let month_prime = (5 * day_of_year + 2) / 153;
53    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
54    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
55    let year = year + i64::from(month <= 2);
56    (year, month as u32, day as u32)
57}
58
59/// Parse a strict `YYYY-MM-DD` calendar date, rejecting trailing components,
60/// months outside 1-12, and days beyond the month's length.
61pub fn parse_date_ymd(value: &str) -> Option<(i64, u32, u32)> {
62    let mut parts = value.split('-');
63    let year = parts.next()?.parse::<i64>().ok()?;
64    let month = parts.next()?.parse::<u32>().ok()?;
65    let day = parts.next()?.parse::<u32>().ok()?;
66    if parts.next().is_some() || !(1..=12).contains(&month) {
67        return None;
68    }
69    let max_day = days_in_month(year, month);
70    if !(1..=max_day).contains(&day) {
71        return None;
72    }
73    Some((year, month, day))
74}
75
76/// Parse a strict `HH:MM:SS` clock time, rejecting trailing components and
77/// out-of-range fields (`second == 60` is rejected here; leap-second-tolerant
78/// callers keep their own looser parser).
79pub fn parse_time_hms(value: &str) -> Option<(u32, u32, u32)> {
80    let mut parts = value.split(':');
81    let hour = parts.next()?.parse::<u32>().ok()?;
82    let minute = parts.next()?.parse::<u32>().ok()?;
83    let second = parts.next()?.parse::<u32>().ok()?;
84    if parts.next().is_some() || hour > 23 || minute > 59 || second > 59 {
85        return None;
86    }
87    Some((hour, minute, second))
88}
89
90/// Parse a timezone token into seconds east of UTC. Only git's canonical
91/// `<+|->HHMM` form is accepted: exactly five bytes, sign first, then four
92/// ASCII digits, with hours at most 23 and minutes at most 59 (the bounds of
93/// git's date.c `match_tz`). Returns `None` otherwise.
94pub fn parse_tz_offset(value: &str) -> Option<i64> {
95    let bytes = value.as_bytes();
96    if bytes.len() != 5
97        || !matches!(bytes.first(), Some(b'+' | b'-'))
98        || !bytes[1..].iter().all(|byte| byte.is_ascii_digit())
99    {
100        return None;
101    }
102    let hours = value[1..3].parse::<i64>().ok()?;
103    let minutes = value[3..5].parse::<i64>().ok()?;
104    if hours > 23 || minutes > 59 {
105        return None;
106    }
107    let offset = hours * 3_600 + minutes * 60;
108    if bytes[0] == b'-' {
109        Some(-offset)
110    } else {
111        Some(offset)
112    }
113}
114
115/// Split an ISO 8601 time portion (the part after `T`) into the bare time and
116/// an optional embedded timezone suffix. A trailing `Z` normalises to the
117/// static `"+0000"`; a trailing `±HHMM` is borrowed from the input. Otherwise
118/// the whole string is the time and any timezone arrives separately.
119pub fn split_embedded_timezone(rest: &str) -> (&str, Option<&str>) {
120    if let Some(time) = rest.strip_suffix('Z') {
121        return (time, Some("+0000"));
122    }
123    let bytes = rest.as_bytes();
124    if bytes.len() >= 5 {
125        let tz_start = bytes.len() - 5;
126        if matches!(bytes[tz_start], b'+' | b'-')
127            && bytes[tz_start + 1..]
128                .iter()
129                .all(|byte| byte.is_ascii_digit())
130        {
131            return (&rest[..tz_start], Some(&rest[tz_start..]));
132        }
133    }
134    (rest, None)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn civil_round_trip() {
143        assert_eq!(days_from_civil(1970, 1, 1), 0);
144        assert_eq!(civil_from_days(0), (1970, 1, 1));
145        assert_eq!(days_from_civil(2000, 3, 1), 11017);
146        assert_eq!(civil_from_days(11_017), (2000, 3, 1));
147        // Leap-day handling around century boundaries.
148        assert_eq!(days_in_month(2000, 2), 29);
149        assert_eq!(days_in_month(1900, 2), 28);
150        assert_eq!(days_in_month(2024, 2), 29);
151        assert_eq!(days_in_month(2023, 13), 0);
152        assert!(!is_leap_year(1900));
153        assert!(is_leap_year(2000));
154    }
155
156    #[test]
157    fn ymd_and_time_validation() {
158        assert_eq!(parse_date_ymd("2024-02-29"), Some((2024, 2, 29)));
159        assert_eq!(parse_date_ymd("2023-02-29"), None);
160        assert_eq!(parse_date_ymd("2024-13-01"), None);
161        assert_eq!(parse_date_ymd("2024-01-02T00:00:00"), None);
162        assert_eq!(parse_time_hms("23:59:59"), Some((23, 59, 59)));
163        assert_eq!(parse_time_hms("24:00:00"), None);
164        assert_eq!(parse_time_hms("12:60:00"), None);
165        assert_eq!(parse_time_hms("12:00"), None);
166    }
167
168    #[test]
169    fn tz_offsets() {
170        assert_eq!(parse_tz_offset("+0000"), Some(0));
171        assert_eq!(parse_tz_offset("-0530"), Some(-19_800));
172        assert_eq!(parse_tz_offset("+2400"), None);
173        assert_eq!(parse_tz_offset("+0060"), None);
174        assert_eq!(parse_tz_offset("+000a"), None);
175        assert_eq!(parse_tz_offset("+000"), None);
176    }
177
178    #[test]
179    fn embedded_timezone_suffixes() {
180        assert_eq!(split_embedded_timezone("00:00:01Z"), ("00:00:01", Some("+0000")));
181        assert_eq!(
182            split_embedded_timezone("03:04:05+0100"),
183            ("03:04:05", Some("+0100"))
184        );
185        assert_eq!(split_embedded_timezone("03:04:05"), ("03:04:05", None));
186    }
187}