Skip to main content

wt/
time.rs

1//! Time formatting for display (spec ยง7 "Display conventions").
2//!
3//! Commit timestamps are stored as Unix seconds. [`iso8601`] renders the stable
4//! `--json` form (`2024-01-15T10:30:00Z`); [`relative`] renders the human form
5//! as a single largest unit (`just now`, `5m ago`, `3h ago`, `2d ago`,
6//! `4mo ago`, `1y ago`).
7
8use jiff::Timestamp;
9
10const MINUTE: i64 = 60;
11const HOUR: i64 = 60 * MINUTE;
12const DAY: i64 = 24 * HOUR;
13const MONTH: i64 = 30 * DAY;
14const YEAR: i64 = 365 * DAY;
15
16/// Formats Unix seconds as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`).
17pub fn iso8601(unix_seconds: i64) -> String {
18    match Timestamp::from_second(unix_seconds) {
19        Ok(ts) => ts.strftime("%Y-%m-%dT%H:%M:%SZ").to_string(),
20        Err(_) => "(invalid time)".to_string(),
21    }
22}
23
24/// Renders the gap from `then_unix` to `now_unix` as a single largest unit.
25/// Times in the future (or under a minute old) render as `just now`.
26pub fn relative(now_unix: i64, then_unix: i64) -> String {
27    let secs = now_unix - then_unix;
28    if secs < MINUTE {
29        return "just now".to_string();
30    }
31    let (value, unit) = if secs < HOUR {
32        (secs / MINUTE, "m")
33    } else if secs < DAY {
34        (secs / HOUR, "h")
35    } else if secs < MONTH {
36        (secs / DAY, "d")
37    } else if secs < YEAR {
38        (secs / MONTH, "mo")
39    } else {
40        (secs / YEAR, "y")
41    };
42    format!("{value}{unit} ago")
43}
44
45/// The current time as Unix seconds (the reference for [`relative`]).
46pub fn now_unix() -> i64 {
47    Timestamp::now().as_second()
48}
49
50/// Parses an ISO-8601 timestamp (as produced by [`iso8601`]) back to Unix
51/// seconds, for computing relative display from the stored value.
52pub fn parse_iso8601(text: &str) -> Option<i64> {
53    text.parse::<Timestamp>().ok().map(|t| t.as_second())
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn iso8601_formats_known_instant() {
62        // 2024-01-15T10:30:00Z == 1705314600 seconds since the epoch.
63        assert_eq!(iso8601(1_705_314_600), "2024-01-15T10:30:00Z");
64        assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
65    }
66
67    #[test]
68    fn relative_units_pick_largest() {
69        assert_eq!(relative(1_000, 1_000), "just now");
70        assert_eq!(relative(1_000 + 30, 1_000), "just now");
71        assert_eq!(relative(1_000 + 5 * MINUTE, 1_000), "5m ago");
72        assert_eq!(relative(1_000 + 3 * HOUR, 1_000), "3h ago");
73        assert_eq!(relative(1_000 + 2 * DAY, 1_000), "2d ago");
74        assert_eq!(relative(1_000 + 4 * MONTH, 1_000), "4mo ago");
75        assert_eq!(relative(1_000 + YEAR, 1_000), "1y ago");
76        assert_eq!(relative(1_000 + 3 * YEAR, 1_000), "3y ago");
77    }
78
79    #[test]
80    fn relative_boundaries() {
81        assert_eq!(relative(59, 0), "just now");
82        assert_eq!(relative(60, 0), "1m ago");
83        assert_eq!(relative(HOUR - 1, 0), "59m ago");
84        assert_eq!(relative(HOUR, 0), "1h ago");
85        assert_eq!(relative(DAY - 1, 0), "23h ago");
86        assert_eq!(relative(DAY, 0), "1d ago");
87        assert_eq!(relative(MONTH - 1, 0), "29d ago");
88        assert_eq!(relative(MONTH, 0), "1mo ago");
89        assert_eq!(relative(YEAR, 0), "1y ago");
90    }
91
92    #[test]
93    fn future_times_are_just_now() {
94        assert_eq!(relative(0, 1_000), "just now");
95    }
96
97    #[test]
98    fn now_unix_is_after_2020() {
99        assert!(now_unix() > 1_600_000_000);
100    }
101
102    #[test]
103    fn iso8601_round_trips_through_parse() {
104        for unix in [0, 1_705_314_600, 1_600_000_000] {
105            assert_eq!(parse_iso8601(&iso8601(unix)), Some(unix));
106        }
107        assert_eq!(parse_iso8601("not a timestamp"), None);
108    }
109}