1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct CalendarDay {
13 pub y: i64,
14 pub m: i64,
15 pub d: i64,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum DateBucket {
22 Day,
23 Week,
24 Month,
25 Quarter,
26 Year,
27}
28
29pub const DATE_BUCKETS: [&str; 5] = ["day", "week", "month", "quarter", "year"];
30
31impl DateBucket {
32 pub fn parse(text: &str) -> Option<Self> {
33 match text {
34 "day" => Some(Self::Day),
35 "week" => Some(Self::Week),
36 "month" => Some(Self::Month),
37 "quarter" => Some(Self::Quarter),
38 "year" => Some(Self::Year),
39 _ => None,
40 }
41 }
42
43 pub fn as_str(self) -> &'static str {
44 match self {
45 Self::Day => "day",
46 Self::Week => "week",
47 Self::Month => "month",
48 Self::Quarter => "quarter",
49 Self::Year => "year",
50 }
51 }
52}
53
54pub fn is_leap_year(y: i64) -> bool {
55 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
56}
57
58pub fn days_in_month(y: i64, m: i64) -> i64 {
59 match m {
60 2 if is_leap_year(y) => 29,
61 2 => 28,
62 4 | 6 | 9 | 11 => 30,
63 _ => 31,
64 }
65}
66
67pub fn weekday_of(y: i64, m: i64, d: i64) -> usize {
69 const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
70 let y = if m < 3 { y - 1 } else { y };
71 (y + y.div_euclid(4) - y.div_euclid(100) + y.div_euclid(400) + T[(m.clamp(1, 12) - 1) as usize] + d).rem_euclid(7) as usize
72}
73
74pub fn day_of_year(y: i64, m: i64, d: i64) -> i64 {
76 (1..m).map(|i| days_in_month(y, i)).sum::<i64>() + d
77}
78
79pub fn iso_weeks_in_year(y: i64) -> i64 {
81 let jan1 = weekday_of(y, 1, 1);
82 if jan1 == 4 || (jan1 == 3 && is_leap_year(y)) {
83 53
84 } else {
85 52
86 }
87}
88
89pub fn iso_week(y: i64, m: i64, d: i64) -> (i64, i64) {
91 let wd = match weekday_of(y, m, d) {
92 0 => 7,
93 n => n as i64,
94 };
95 let week = (day_of_year(y, m, d) - wd + 10).div_euclid(7);
96 if week < 1 {
97 return (y - 1, iso_weeks_in_year(y - 1));
98 }
99 if week > iso_weeks_in_year(y) {
100 return (y + 1, 1);
101 }
102 (y, week)
103}
104
105fn digits(b: &[u8], from: usize, n: usize) -> bool {
106 b.len() >= from + n && b[from..from + n].iter().all(u8::is_ascii_digit)
107}
108
109fn int(b: &[u8], from: usize, n: usize) -> i64 {
110 b[from..from + n].iter().fold(0, |acc, c| acc * 10 + i64::from(c - b'0'))
111}
112
113pub fn parse_day(text: &str) -> Option<CalendarDay> {
116 let b = text.as_bytes();
117 if !(digits(b, 0, 4) && b.get(4) == Some(&b'-') && digits(b, 5, 2) && b.get(7) == Some(&b'-') && digits(b, 8, 2)) {
118 return None;
119 }
120 if b.len() > 10 && b[10] != b'T' && b[10] != b' ' {
121 return None;
122 }
123 let (y, m, d) = (int(b, 0, 4), int(b, 5, 2), int(b, 8, 2));
124 if !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m) {
125 return None;
126 }
127 Some(CalendarDay { y, m, d })
128}
129
130pub fn parse_month(text: &str) -> Option<(i64, i64)> {
132 let b = text.as_bytes();
133 if !(digits(b, 0, 4) && b.get(4) == Some(&b'-') && digits(b, 5, 2)) || (b.len() > 7 && b[7] != b'-') {
134 return None;
135 }
136 let m = int(b, 5, 2);
137 (1..=12).contains(&m).then(|| (int(b, 0, 4), m))
138}
139
140pub fn parse_year(text: &str) -> Option<i64> {
142 let b = text.as_bytes();
143 if !digits(b, 0, 4) || (b.len() > 4 && b[4] != b'-') {
144 return None;
145 }
146 Some(int(b, 0, 4))
147}
148
149pub fn format_day(day: CalendarDay) -> String {
151 format!("{:04}-{:02}-{:02}", day.y, day.m, day.d)
152}
153
154pub fn date_bucket(text: &str, bucket: DateBucket) -> Option<String> {
158 match bucket {
159 DateBucket::Day => parse_day(text).map(format_day),
160 DateBucket::Week => parse_day(text).map(|day| {
161 let (year, week) = iso_week(day.y, day.m, day.d);
162 format!("{year:04}-W{week:02}")
163 }),
164 DateBucket::Year => parse_year(text).map(|y| format!("{y:04}")),
165 DateBucket::Month => parse_month(text).map(|(y, m)| format!("{y:04}-{m:02}")),
166 DateBucket::Quarter => parse_month(text).map(|(y, m)| format!("{y:04}-Q{}", (m - 1) / 3 + 1)),
167 }
168}
169
170pub fn day_number(y: i64, m: i64, d: i64) -> i64 {
174 let yy = if m <= 2 { y - 1 } else { y };
175 let era = yy.div_euclid(400);
176 let yoe = yy - era * 400;
177 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2).div_euclid(5) + d - 1;
178 let doe = yoe * 365 + yoe.div_euclid(4) - yoe.div_euclid(100) + doy;
179 era * 146_097 + doe - 719_468
180}
181
182pub fn day_from_number(n: i64) -> CalendarDay {
183 let z = n + 719_468;
184 let era = z.div_euclid(146_097);
185 let doe = z - era * 146_097;
186 let yoe = (doe - doe.div_euclid(1460) + doe.div_euclid(36_524) - doe.div_euclid(146_096)).div_euclid(365);
187 let doy = doe - (365 * yoe + yoe.div_euclid(4) - yoe.div_euclid(100));
188 let mp = (5 * doy + 2).div_euclid(153);
189 let d = doy - (153 * mp + 2).div_euclid(5) + 1;
190 let m = if mp < 10 { mp + 3 } else { mp - 9 };
191 CalendarDay { y: yoe + era * 400 + i64::from(m <= 2), m, d }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn iso_weeks_cross_years() {
200 assert_eq!(date_bucket("2027-01-01", DateBucket::Week).as_deref(), Some("2026-W53"));
201 assert_eq!(date_bucket("2024-12-30", DateBucket::Week).as_deref(), Some("2025-W01"));
202 assert_eq!(date_bucket("2026-02-30", DateBucket::Day), None);
203 }
204}