Skip to main content

forge_agent/env/
timezone.rs

1//! The host's local IANA timezone, resolved dynamically, and the wall
2//! clock an RFC-3339 timestamp reads as in it. A generic OS-env probe
3//! shared by the usage scan's day-bucketing, the SCHEDULES schedule
4//! formatter and the turn rows a view draws from a transcript.
5
6use time::format_description::well_known::Rfc3339;
7use time_tz::{OffsetDateTimeExt, Tz, timezones};
8
9/// The OS-configured IANA timezone, read dynamically. Uses
10/// `iana-time-zone` (multithread-safe on Unix, unlike
11/// `time::UtcOffset::current_local_offset`, which errors in a threaded
12/// process). Falls back to UTC with a warn only when the zone can't be
13/// resolved - the rare exception, so "today" tracks the user's wall clock.
14pub fn system_timezone() -> &'static Tz {
15    match iana_time_zone::get_timezone() {
16        Ok(name) => timezones::get_by_name(&name).unwrap_or_else(|| {
17            tracing::warn!(
18                target: "forge_agent::env::timezone",
19                %name,
20                "unknown system timezone; falling back to UTC",
21            );
22            timezones::db::UTC
23        }),
24        Err(error) => {
25            tracing::warn!(
26                target: "forge_agent::env::timezone",
27                %error,
28                "system timezone unavailable; falling back to UTC",
29            );
30            timezones::db::UTC
31        }
32    }
33}
34
35/// An RFC-3339 instant the CLI wrote, as the reader's own wall clock:
36/// `HH:MM:SS` for today's, the date too for anything older, because a turn
37/// read from a transcript can be weeks old and a bare time would read as
38/// today. `None` for a string that does not parse, so a malformed row draws
39/// no clock rather than a fabricated one.
40pub fn local_clock(instant: &str) -> Option<String> {
41    let parsed = time::OffsetDateTime::parse(instant, &Rfc3339).ok()?;
42    let local = parsed.to_timezone(system_timezone());
43    let today = time::OffsetDateTime::now_utc().to_timezone(system_timezone()).date();
44    let clock = format!("{:02}:{:02}:{:02}", local.hour(), local.minute(), local.second());
45    Some(if local.date() == today { clock } else { format!("{} {clock}", local.date()) })
46}
47
48/// The milliseconds from one RFC-3339 instant the CLI wrote to another.
49/// `None` unless both parse and the second is not earlier than the first.
50pub fn millis_between(from: &str, to: &str) -> Option<u64> {
51    let from = time::OffsetDateTime::parse(from, &Rfc3339).ok()?;
52    let to = time::OffsetDateTime::parse(to, &Rfc3339).ok()?;
53    u64::try_from((to - from).whole_milliseconds()).ok()
54}
55
56#[cfg(test)]
57mod tests {
58    use super::{Rfc3339, local_clock, millis_between, system_timezone};
59    use time_tz::OffsetDateTimeExt;
60
61    /// The instant is parsed and rendered, and an older one carries its date:
62    /// a turn read from a transcript can be weeks old, and a bare time on one
63    /// reads as today. The expected clock is recomputed here with the host's
64    /// own zone, so this pins the parse and the format and leaves the zone
65    /// itself to the check below.
66    #[test]
67    fn a_row_instant_reads_as_the_reader_own_wall_clock() {
68        let instant = "2020-04-22T04:15:27.000Z";
69        let local =
70            time::OffsetDateTime::parse(instant, &time::format_description::well_known::Rfc3339)
71                .expect("the fixture parses")
72                .to_timezone(system_timezone());
73        let clock = format!("{:02}:{:02}:{:02}", local.hour(), local.minute(), local.second());
74
75        assert_eq!(
76            local_clock(instant).as_deref(),
77            Some(format!("{} {clock}", local.date()).as_str()),
78            "an instant from another day renders as that date and the host's own clock",
79        );
80    }
81
82    /// The zone is the host's, not the instant's own `Z`: an instant whose
83    /// clock differs from its UTC reading proves one of the two was applied.
84    /// A host already on UTC cannot tell the difference and says so.
85    #[test]
86    fn the_clock_is_the_hosts_and_not_the_instants_own() {
87        let instant = "2020-04-22T04:15:27.000Z";
88        let rendered = local_clock(instant).expect("the fixture parses");
89        let offset = time::OffsetDateTime::now_utc().to_timezone(system_timezone()).offset();
90
91        if offset.is_utc() {
92            assert!(
93                rendered.ends_with("04:15:27"),
94                "a UTC host renders the instant's own clock: {rendered}"
95            );
96        } else {
97            assert!(
98                !rendered.ends_with("04:15:27"),
99                "the host's own clock is not the instant's own: {rendered}",
100            );
101        }
102    }
103
104    /// Today's instant carries the time alone: a clock on the row a reader is
105    /// watching is not dated.
106    #[test]
107    fn todays_instant_carries_the_time_alone() {
108        let now = time::OffsetDateTime::now_utc().to_timezone(system_timezone());
109        let rendered = local_clock(&now.format(&Rfc3339).expect("formats")).expect("parses");
110
111        assert_eq!(
112            rendered,
113            format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second()),
114            "the reader's own clock, and no date on the day it belongs to",
115        );
116    }
117
118    /// A row whose clock does not parse draws nothing rather than a time.
119    #[test]
120    fn an_unparseable_instant_has_no_wall_clock() {
121        assert_eq!(local_clock("not a timestamp"), None, "no clock is invented for it");
122    }
123
124    /// The span between two of a turn's own rows. A pair the wrong way round
125    /// has no span: zero and a wrapped count both read as a measurement.
126    #[test]
127    fn a_span_is_the_time_between_two_rows_and_nothing_when_it_runs_backwards() {
128        assert_eq!(
129            millis_between("2026-04-22T04:15:27.000Z", "2026-04-22T04:18:08.000Z"),
130            Some(161_000),
131            "two minutes and forty-one seconds",
132        );
133        assert_eq!(
134            millis_between("2026-04-22T04:18:08.000Z", "2026-04-22T04:15:27.000Z"),
135            None,
136            "a span that runs backwards is not a duration",
137        );
138        assert_eq!(
139            millis_between("2026-04-22T04:15:27.000Z", "nonsense"),
140            None,
141            "and neither is a pair with an unreadable end"
142        );
143    }
144}