rune-epoch 0.1.1

Unix timestamp conversions — epoch to human-readable and back
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! Unix timestamp conversions between epoch seconds and human-readable UTC strings.
//!
//! Converts Unix epoch values (seconds or milliseconds) to ISO 8601 UTC strings
//! and back, using pure Rust standard library arithmetic — no external time crates.
//! Handles the full signed 32-bit year range, including negative timestamps
//! (dates before 1970-01-01).
//!
//! # Features
//!
//! - [`now_secs`] — current Unix timestamp in seconds.
//! - [`now_millis`] — current Unix timestamp in milliseconds.
//! - [`to_utc_string`] — epoch seconds → `"YYYY-MM-DDTHH:MM:SSZ"`.
//! - [`from_utc_string`] — `"YYYY-MM-DDTHH:MM:SSZ"` → epoch seconds.
//! - [`EpochError`] — structured error for invalid datetime strings.
//!
//! # Quick Start
//!
//! ```rust
//! use rune_epoch::{to_utc_string, from_utc_string};
//!
//! assert_eq!(to_utc_string(0), "1970-01-01T00:00:00Z");
//! assert_eq!(from_utc_string("1970-01-01T00:00:00Z").unwrap(), 0);
//! ```
//!
//! # CLI
//!
//! ```bash
//! rune-epoch                          # print current epoch
//! rune-epoch 1704067200               # convert epoch to UTC
//! rune-epoch --millis                 # print current epoch in milliseconds
//! rune-epoch "2024-01-15T14:30:00Z"   # parse datetime string to epoch
//! ```

use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};

/// Error returned when [`from_utc_string`] cannot parse its input.
///
/// Distinguish between a string that doesn't match the expected layout
/// (`InvalidFormat`), a date with out-of-range fields (`InvalidDate`), and
/// a time with out-of-range fields (`InvalidTime`).
///
/// # Examples
///
/// ```rust
/// use rune_epoch::{from_utc_string, EpochError};
///
/// assert!(matches!(from_utc_string("not-a-date"), Err(EpochError::InvalidFormat)));
/// assert!(matches!(from_utc_string("2024-13-01T00:00:00Z"), Err(EpochError::InvalidDate)));
/// assert!(matches!(from_utc_string("2024-01-01T25:00:00Z"), Err(EpochError::InvalidTime)));
/// ```
#[derive(Debug, PartialEq, Eq)]
pub enum EpochError {
    /// The string does not match `YYYY-MM-DDTHH:MM:SSZ` or `YYYY-MM-DD HH:MM:SS`.
    InvalidFormat,
    /// Year, month, or day is outside the valid Gregorian calendar range.
    InvalidDate,
    /// Hour, minute, or second is outside 0–23/0–59/0–59.
    InvalidTime,
}

impl fmt::Display for EpochError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            EpochError::InvalidFormat => {
                formatter.write_str("invalid datetime format — expected YYYY-MM-DDTHH:MM:SSZ")
            }
            EpochError::InvalidDate => formatter
                .write_str("invalid date — month must be 1–12 and day must be valid for the month"),
            EpochError::InvalidTime => {
                formatter.write_str("invalid time — hour 0–23, minute 0–59, second 0–59")
            }
        }
    }
}

impl std::error::Error for EpochError {}

/// Returns the current Unix timestamp in whole seconds.
///
/// # Examples
///
/// ```rust
/// use rune_epoch::now_secs;
///
/// assert!(now_secs() > 0);
/// ```
pub fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock is before Unix epoch")
        .as_secs()
}

/// Returns the current Unix timestamp in milliseconds.
///
/// # Examples
///
/// ```rust
/// use rune_epoch::now_millis;
///
/// assert!(now_millis() > 0);
/// ```
pub fn now_millis() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock is before Unix epoch")
        .as_millis()
}

/// Converts a Unix epoch (seconds) to a UTC datetime string in ISO 8601 format.
///
/// Handles negative timestamps (dates before 1970-01-01T00:00:00Z) using
/// pure Gregorian calendar arithmetic in the standard library — no external
/// time crates are required.
///
/// # Examples
///
/// ```rust
/// use rune_epoch::to_utc_string;
///
/// assert_eq!(to_utc_string(0),          "1970-01-01T00:00:00Z");
/// assert_eq!(to_utc_string(1704067200), "2024-01-01T00:00:00Z");
/// assert_eq!(to_utc_string(-86400),     "1969-12-31T00:00:00Z");
/// ```
pub fn to_utc_string(epoch_secs: i64) -> String {
    let (year, month, day, hour, minute, second) = epoch_to_parts(epoch_secs);
    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

/// Parses a UTC datetime string and returns the Unix epoch in seconds.
///
/// Accepts both `"YYYY-MM-DDTHH:MM:SSZ"` (ISO 8601) and
/// `"YYYY-MM-DD HH:MM:SS"` (space separator, no trailing `Z`).
///
/// # Errors
///
/// Returns [`EpochError::InvalidFormat`] when the string layout does not match,
/// [`EpochError::InvalidDate`] for out-of-range date components, and
/// [`EpochError::InvalidTime`] for out-of-range time components.
///
/// # Examples
///
/// ```rust
/// use rune_epoch::from_utc_string;
///
/// assert_eq!(from_utc_string("1970-01-01T00:00:00Z").unwrap(), 0);
/// assert_eq!(from_utc_string("2024-01-01T00:00:00Z").unwrap(), 1704067200);
/// assert_eq!(from_utc_string("2024-01-01 00:00:00").unwrap(),  1704067200);
/// ```
pub fn from_utc_string(datetime: &str) -> Result<i64, EpochError> {
    let (date_part, time_part) = split_datetime(datetime)?;
    let (year, month, day) = parse_date(date_part)?;
    let (hour, minute, second) = parse_time(time_part)?;
    Ok(parts_to_epoch(year, month, day, hour, minute, second))
}

/// Returns `true` when `year` is a leap year in the proleptic Gregorian calendar.
///
/// A year is a leap year if it is divisible by 4, except that century years
/// (divisible by 100) are not leap years unless they are also divisible by 400.
///
/// # Examples
///
/// ```rust
/// use rune_epoch::is_leap_year;
///
/// assert!(is_leap_year(2000));
/// assert!(!is_leap_year(1900));
/// assert!(is_leap_year(2024));
/// assert!(!is_leap_year(2023));
/// ```
pub fn is_leap_year(year: i32) -> bool {
    (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
}

/// Returns the number of days in `month` for the given `year`.
///
/// Accounts for leap years when `month` is February.
///
/// # Examples
///
/// ```rust
/// use rune_epoch::days_in_month;
///
/// assert_eq!(days_in_month(2, 2024), 29);
/// assert_eq!(days_in_month(2, 1900), 28);
/// assert_eq!(days_in_month(1, 2024), 31);
/// ```
pub fn days_in_month(month: u32, year: i32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

fn days_in_year(year: i32) -> i64 {
    if is_leap_year(year) { 366 } else { 365 }
}

fn epoch_to_parts(epoch_secs: i64) -> (i32, u32, u32, u32, u32, u32) {
    let total_seconds = epoch_secs;
    let second = total_seconds.rem_euclid(60) as u32;
    let total_minutes = total_seconds.div_euclid(60);
    let minute = total_minutes.rem_euclid(60) as u32;
    let total_hours = total_minutes.div_euclid(60);
    let hour = total_hours.rem_euclid(24) as u32;
    let mut remaining_days = total_hours.div_euclid(24);

    let mut year = 1970i32;
    if remaining_days >= 0 {
        loop {
            let year_days = days_in_year(year);
            if remaining_days < year_days {
                break;
            }
            remaining_days -= year_days;
            year += 1;
        }
    } else {
        loop {
            year -= 1;
            remaining_days += days_in_year(year);
            if remaining_days >= 0 {
                break;
            }
        }
    }

    let mut month = 1u32;
    loop {
        let month_days = i64::from(days_in_month(month, year));
        if remaining_days < month_days {
            break;
        }
        remaining_days -= month_days;
        month += 1;
    }

    let day = (remaining_days + 1) as u32;
    (year, month, day, hour, minute, second)
}

fn parts_to_epoch(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 {
    let mut days: i64 = 0;

    if year >= 1970 {
        for y in 1970..year {
            days += days_in_year(y);
        }
    } else {
        for y in year..1970 {
            days -= days_in_year(y);
        }
    }

    for m in 1..month {
        days += i64::from(days_in_month(m, year));
    }

    days += i64::from(day) - 1;

    days * 86400 + i64::from(hour) * 3600 + i64::from(minute) * 60 + i64::from(second)
}

fn split_datetime(datetime: &str) -> Result<(&str, &str), EpochError> {
    let trimmed = datetime.trim_end_matches('Z');
    if let Some(pos) = trimmed.find('T').or_else(|| trimmed.find(' ')) {
        Ok((&trimmed[..pos], &trimmed[pos + 1..]))
    } else {
        Err(EpochError::InvalidFormat)
    }
}

fn parse_date(date: &str) -> Result<(i32, u32, u32), EpochError> {
    let parts: Vec<&str> = date.split('-').collect();
    if parts.len() < 3 {
        return Err(EpochError::InvalidFormat);
    }

    let year: i32 = parts[0].parse().map_err(|_| EpochError::InvalidFormat)?;
    let month: u32 = parts[1].parse().map_err(|_| EpochError::InvalidFormat)?;
    let day: u32 = parts[2].parse().map_err(|_| EpochError::InvalidFormat)?;

    if !(1..=12).contains(&month) {
        return Err(EpochError::InvalidDate);
    }
    if day < 1 || day > days_in_month(month, year) {
        return Err(EpochError::InvalidDate);
    }

    Ok((year, month, day))
}

fn parse_time(time: &str) -> Result<(u32, u32, u32), EpochError> {
    let parts: Vec<&str> = time.split(':').collect();
    if parts.len() != 3 {
        return Err(EpochError::InvalidFormat);
    }

    let hour: u32 = parts[0].parse().map_err(|_| EpochError::InvalidFormat)?;
    let minute: u32 = parts[1].parse().map_err(|_| EpochError::InvalidFormat)?;
    let second: u32 = parts[2].parse().map_err(|_| EpochError::InvalidFormat)?;

    if hour > 23 {
        return Err(EpochError::InvalidTime);
    }
    if minute > 59 {
        return Err(EpochError::InvalidTime);
    }
    if second > 59 {
        return Err(EpochError::InvalidTime);
    }

    Ok((hour, minute, second))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn now_secs_is_positive() {
        assert!(now_secs() > 0);
    }

    #[test]
    fn now_millis_is_positive() {
        assert!(now_millis() > 0);
    }

    #[test]
    fn epoch_zero_is_unix_epoch() {
        assert_eq!(to_utc_string(0), "1970-01-01T00:00:00Z");
    }

    #[test]
    fn known_epoch_converts_correctly() {
        assert_eq!(to_utc_string(1704067200), "2024-01-01T00:00:00Z");
    }

    #[test]
    fn parse_unix_epoch_string() {
        assert_eq!(from_utc_string("1970-01-01T00:00:00Z").unwrap(), 0);
    }

    #[test]
    fn parse_known_date_to_epoch() {
        assert_eq!(from_utc_string("2024-01-01T00:00:00Z").unwrap(), 1704067200);
    }

    #[test]
    fn space_separator_is_accepted() {
        assert_eq!(from_utc_string("2024-01-01 00:00:00").unwrap(), 1704067200);
    }

    #[test]
    fn roundtrip_arbitrary_timestamp() {
        let timestamp: i64 = 1_700_000_042;
        assert_eq!(
            from_utc_string(&to_utc_string(timestamp)).unwrap(),
            timestamp
        );
    }

    #[test]
    fn negative_timestamp_before_1970() {
        assert_eq!(to_utc_string(-86400), "1969-12-31T00:00:00Z");
    }

    #[test]
    fn roundtrip_negative_timestamp() {
        let timestamp: i64 = -1_234_567;
        assert_eq!(
            from_utc_string(&to_utc_string(timestamp)).unwrap(),
            timestamp
        );
    }

    #[test]
    fn year_2000_is_leap() {
        assert!(is_leap_year(2000));
    }

    #[test]
    fn year_1900_is_not_leap() {
        assert!(!is_leap_year(1900));
    }

    #[test]
    fn year_2024_is_leap() {
        assert!(is_leap_year(2024));
    }

    #[test]
    fn year_2023_is_not_leap() {
        assert!(!is_leap_year(2023));
    }

    #[test]
    fn invalid_format_returns_error() {
        assert_eq!(
            from_utc_string("not-a-date").unwrap_err(),
            EpochError::InvalidFormat
        );
    }

    #[test]
    fn invalid_month_returns_error() {
        assert_eq!(
            from_utc_string("2024-13-01T00:00:00Z").unwrap_err(),
            EpochError::InvalidDate
        );
    }

    #[test]
    fn invalid_hour_returns_error() {
        assert_eq!(
            from_utc_string("2024-01-01T25:00:00Z").unwrap_err(),
            EpochError::InvalidTime
        );
    }

    #[test]
    fn feb_29_leap_year_is_valid() {
        assert!(from_utc_string("2024-02-29T00:00:00Z").is_ok());
    }

    #[test]
    fn feb_29_non_leap_year_is_invalid() {
        assert_eq!(
            from_utc_string("1900-02-29T00:00:00Z").unwrap_err(),
            EpochError::InvalidDate
        );
    }

    #[test]
    fn days_in_february_leap_year() {
        assert_eq!(days_in_month(2, 2024), 29);
    }

    #[test]
    fn days_in_february_non_leap_year() {
        assert_eq!(days_in_month(2, 1900), 28);
    }
}