1pub const DEFAULT_SINCE: i64 = -2_208_988_800;
9
10pub fn date_to_epoch(y: i64, m: i64, d: i64, h: i64, min: i64, s: i64) -> i64 {
13 let (mut yr, mut mo) = (y, m);
15 if mo <= 2 {
16 yr -= 1;
17 mo += 9;
18 } else {
19 mo -= 3;
20 }
21 let era = if yr >= 0 { yr } else { yr - 399 } / 400;
22 let yoe = yr - era * 400;
23 let doy = (153 * mo + 2) / 5 + d - 1;
24 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
25 let days = era * 146_097 + doe - 719_468;
26 days * 86_400 + h * 3600 + min * 60 + s
27}
28
29pub fn default_until() -> i64 {
32 let secs = std::time::SystemTime::now()
33 .duration_since(std::time::UNIX_EPOCH)
34 .unwrap_or_default()
35 .as_secs();
36 i64::try_from(secs).unwrap_or(i64::MAX)
37}
38
39pub fn epoch_to_year(epoch: i64) -> i64 {
44 let approx = 1970 + epoch / 31_557_600;
46 if date_to_epoch(approx + 1, 1, 1, 0, 0, 0) <= epoch {
47 approx + 1
48 } else if date_to_epoch(approx, 1, 1, 0, 0, 0) > epoch {
49 approx - 1
50 } else {
51 approx
52 }
53}
54
55pub fn parse(s: &str) -> Result<i64, String> {
60 let s = s.trim();
61
62 if let Ok(v) = s.parse::<i64>() {
64 if v > 100_000 {
65 return Ok(v);
67 }
68 if (1..=9999).contains(&v) {
69 return Ok(date_to_epoch(v, 1, 1, 0, 0, 0));
71 }
72 return Err(format!(
73 "ambiguous temporal value: {v}; use a year (1-9999) or epoch seconds (>100000)"
74 ));
75 }
76
77 let parts: Vec<&str> = s.splitn(2, 'T').collect();
79 let date_part = parts[0];
80 let time_part = if parts.len() > 1 { parts[1] } else { "" };
81
82 let date_segs: Vec<&str> = date_part.split('-').collect();
83 if date_segs.len() != 3 {
84 return Err(format!(
85 "invalid temporal format '{s}'; expected: YYYY, YYYY-MM-DD, YYYY-MM-DDTHH:MM, YYYY-MM-DDTHH:MM:SS, or epoch seconds"
86 ));
87 }
88
89 let y = date_segs[0].parse::<i64>().map_err(|_| format!("invalid year in '{s}'"))?;
90 let m = date_segs[1].parse::<i64>().map_err(|_| format!("invalid month in '{s}'"))?;
91 let d = date_segs[2].parse::<i64>().map_err(|_| format!("invalid day in '{s}'"))?;
92
93 if !(1..=12).contains(&m) {
94 return Err(format!("month out of range in '{s}'"));
95 }
96 if !(1..=31).contains(&d) {
97 return Err(format!("day out of range in '{s}'"));
98 }
99
100 if time_part.is_empty() {
101 return Ok(date_to_epoch(y, m, d, 0, 0, 0));
102 }
103
104 let time_segs: Vec<&str> = time_part.split(':').collect();
105 let h = time_segs
106 .first()
107 .and_then(|s| s.parse::<i64>().ok())
108 .ok_or_else(|| format!("invalid hour in '{s}'"))?;
109 let min = time_segs
110 .get(1)
111 .and_then(|s| s.parse::<i64>().ok())
112 .ok_or_else(|| format!("invalid minute in '{s}'"))?;
113 let sec = match time_segs.get(2) {
115 Some(s) => s.parse::<i64>().map_err(|_| format!("invalid seconds in '{s}'"))?,
116 None => 0,
117 };
118
119 if !(0..=23).contains(&h) {
120 return Err(format!("hour out of range in '{s}'"));
121 }
122 if !(0..=59).contains(&min) {
123 return Err(format!("minute out of range in '{s}'"));
124 }
125
126 Ok(date_to_epoch(y, m, d, h, min, sec))
127}
128
129pub fn parse_until(s: &str) -> Result<i64, String> {
134 let s = s.trim();
135 if let Ok(v) = s.parse::<i64>() {
136 if v > 100_000 {
137 return Ok(v);
138 }
139 if (1..=9999).contains(&v) {
140 return Ok(date_to_epoch(v + 1, 1, 1, 0, 0, 0));
141 }
142 }
143 if !s.contains('T') && s.contains('-') {
145 let e = parse(s)?;
146 return Ok(e + 86_400);
147 }
148 parse(s)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn parse_year() {
157 let e = parse("2025").unwrap();
158 assert_eq!(epoch_to_year(e), 2025);
159 }
160
161 #[test]
162 fn parse_date() {
163 let e = parse("2025-01-01").unwrap();
164 assert_eq!(e, date_to_epoch(2025, 1, 1, 0, 0, 0));
165 }
166
167 #[test]
168 fn parse_datetime() {
169 let e = parse("2025-03-28T14:00").unwrap();
170 assert_eq!(e, date_to_epoch(2025, 3, 28, 14, 0, 0));
171 }
172
173 #[test]
174 fn parse_datetime_seconds() {
175 let e = parse("2025-03-28T14:00:30").unwrap();
176 assert_eq!(e, date_to_epoch(2025, 3, 28, 14, 0, 30));
177 }
178
179 #[test]
180 fn parse_epoch() {
181 assert_eq!(parse("1711630800").unwrap(), 1_711_630_800);
182 }
183
184 #[test]
185 fn parse_invalid() {
186 assert!(parse("abc").is_err());
187 assert!(parse("2025-13-01").is_err());
188 assert!(parse("2025-01-32").is_err());
189 }
190
191 #[test]
192 fn default_since_is_1900() {
193 assert_eq!(epoch_to_year(DEFAULT_SINCE), 1900);
194 }
195
196 #[test]
199 fn until_year_is_exclusive() {
200 let e = parse_until("2025").unwrap();
201 assert_eq!(e, date_to_epoch(2026, 1, 1, 0, 0, 0));
202 assert_eq!(epoch_to_year(e - 1), 2025);
203 }
204
205 #[test]
206 fn until_date_is_end_of_day() {
207 let e = parse_until("2025-03-28").unwrap();
208 let start = parse("2025-03-28").unwrap();
209 assert_eq!(e, start + 86_400);
210 }
211
212 #[test]
213 fn until_datetime_is_exact() {
214 let e = parse_until("2025-03-28T16:00").unwrap();
215 assert_eq!(e, date_to_epoch(2025, 3, 28, 16, 0, 0));
216 }
217
218 #[test]
219 fn until_epoch_is_exact() {
220 assert_eq!(parse_until("1711638000").unwrap(), 1_711_638_000);
221 }
222
223 #[test]
226 fn roundtrip_year_boundaries() {
227 for y in [1900, 1970, 1999, 2000, 2001, 2024, 2025, 2038, 2100] {
228 let e = parse(&y.to_string()).unwrap();
229 assert_eq!(epoch_to_year(e), y, "roundtrip failed for year {y}");
230 }
231 }
232
233 #[test]
234 fn roundtrip_dates() {
235 let cases = [
236 ("2025-01-01", 2025, 1, 1),
237 ("2000-02-29", 2000, 2, 29), ("1970-01-01", 1970, 1, 1), ("2025-12-31", 2025, 12, 31), ];
241 for (input, y, m, d) in cases {
242 let e = parse(input).unwrap();
243 assert_eq!(e, date_to_epoch(y, m, d, 0, 0, 0), "parse failed for {input}");
244 }
245 }
246
247 #[test]
248 fn midnight_vs_2359() {
249 let midnight = parse("2025-03-28T00:00").unwrap();
250 let eod = parse("2025-03-28T23:59").unwrap();
251 assert_eq!(eod - midnight, 23 * 3600 + 59 * 60);
252 }
253
254 #[test]
255 fn whitespace_trimmed() {
256 assert_eq!(parse(" 2025 ").unwrap(), parse("2025").unwrap());
257 assert_eq!(parse(" 2025-03-28 ").unwrap(), parse("2025-03-28").unwrap());
258 }
259
260 #[test]
261 fn invalid_time_components() {
262 assert!(parse("2025-01-01T25:00").is_err()); assert!(parse("2025-01-01T12:60").is_err()); }
265
266 #[test]
267 fn ambiguous_small_number() {
268 assert!(parse("50000").is_err()); }
270}