1pub const MIN: i32 = -719_162;
5pub const MAX: i32 = 2_932_896;
6
7pub fn valid(days: i32) -> bool {
8 (MIN..=MAX).contains(&days)
9}
10
11pub fn from_civil(y: i64, m: u32, d: u32) -> i64 {
13 let y = if m <= 2 { y - 1 } else { y };
15 let era = if y >= 0 { y } else { y - 399 } / 400;
16 let yoe = y - era * 400;
17 let m = m as i64;
18 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
19 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
20 era * 146_097 + doe - 719_468
21}
22
23pub fn to_civil(days: i64) -> (i64, u32, u32) {
25 let z = days + 719_468;
26 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
27 let doe = z - era * 146_097;
28 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
29 let y = yoe + era * 400;
30 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
31 let mp = (5 * doy + 2) / 153;
32 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
33 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
34 (if m <= 2 { y + 1 } else { y }, m, d)
35}
36
37pub fn format(days: i32) -> String {
38 let (y, m, d) = to_civil(days as i64);
39 format!("{y:04}-{m:02}-{d:02}")
40}
41
42pub fn from_parts(y: i64, m: i64, d: i64) -> Option<i32> {
43 if !(1..=9999).contains(&y) || !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m as u32) as i64 {
44 return None;
45 }
46 Some(from_civil(y, m as u32, d as u32) as i32)
47}
48
49pub fn parse(text: &str) -> Option<i32> {
51 let b = text.as_bytes();
52 if b.len() != 10
53 || b[4] != b'-'
54 || b[7] != b'-'
55 || !b
56 .iter()
57 .enumerate()
58 .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
59 {
60 return None;
61 }
62 from_parts(
63 text[..4].parse().ok()?,
64 text[5..7].parse().ok()?,
65 text[8..].parse().ok()?,
66 )
67}
68
69pub fn add_days(days: i32, n: i64) -> Option<i32> {
70 let result = (days as i64).checked_add(n)?;
71 (valid(days) && (MIN as i64..=MAX as i64).contains(&result)).then_some(result as i32)
72}
73
74pub fn from_unix_seconds(seconds: i64) -> Option<i32> {
75 let days = i32::try_from(seconds.div_euclid(86_400)).ok()?;
76 valid(days).then_some(days)
77}
78
79pub fn add_months(days: i32, n: i64) -> Option<i32> {
80 if !valid(days) {
81 return None;
82 }
83 let (y, m, d) = to_civil(days as i64);
84 let month = (y as i128 - 1) * 12 + m as i128 - 1 + n as i128;
85 if !(0..9999 * 12).contains(&month) {
86 return None;
87 }
88 let (y, m) = ((month / 12 + 1) as i64, (month % 12 + 1) as u32);
89 from_parts(y, m as i64, d.min(days_in_month(y, m)) as i64)
90}
91
92pub fn add_years(days: i32, n: i64) -> Option<i32> {
93 add_months(days, n.checked_mul(12)?)
94}
95
96pub fn start_of_month(days: i32) -> Option<i32> {
97 if !valid(days) {
98 return None;
99 }
100 let (y, m, _) = to_civil(days as i64);
101 from_parts(y, m as i64, 1)
102}
103
104pub fn end_of_month(days: i32) -> Option<i32> {
105 if !valid(days) {
106 return None;
107 }
108 let (y, m, _) = to_civil(days as i64);
109 from_parts(y, m as i64, days_in_month(y, m) as i64)
110}
111
112fn days_in_month(y: i64, m: u32) -> u32 {
113 match m {
114 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
115 4 | 6 | 9 | 11 => 30,
116 _ if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
117 _ => 28,
118 }
119}
120
121pub fn weekday(days: i32) -> u32 {
123 ((days as i64 + 3).rem_euclid(7)) as u32
125}
126
127pub const WEEKDAYS: [&str; 7] = [
128 "Monday",
129 "Tuesday",
130 "Wednesday",
131 "Thursday",
132 "Friday",
133 "Saturday",
134 "Sunday",
135];
136
137pub fn add_workdays(days: i32, n: i64) -> Option<i32> {
140 if !valid(days) {
141 return None;
142 }
143 let mut d = days as i128;
144 let mut w = weekday(days) as i128;
145 let mut left = n.unsigned_abs() as i128;
146 let step = if n >= 0 { 1 } else { -1 };
147 if left > 0 && w >= 5 {
148 d += if step > 0 { 7 - w } else { 4 - w };
149 w = if step > 0 { 0 } else { 4 };
150 left -= 1;
151 }
152 let extra = left % 5;
153 let weekend = if (step > 0 && w + extra >= 5) || (step < 0 && extra > w) {
154 2
155 } else {
156 0
157 };
158 if left > 0 {
159 d += step * (left / 5 * 7 + extra + weekend);
160 }
161 (MIN as i128..=MAX as i128).contains(&d).then_some(d as i32)
162}
163
164pub fn add_workdays_with_holidays(days: i32, n: i64, holidays: &[i32]) -> Option<i32> {
167 let mut target = add_workdays(days, n)?;
168 if n > 0 {
169 for &h in &holidays[holidays.partition_point(|h| *h <= days)..] {
170 if h > target {
171 break;
172 }
173 target = add_workdays(target, 1)?;
174 }
175 } else if n < 0 {
176 for &h in holidays[..holidays.partition_point(|h| *h < days)].iter().rev() {
177 if h < target {
178 break;
179 }
180 target = add_workdays(target, -1)?;
181 }
182 }
183 Some(target)
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn calendar_round_trips_and_parsing_is_strict() {
192 assert_eq!(from_parts(1, 1, 1), Some(MIN));
193 assert_eq!(from_parts(9999, 12, 31), Some(MAX));
194 for d in from_civil(1800, 1, 1)..from_civil(2200, 1, 1) {
195 let (y, m, day) = to_civil(d);
196 assert_eq!(from_parts(y, m as i64, day as i64), Some(d as i32));
197 assert_eq!(parse(&format(d as i32)), Some(d as i32));
198 }
199 for s in [
200 "2026-02-29",
201 "1900-02-29",
202 "0000-01-01",
203 "10000-01-01",
204 "2026-1-01",
205 "+026-01-01",
206 "2026-01-1",
207 "2026-01-01 ",
208 "2026-01-01T00:00:00Z",
209 "🙂-01-01",
210 "9223372036854775807-01-01",
211 ] {
212 assert_eq!(parse(s), None, "{s}");
213 }
214 assert!(parse("2000-02-29").is_some());
215 assert_eq!(from_unix_seconds(-1), parse("1969-12-31"));
216 assert_eq!(from_unix_seconds(0), parse("1970-01-01"));
217 assert_eq!(from_unix_seconds(i64::MAX), None);
218 }
219
220 #[test]
221 fn workday_jumps_match_a_daily_walk_in_both_directions() {
222 let base = parse("2026-09-28").unwrap();
223 let holidays = [base + 1, base + 4, base + 7, base + 8, base + 9, base + 10, base + 11];
224 for start in base - 14..=base + 14 {
225 for n in -40i64..=40 {
226 for calendar in [&[][..], &holidays[..]] {
227 let mut expected = start;
228 let mut left = n.unsigned_abs();
229 while left != 0 {
230 expected += n.signum() as i32;
231 if weekday(expected) < 5 && !calendar.contains(&expected) {
232 left -= 1;
233 }
234 }
235 assert_eq!(
236 add_workdays_with_holidays(start, n, calendar),
237 Some(expected),
238 "{start}, {n}"
239 );
240 }
241 }
242 }
243 for n in [i64::MIN, i64::MAX] {
244 assert_eq!(add_workdays(base, n), None);
245 }
246 assert_eq!(add_workdays(MAX, 1), None);
247 assert_eq!(add_workdays(MIN, -1), None);
248 }
249
250 #[test]
251 fn month_and_year_shifts_clamp_and_check_bounds() {
252 let jan = parse("2024-01-31").unwrap();
253 assert_eq!(add_months(jan, 1), parse("2024-02-29"));
254 assert_eq!(add_months(jan, 2), parse("2024-03-31"));
255 assert_eq!(add_months(jan, -1), parse("2023-12-31"));
256 assert_eq!(add_years(parse("2024-02-29").unwrap(), 1), parse("2025-02-28"));
257 assert_eq!(end_of_month(MAX), Some(MAX));
258 assert_eq!(start_of_month(MIN), Some(MIN));
259 for n in [i64::MIN, i64::MAX] {
260 assert_eq!(add_months(jan, n), None);
261 assert_eq!(add_years(jan, n), None);
262 assert_eq!(add_days(jan, n), None);
263 }
264 assert_eq!(add_months(MIN, -1), None);
265 assert_eq!(add_years(MAX, 1), None);
266 }
267}