diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a7dc8a2..6013956e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Cross-package release notes for relayburn. Package changelogs contain package-le - `relayburn-cli`: `burn summary` partial-coverage footers now name the token field with the largest gap and clarify that totals still include all matched turns. +- `relayburn-sdk`: `ingest::pending_stamps` and `query_verbs` now use the + `time` crate for ISO-8601 formatting/parsing (`format_iso_8601`, + `format_iso_z`, `parse_iso_ms`). Output and the pending-stamp on-disk wire + format are unchanged. ## [2.6.0] - 2026-05-08 diff --git a/Cargo.lock b/Cargo.lock index 284f941d..8243a5b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,15 @@ dependencies = [ "syn", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -709,6 +718,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -819,6 +834,12 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "predicates" version = "3.1.4" @@ -978,6 +999,7 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.18", + "time", "tokio", ] @@ -1229,6 +1251,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.52.2" diff --git a/crates/relayburn-sdk/Cargo.toml b/crates/relayburn-sdk/Cargo.toml index 8e9a0f5c..ce87932b 100644 --- a/crates/relayburn-sdk/Cargo.toml +++ b/crates/relayburn-sdk/Cargo.toml @@ -41,6 +41,10 @@ memchr = { workspace = true } # ledger: SQLite events + content store rusqlite = { workspace = true } +# ingest + query_verbs: civil-date arithmetic / ISO-8601 formatting. +# Replaces three hand-rolled Howard Hinnant implementations. +time = { version = "0.3", default-features = false, features = ["formatting", "parsing", "macros"] } + # analyze: order-preserving pricing table. # `IndexMap` preserves JSON insertion order so duplicate model IDs in # `models.dev.json` (e.g. `claude-sonnet-4-6` under both `anthropic` and diff --git a/crates/relayburn-sdk/src/ingest/pending_stamps.rs b/crates/relayburn-sdk/src/ingest/pending_stamps.rs index 9589d0b2..12628dfb 100644 --- a/crates/relayburn-sdk/src/ingest/pending_stamps.rs +++ b/crates/relayburn-sdk/src/ingest/pending_stamps.rs @@ -28,6 +28,10 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use time::format_description::well_known::Rfc3339; +use time::macros::format_description; +use time::OffsetDateTime; + use crate::ledger::{ledger_home, Enrichment, Ledger, Stamp, StampSelector}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -481,54 +485,21 @@ fn format_iso_8601(t: SystemTime) -> String { let dur = t .duration_since(UNIX_EPOCH) .unwrap_or(Duration::from_secs(0)); - let secs = dur.as_secs() as i64; - let ms = dur.subsec_millis(); - let (year, month, day, hour, minute, second) = civil_from_unix_seconds(secs); - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", - year, month, day, hour, minute, second, ms - ) + let nanos = dur.as_nanos() as i128; + let dt = + OffsetDateTime::from_unix_timestamp_nanos(nanos).unwrap_or(OffsetDateTime::UNIX_EPOCH); + let fmt = format_description!( + "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z" + ); + dt.format(&fmt).expect("format ms iso") } fn parse_iso_ms(s: &str) -> Option { // Accept the JS `Date.parse` shapes we actually emit: - // `YYYY-MM-DDTHH:MM:SS[.fff]Z`. Anything more exotic was never written - // by the TS adapter so we don't need to round-trip it. - let bytes = s.as_bytes(); - if bytes.len() < 20 || bytes[bytes.len() - 1] != b'Z' { - return None; - } - let year: i64 = s.get(0..4)?.parse().ok()?; - if s.as_bytes().get(4) != Some(&b'-') { - return None; - } - let month: u32 = s.get(5..7)?.parse().ok()?; - if s.as_bytes().get(7) != Some(&b'-') { - return None; - } - let day: u32 = s.get(8..10)?.parse().ok()?; - if s.as_bytes().get(10) != Some(&b'T') { - return None; - } - let hour: u32 = s.get(11..13)?.parse().ok()?; - let minute: u32 = s.get(14..16)?.parse().ok()?; - let second: u32 = s.get(17..19)?.parse().ok()?; - let mut ms: i64 = 0; - if s.as_bytes().get(19) == Some(&b'.') { - let frac_end = s.len() - 1; - let frac = s.get(20..frac_end)?; - if !frac.is_empty() { - let mut padded = String::from(frac); - while padded.len() < 3 { - padded.push('0'); - } - ms = padded.get(0..3)?.parse().ok()?; - } - } else if s.as_bytes().get(19) != Some(&b'Z') { - return None; - } - let secs = unix_seconds_from_civil(year, month, day, hour, minute, second); - Some(secs * 1000 + ms) + // `YYYY-MM-DDTHH:MM:SS[.fff]Z`. RFC3339 covers both — variable + // subsecond precision plus a `Z` suffix. + let dt = OffsetDateTime::parse(s, &Rfc3339).ok()?; + Some((dt.unix_timestamp_nanos() / 1_000_000) as i64) } fn system_time_ms(t: SystemTime) -> i64 { @@ -655,41 +626,6 @@ fn fill_random_pid_time_fallback(buf: &mut [u8]) { } } -// --- Civil ↔ Unix-seconds conversions (proleptic Gregorian, no chrono dep) - - -/// Days since 1970-01-01 for the start of `year-month-day`. Algorithm from -/// Howard Hinnant's "date" library. -fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = (y - era * 400) as u64; - let doy = ((153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1) as u64; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe as i64 - 719468 -} - -fn unix_seconds_from_civil(y: i64, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> i64 { - days_from_civil(y, m, d) * 86400 + (hh as i64) * 3600 + (mm as i64) * 60 + ss as i64 -} - -fn civil_from_unix_seconds(secs: i64) -> (i64, u32, u32, u32, u32, u32) { - let z = secs.div_euclid(86400) + 719468; - let sod = secs.rem_euclid(86400); - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = (z - era * 146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - let hh = (sod / 3600) as u32; - let mm = ((sod % 3600) / 60) as u32; - let ss = (sod % 60) as u32; - (y, m, d, hh, mm, ss) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/relayburn-sdk/src/query_verbs.rs b/crates/relayburn-sdk/src/query_verbs.rs index 310c4594..65098a10 100644 --- a/crates/relayburn-sdk/src/query_verbs.rs +++ b/crates/relayburn-sdk/src/query_verbs.rs @@ -120,32 +120,14 @@ fn system_now_secs() -> u64 { .unwrap_or(0) } -/// Format Unix-seconds as `YYYY-MM-DDTHH:MM:SSZ`. Proleptic Gregorian — same -/// flavor of date math `relayburn-ingest::pending_stamps` uses to avoid a -/// chrono dep. +/// Format Unix-seconds as `YYYY-MM-DDTHH:MM:SSZ`. fn format_iso_z(secs: u64) -> String { - let total_days = (secs / 86_400) as i64; - let secs_in_day = (secs % 86_400) as u32; - let hour = secs_in_day / 3_600; - let minute = (secs_in_day / 60) % 60; - let second = secs_in_day % 60; - let (year, month, day) = days_to_ymd(total_days); - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") -} - -fn days_to_ymd(days_from_epoch: i64) -> (i64, u32, u32) { - // Howard Hinnant's date-library algorithm (proleptic Gregorian). - let z = days_from_epoch + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = if m <= 2 { y + 1 } else { y }; - (year, m as u32, d as u32) + let dt = time::OffsetDateTime::from_unix_timestamp(secs as i64) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH); + let fmt = time::macros::format_description!( + "[year]-[month]-[day]T[hour]:[minute]:[second]Z" + ); + dt.format(&fmt).expect("format z iso") } // ---------------------------------------------------------------------------