1use std::fmt;
35use std::time::{SystemTime, UNIX_EPOCH};
36
37#[derive(Debug, PartialEq, Eq)]
53pub enum EpochError {
54 InvalidFormat,
56 InvalidDate,
58 InvalidTime,
60}
61
62impl fmt::Display for EpochError {
63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 EpochError::InvalidFormat => {
66 formatter.write_str("invalid datetime format — expected YYYY-MM-DDTHH:MM:SSZ")
67 }
68 EpochError::InvalidDate => formatter
69 .write_str("invalid date — month must be 1–12 and day must be valid for the month"),
70 EpochError::InvalidTime => {
71 formatter.write_str("invalid time — hour 0–23, minute 0–59, second 0–59")
72 }
73 }
74 }
75}
76
77impl std::error::Error for EpochError {}
78
79pub fn now_secs() -> u64 {
89 SystemTime::now()
90 .duration_since(UNIX_EPOCH)
91 .expect("system clock is before Unix epoch")
92 .as_secs()
93}
94
95pub fn now_millis() -> u128 {
105 SystemTime::now()
106 .duration_since(UNIX_EPOCH)
107 .expect("system clock is before Unix epoch")
108 .as_millis()
109}
110
111pub fn to_utc_string(epoch_secs: i64) -> String {
127 let (year, month, day, hour, minute, second) = epoch_to_parts(epoch_secs);
128 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
129}
130
131pub fn from_utc_string(datetime: &str) -> Result<i64, EpochError> {
152 let (date_part, time_part) = split_datetime(datetime)?;
153 let (year, month, day) = parse_date(date_part)?;
154 let (hour, minute, second) = parse_time(time_part)?;
155 Ok(parts_to_epoch(year, month, day, hour, minute, second))
156}
157
158pub fn is_leap_year(year: i32) -> bool {
174 (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
175}
176
177pub fn days_in_month(month: u32, year: i32) -> u32 {
191 match month {
192 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
193 4 | 6 | 9 | 11 => 30,
194 2 if is_leap_year(year) => 29,
195 2 => 28,
196 _ => 0,
197 }
198}
199
200fn days_in_year(year: i32) -> i64 {
201 if is_leap_year(year) { 366 } else { 365 }
202}
203
204fn epoch_to_parts(epoch_secs: i64) -> (i32, u32, u32, u32, u32, u32) {
205 let total_seconds = epoch_secs;
206 let second = total_seconds.rem_euclid(60) as u32;
207 let total_minutes = total_seconds.div_euclid(60);
208 let minute = total_minutes.rem_euclid(60) as u32;
209 let total_hours = total_minutes.div_euclid(60);
210 let hour = total_hours.rem_euclid(24) as u32;
211 let mut remaining_days = total_hours.div_euclid(24);
212
213 let mut year = 1970i32;
214 if remaining_days >= 0 {
215 loop {
216 let year_days = days_in_year(year);
217 if remaining_days < year_days {
218 break;
219 }
220 remaining_days -= year_days;
221 year += 1;
222 }
223 } else {
224 loop {
225 year -= 1;
226 remaining_days += days_in_year(year);
227 if remaining_days >= 0 {
228 break;
229 }
230 }
231 }
232
233 let mut month = 1u32;
234 loop {
235 let month_days = i64::from(days_in_month(month, year));
236 if remaining_days < month_days {
237 break;
238 }
239 remaining_days -= month_days;
240 month += 1;
241 }
242
243 let day = (remaining_days + 1) as u32;
244 (year, month, day, hour, minute, second)
245}
246
247fn parts_to_epoch(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 {
248 let mut days: i64 = 0;
249
250 if year >= 1970 {
251 for y in 1970..year {
252 days += days_in_year(y);
253 }
254 } else {
255 for y in year..1970 {
256 days -= days_in_year(y);
257 }
258 }
259
260 for m in 1..month {
261 days += i64::from(days_in_month(m, year));
262 }
263
264 days += i64::from(day) - 1;
265
266 days * 86400 + i64::from(hour) * 3600 + i64::from(minute) * 60 + i64::from(second)
267}
268
269fn split_datetime(datetime: &str) -> Result<(&str, &str), EpochError> {
270 let trimmed = datetime.trim_end_matches('Z');
271 if let Some(pos) = trimmed.find('T').or_else(|| trimmed.find(' ')) {
272 Ok((&trimmed[..pos], &trimmed[pos + 1..]))
273 } else {
274 Err(EpochError::InvalidFormat)
275 }
276}
277
278fn parse_date(date: &str) -> Result<(i32, u32, u32), EpochError> {
279 let parts: Vec<&str> = date.split('-').collect();
280 if parts.len() < 3 {
281 return Err(EpochError::InvalidFormat);
282 }
283
284 let year: i32 = parts[0].parse().map_err(|_| EpochError::InvalidFormat)?;
285 let month: u32 = parts[1].parse().map_err(|_| EpochError::InvalidFormat)?;
286 let day: u32 = parts[2].parse().map_err(|_| EpochError::InvalidFormat)?;
287
288 if !(1..=12).contains(&month) {
289 return Err(EpochError::InvalidDate);
290 }
291 if day < 1 || day > days_in_month(month, year) {
292 return Err(EpochError::InvalidDate);
293 }
294
295 Ok((year, month, day))
296}
297
298fn parse_time(time: &str) -> Result<(u32, u32, u32), EpochError> {
299 let parts: Vec<&str> = time.split(':').collect();
300 if parts.len() != 3 {
301 return Err(EpochError::InvalidFormat);
302 }
303
304 let hour: u32 = parts[0].parse().map_err(|_| EpochError::InvalidFormat)?;
305 let minute: u32 = parts[1].parse().map_err(|_| EpochError::InvalidFormat)?;
306 let second: u32 = parts[2].parse().map_err(|_| EpochError::InvalidFormat)?;
307
308 if hour > 23 {
309 return Err(EpochError::InvalidTime);
310 }
311 if minute > 59 {
312 return Err(EpochError::InvalidTime);
313 }
314 if second > 59 {
315 return Err(EpochError::InvalidTime);
316 }
317
318 Ok((hour, minute, second))
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn now_secs_is_positive() {
327 assert!(now_secs() > 0);
328 }
329
330 #[test]
331 fn now_millis_is_positive() {
332 assert!(now_millis() > 0);
333 }
334
335 #[test]
336 fn epoch_zero_is_unix_epoch() {
337 assert_eq!(to_utc_string(0), "1970-01-01T00:00:00Z");
338 }
339
340 #[test]
341 fn known_epoch_converts_correctly() {
342 assert_eq!(to_utc_string(1704067200), "2024-01-01T00:00:00Z");
343 }
344
345 #[test]
346 fn parse_unix_epoch_string() {
347 assert_eq!(from_utc_string("1970-01-01T00:00:00Z").unwrap(), 0);
348 }
349
350 #[test]
351 fn parse_known_date_to_epoch() {
352 assert_eq!(from_utc_string("2024-01-01T00:00:00Z").unwrap(), 1704067200);
353 }
354
355 #[test]
356 fn space_separator_is_accepted() {
357 assert_eq!(from_utc_string("2024-01-01 00:00:00").unwrap(), 1704067200);
358 }
359
360 #[test]
361 fn roundtrip_arbitrary_timestamp() {
362 let timestamp: i64 = 1_700_000_042;
363 assert_eq!(
364 from_utc_string(&to_utc_string(timestamp)).unwrap(),
365 timestamp
366 );
367 }
368
369 #[test]
370 fn negative_timestamp_before_1970() {
371 assert_eq!(to_utc_string(-86400), "1969-12-31T00:00:00Z");
372 }
373
374 #[test]
375 fn roundtrip_negative_timestamp() {
376 let timestamp: i64 = -1_234_567;
377 assert_eq!(
378 from_utc_string(&to_utc_string(timestamp)).unwrap(),
379 timestamp
380 );
381 }
382
383 #[test]
384 fn year_2000_is_leap() {
385 assert!(is_leap_year(2000));
386 }
387
388 #[test]
389 fn year_1900_is_not_leap() {
390 assert!(!is_leap_year(1900));
391 }
392
393 #[test]
394 fn year_2024_is_leap() {
395 assert!(is_leap_year(2024));
396 }
397
398 #[test]
399 fn year_2023_is_not_leap() {
400 assert!(!is_leap_year(2023));
401 }
402
403 #[test]
404 fn invalid_format_returns_error() {
405 assert_eq!(
406 from_utc_string("not-a-date").unwrap_err(),
407 EpochError::InvalidFormat
408 );
409 }
410
411 #[test]
412 fn invalid_month_returns_error() {
413 assert_eq!(
414 from_utc_string("2024-13-01T00:00:00Z").unwrap_err(),
415 EpochError::InvalidDate
416 );
417 }
418
419 #[test]
420 fn invalid_hour_returns_error() {
421 assert_eq!(
422 from_utc_string("2024-01-01T25:00:00Z").unwrap_err(),
423 EpochError::InvalidTime
424 );
425 }
426
427 #[test]
428 fn feb_29_leap_year_is_valid() {
429 assert!(from_utc_string("2024-02-29T00:00:00Z").is_ok());
430 }
431
432 #[test]
433 fn feb_29_non_leap_year_is_invalid() {
434 assert_eq!(
435 from_utc_string("1900-02-29T00:00:00Z").unwrap_err(),
436 EpochError::InvalidDate
437 );
438 }
439
440 #[test]
441 fn days_in_february_leap_year() {
442 assert_eq!(days_in_month(2, 2024), 29);
443 }
444
445 #[test]
446 fn days_in_february_non_leap_year() {
447 assert_eq!(days_in_month(2, 1900), 28);
448 }
449}