summaryrefslogtreecommitdiff
path: root/src/time.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/time.rs')
-rw-r--r--src/time.rs222
1 files changed, 222 insertions, 0 deletions
diff --git a/src/time.rs b/src/time.rs
new file mode 100644
index 0000000..2e084a8
--- /dev/null
+++ b/src/time.rs
@@ -0,0 +1,222 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// Format as `YYYYMMDD-HHMMSS-ffffff` (build directories and log files).
+#[must_use]
+pub fn format_build_timestamp(t: SystemTime) -> String {
+ let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default();
+ let (year, month, day, hour, min, sec) = epoch_to_civil(dur.as_secs());
+ let us = dur.subsec_micros();
+ format!("{year:04}{month:02}{day:02}-{hour:02}{min:02}{sec:02}-{us:06}")
+}
+
+/// Format as `YYYY-MM-DDTHH:MM:SSZ` (state.json `started_at`, second precision).
+#[must_use]
+pub fn format_rfc3339(t: SystemTime) -> String {
+ let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default();
+ let (year, month, day, hour, min, sec) = epoch_to_civil(dur.as_secs());
+ format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
+}
+
+/// Format as `YYYY-MM-DDTHH:MM:SS.mmmZ` (logger console output, millisecond precision, UTC).
+#[must_use]
+pub fn format_log_timestamp(t: SystemTime) -> String {
+ let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default();
+ let (year, month, day, hour, min, sec) = epoch_to_civil(dur.as_secs());
+ let ms = dur.subsec_millis();
+ format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{ms:03}Z")
+}
+
+/// Parse `YYYY-MM-DDTHH:MM:SSZ` back to `SystemTime`. Only handles `Z` suffix.
+#[must_use]
+pub fn parse_rfc3339(s: &str) -> Option<SystemTime> {
+ let s = s.strip_suffix('Z')?;
+ if s.len() != 19 {
+ return None;
+ }
+ let bytes = s.as_bytes();
+ if bytes.get(4) != Some(&b'-')
+ || bytes.get(7) != Some(&b'-')
+ || bytes.get(10) != Some(&b'T')
+ || bytes.get(13) != Some(&b':')
+ || bytes.get(16) != Some(&b':')
+ {
+ return None;
+ }
+ let year: u16 = s.get(0..4)?.parse().ok()?;
+ let month: u8 = s.get(5..7)?.parse().ok()?;
+ let day: u8 = s.get(8..10)?.parse().ok()?;
+ let hour: u8 = s.get(11..13)?.parse().ok()?;
+ let min: u8 = s.get(14..16)?.parse().ok()?;
+ let sec: u8 = s.get(17..19)?.parse().ok()?;
+ if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
+ return None;
+ }
+ let epoch = civil_to_epoch(year, month, day, hour, min, sec);
+ Some(UNIX_EPOCH + std::time::Duration::from_secs(epoch))
+}
+
+/// Convert Unix epoch seconds to (year, month, day, hour, minute, second).
+/// Uses Howard Hinnant's `civil_from_days` algorithm.
+///
+/// # Safety (casts)
+/// All `as` casts are bounded by the civil-date algorithm:
+/// year fits u16 (0–9999), month/day/h/m/s fit u8, day-count fits i64.
+#[expect(
+ clippy::cast_possible_truncation,
+ clippy::cast_possible_wrap,
+ clippy::cast_sign_loss,
+ reason = "Hinnant civil_from_days algorithm: values bounded by calendar math"
+)]
+const fn epoch_to_civil(secs: u64) -> (u16, u8, u8, u8, u8, u8) {
+ let day_secs = secs % 86400;
+ let days = (secs / 86400) as i64 + 719_468;
+ let era = days.div_euclid(146_097);
+ let doe = days.rem_euclid(146_097) as u64; // day of era [0, 146096]
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // year of era
+ let year = (yoe as i64) + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
+ let mp = (5 * doy + 2) / 153; // [0, 11]
+ let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
+ let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
+ let year = if month <= 2 { year + 1 } else { year } as u16;
+ let hour = (day_secs / 3600) as u8;
+ let min = ((day_secs % 3600) / 60) as u8;
+ let sec = (day_secs % 60) as u8;
+ (year, month, day, hour, min, sec)
+}
+
+/// Convert (year, month, day, hour, minute, second) to Unix epoch seconds.
+///
+/// # Safety (casts)
+/// All `as` casts are bounded by the civil-date algorithm:
+/// year fits i64, month/day/h/m/s fit u64, `doe` fits i64 (0–146096).
+/// Final `as u64` is non-negative for all valid civil dates.
+#[expect(
+ clippy::cast_possible_wrap,
+ clippy::cast_sign_loss,
+ reason = "Hinnant civil_from_days algorithm: values bounded by calendar math"
+)]
+const fn civil_to_epoch(year: u16, month: u8, day: u8, hour: u8, min: u8, sec: u8) -> u64 {
+ let year = (year as i64) - (month <= 2) as i64;
+ let era = year.div_euclid(400);
+ let yoe = year.rem_euclid(400) as u64; // year of era [0, 399]
+ let m_adj = if month > 2 {
+ (month as u64) - 3
+ } else {
+ (month as u64) + 9
+ }; // [0, 11]
+ let doy = (153 * m_adj + 2) / 5 + (day as u64) - 1; // day of year [0, 365]
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era [0, 146096]
+ let days = (era * 146_097 + doe as i64 - 719_468) as u64;
+ days * 86400 + (hour as u64) * 3600 + (min as u64) * 60 + (sec as u64)
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod tests {
+ use super::*;
+ use std::time::Duration;
+
+ #[test]
+ fn format_build_timestamp_unix_epoch() {
+ assert_eq!(format_build_timestamp(UNIX_EPOCH), "19700101-000000-000000");
+ }
+
+ #[test]
+ fn format_build_timestamp_format() {
+ let s = format_build_timestamp(SystemTime::now());
+ let parts: Vec<&str> = s.split('-').collect();
+ assert_eq!(parts.len(), 3, "expected 3 dash-separated parts, got: {s}");
+ assert_eq!(parts[0].len(), 8, "date part should be 8 digits");
+ assert_eq!(parts[1].len(), 6, "time part should be 6 digits");
+ assert_eq!(parts[2].len(), 6, "micros part should be 6 digits");
+ assert!(parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())));
+ }
+
+ #[test]
+ fn format_rfc3339_unix_epoch() {
+ assert_eq!(format_rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z");
+ }
+
+ #[test]
+ fn format_log_timestamp_unix_epoch() {
+ assert_eq!(format_log_timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
+ }
+
+ #[test]
+ fn format_rfc3339_known_date() {
+ // 2024-02-29T12:30:45Z (leap year)
+ let secs = civil_to_epoch(2024, 2, 29, 12, 30, 45);
+ let t = UNIX_EPOCH + Duration::from_secs(secs);
+ assert_eq!(format_rfc3339(t), "2024-02-29T12:30:45Z");
+ }
+
+ #[test]
+ fn parse_rfc3339_roundtrip() {
+ let now = SystemTime::now();
+ let s = format_rfc3339(now);
+ let parsed = parse_rfc3339(&s).unwrap();
+ // Roundtrip loses sub-second precision, so compare formatted output
+ assert_eq!(format_rfc3339(parsed), s);
+ }
+
+ #[test]
+ fn parse_rfc3339_valid() {
+ let t = parse_rfc3339("2026-02-13T14:30:00Z").unwrap();
+ assert_eq!(format_rfc3339(t), "2026-02-13T14:30:00Z");
+ }
+
+ #[test]
+ fn parse_rfc3339_rejects_plus_offset() {
+ assert!(parse_rfc3339("2026-02-13T14:30:00+00:00").is_none());
+ }
+
+ #[test]
+ fn parse_rfc3339_rejects_garbage() {
+ assert!(parse_rfc3339("not-a-date").is_none());
+ assert!(parse_rfc3339("").is_none());
+ assert!(parse_rfc3339("2026-13-01T00:00:00Z").is_none()); // month 13
+ assert!(parse_rfc3339("2026-00-01T00:00:00Z").is_none()); // month 0
+ }
+
+ #[test]
+ fn epoch_to_civil_roundtrip() {
+ let dates: &[(u16, u8, u8, u8, u8, u8)] = &[
+ (1970, 1, 1, 0, 0, 0),
+ (2000, 1, 1, 0, 0, 0),
+ (2024, 2, 29, 23, 59, 59), // leap year
+ (2024, 12, 31, 12, 0, 0),
+ (2026, 2, 13, 14, 30, 0),
+ ];
+ for &(y, m, d, h, min, s) in dates {
+ let epoch = civil_to_epoch(y, m, d, h, min, s);
+ let (y2, m2, d2, h2, min2, s2) = epoch_to_civil(epoch);
+ assert_eq!(
+ (y, m, d, h, min, s),
+ (y2, m2, d2, h2, min2, s2),
+ "roundtrip failed for {y}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z"
+ );
+ }
+ }
+
+ #[test]
+ fn format_log_timestamp_millisecond_precision() {
+ let t = UNIX_EPOCH + Duration::from_millis(1_234_567_890_123);
+ let s = format_log_timestamp(t);
+ assert!(s.ends_with("Z"));
+ assert!(s.contains('.'));
+ // The milliseconds portion should be 3 digits
+ let dot_pos = s.find('.').unwrap();
+ assert_eq!(&s[dot_pos + 4..], "Z");
+ }
+
+ #[test]
+ fn format_build_timestamp_microsecond_precision() {
+ let t = UNIX_EPOCH + Duration::from_micros(1_234_567_890_123_456);
+ let s = format_build_timestamp(t);
+ // Last 6 chars before end should be microseconds
+ let parts: Vec<&str> = s.split('-').collect();
+ assert_eq!(parts.len(), 3);
+ assert_eq!(parts[2].len(), 6);
+ }
+}