From ce0dbf6b249956700c6a1705bf4ad85a09d53e8c Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Sun, 15 Feb 2026 21:27:00 +0100 Subject: feat: witryna 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch, cleanup, and status CLI commands. Persistent build state via state.json. Post-deploy hooks on success and failure with WITRYNA_BUILD_STATUS. Dependency diet (axum→tiny_http, clap→argh, tracing→log). Drop built-in rate limiting. Nix flake with NixOS module. Arch Linux PKGBUILD. Centralized version management. Co-Authored-By: Claude Opus 4.6 --- src/time.rs | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/time.rs (limited to 'src/time.rs') 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 { + 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); + } +} -- cgit v1.2.3