Skip to main content

rune_epoch/
lib.rs

1//! Unix timestamp conversions between epoch seconds and human-readable UTC strings.
2//!
3//! Converts Unix epoch values (seconds or milliseconds) to ISO 8601 UTC strings
4//! and back, using pure Rust standard library arithmetic — no external time crates.
5//! Handles the full signed 32-bit year range, including negative timestamps
6//! (dates before 1970-01-01).
7//!
8//! # Features
9//!
10//! - [`now_secs`] — current Unix timestamp in seconds.
11//! - [`now_millis`] — current Unix timestamp in milliseconds.
12//! - [`to_utc_string`] — epoch seconds → `"YYYY-MM-DDTHH:MM:SSZ"`.
13//! - [`from_utc_string`] — `"YYYY-MM-DDTHH:MM:SSZ"` → epoch seconds.
14//! - [`EpochError`] — structured error for invalid datetime strings.
15//!
16//! # Quick Start
17//!
18//! ```rust
19//! use rune_epoch::{to_utc_string, from_utc_string};
20//!
21//! assert_eq!(to_utc_string(0), "1970-01-01T00:00:00Z");
22//! assert_eq!(from_utc_string("1970-01-01T00:00:00Z").unwrap(), 0);
23//! ```
24//!
25//! # CLI
26//!
27//! ```bash
28//! rune-epoch                          # print current epoch
29//! rune-epoch 1704067200               # convert epoch to UTC
30//! rune-epoch --millis                 # print current epoch in milliseconds
31//! rune-epoch "2024-01-15T14:30:00Z"   # parse datetime string to epoch
32//! ```
33
34use std::fmt;
35use std::time::{SystemTime, UNIX_EPOCH};
36
37/// Error returned when [`from_utc_string`] cannot parse its input.
38///
39/// Distinguish between a string that doesn't match the expected layout
40/// (`InvalidFormat`), a date with out-of-range fields (`InvalidDate`), and
41/// a time with out-of-range fields (`InvalidTime`).
42///
43/// # Examples
44///
45/// ```rust
46/// use rune_epoch::{from_utc_string, EpochError};
47///
48/// assert!(matches!(from_utc_string("not-a-date"), Err(EpochError::InvalidFormat)));
49/// assert!(matches!(from_utc_string("2024-13-01T00:00:00Z"), Err(EpochError::InvalidDate)));
50/// assert!(matches!(from_utc_string("2024-01-01T25:00:00Z"), Err(EpochError::InvalidTime)));
51/// ```
52#[derive(Debug, PartialEq, Eq)]
53pub enum EpochError {
54    /// The string does not match `YYYY-MM-DDTHH:MM:SSZ` or `YYYY-MM-DD HH:MM:SS`.
55    InvalidFormat,
56    /// Year, month, or day is outside the valid Gregorian calendar range.
57    InvalidDate,
58    /// Hour, minute, or second is outside 0–23/0–59/0–59.
59    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
79/// Returns the current Unix timestamp in whole seconds.
80///
81/// # Examples
82///
83/// ```rust
84/// use rune_epoch::now_secs;
85///
86/// assert!(now_secs() > 0);
87/// ```
88pub 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
95/// Returns the current Unix timestamp in milliseconds.
96///
97/// # Examples
98///
99/// ```rust
100/// use rune_epoch::now_millis;
101///
102/// assert!(now_millis() > 0);
103/// ```
104pub 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
111/// Converts a Unix epoch (seconds) to a UTC datetime string in ISO 8601 format.
112///
113/// Handles negative timestamps (dates before 1970-01-01T00:00:00Z) using
114/// pure Gregorian calendar arithmetic in the standard library — no external
115/// time crates are required.
116///
117/// # Examples
118///
119/// ```rust
120/// use rune_epoch::to_utc_string;
121///
122/// assert_eq!(to_utc_string(0),          "1970-01-01T00:00:00Z");
123/// assert_eq!(to_utc_string(1704067200), "2024-01-01T00:00:00Z");
124/// assert_eq!(to_utc_string(-86400),     "1969-12-31T00:00:00Z");
125/// ```
126pub 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
131/// Parses a UTC datetime string and returns the Unix epoch in seconds.
132///
133/// Accepts both `"YYYY-MM-DDTHH:MM:SSZ"` (ISO 8601) and
134/// `"YYYY-MM-DD HH:MM:SS"` (space separator, no trailing `Z`).
135///
136/// # Errors
137///
138/// Returns [`EpochError::InvalidFormat`] when the string layout does not match,
139/// [`EpochError::InvalidDate`] for out-of-range date components, and
140/// [`EpochError::InvalidTime`] for out-of-range time components.
141///
142/// # Examples
143///
144/// ```rust
145/// use rune_epoch::from_utc_string;
146///
147/// assert_eq!(from_utc_string("1970-01-01T00:00:00Z").unwrap(), 0);
148/// assert_eq!(from_utc_string("2024-01-01T00:00:00Z").unwrap(), 1704067200);
149/// assert_eq!(from_utc_string("2024-01-01 00:00:00").unwrap(),  1704067200);
150/// ```
151pub 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
158/// Returns `true` when `year` is a leap year in the proleptic Gregorian calendar.
159///
160/// A year is a leap year if it is divisible by 4, except that century years
161/// (divisible by 100) are not leap years unless they are also divisible by 400.
162///
163/// # Examples
164///
165/// ```rust
166/// use rune_epoch::is_leap_year;
167///
168/// assert!(is_leap_year(2000));
169/// assert!(!is_leap_year(1900));
170/// assert!(is_leap_year(2024));
171/// assert!(!is_leap_year(2023));
172/// ```
173pub fn is_leap_year(year: i32) -> bool {
174    (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
175}
176
177/// Returns the number of days in `month` for the given `year`.
178///
179/// Accounts for leap years when `month` is February.
180///
181/// # Examples
182///
183/// ```rust
184/// use rune_epoch::days_in_month;
185///
186/// assert_eq!(days_in_month(2, 2024), 29);
187/// assert_eq!(days_in_month(2, 1900), 28);
188/// assert_eq!(days_in_month(1, 2024), 31);
189/// ```
190pub 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}