Skip to main content

rusty_ts/time/
format.rs

1//! Strftime rendering with moreutils-`ts` fractional-second extensions.
2//!
3//! Per `spec.md` FR-003, FR-004, FR-008 and `plan.md` HINT-001:
4//!
5//! moreutils `ts` adds two strftime tokens chrono does not implement
6//! natively: `%.S` (seconds-with-fractional-component) and `%.s` (Unix
7//! epoch-with-fractional-component). We implement these as a one-pass
8//! pre-tokenizer that splices the fractional digits into the chrono-rendered
9//! output. Default precision is 6 digits (microsecond, matching moreutils'
10//! `Time::HiRes`-backed default) per FR-008.
11//!
12//! The default format string is `"%b %d %H:%M:%S"` (FR-003).
13
14use crate::time::tz::TimezoneSource;
15use chrono::{DateTime, Utc};
16
17/// The moreutils `ts` default format.
18pub const DEFAULT_FORMAT: &str = "%b %d %H:%M:%S";
19
20/// Default fractional precision (digits) for `%.S` / `%.s`. Microsecond
21/// resolution per FR-008.
22pub const DEFAULT_FRACTIONAL_DIGITS: usize = 6;
23
24/// Render the moreutils default format via the supplied timezone.
25pub fn format_default(now: DateTime<Utc>, tz: &TimezoneSource) -> String {
26    format_with(DEFAULT_FORMAT, now, tz)
27}
28
29/// Render an arbitrary strftime format, expanding moreutils `%.S` and `%.s`
30/// fractional tokens before delegating the rest to chrono.
31pub fn format_with(spec: &str, now: DateTime<Utc>, tz: &TimezoneSource) -> String {
32    if !spec.contains("%.S") && !spec.contains("%.s") {
33        return tz.render(now, spec);
34    }
35
36    // One-pass pre-tokenizer: walk the format string, split on the two
37    // fractional tokens, render the surrounding fragments via chrono, and
38    // splice in the fractional component at each token site. Microsecond
39    // precision; lower-precision systems get whatever the underlying clock
40    // supplies (still ≤ 6 digits).
41    let micros = now.timestamp_subsec_micros();
42    let epoch = now.timestamp();
43    let frac_seconds = format!("{micros:06}");
44    let frac_epoch = format!("{epoch}.{micros:06}");
45
46    let mut out = String::with_capacity(spec.len() + 16);
47    let mut remaining = spec;
48
49    loop {
50        // Find the earliest of `%.S` and `%.s`.
51        let pos_big = remaining.find("%.S");
52        let pos_small = remaining.find("%.s");
53
54        let (pos, is_big) = match (pos_big, pos_small) {
55            (Some(a), Some(b)) if a < b => (a, true),
56            (Some(_), Some(b)) => (b, false),
57            (Some(a), None) => (a, true),
58            (None, Some(b)) => (b, false),
59            (None, None) => {
60                // No more fractional tokens; render the rest via chrono.
61                out.push_str(&tz.render(now, remaining));
62                break;
63            }
64        };
65
66        // Render the prefix (everything up to the fractional token) via chrono.
67        if pos > 0 {
68            out.push_str(&tz.render(now, &remaining[..pos]));
69        }
70
71        if is_big {
72            // `%.S` = seconds with microsecond fraction. Render the integer
73            // second component from the zone-converted instant, then append
74            // ".{microseconds}".
75            let seconds = tz.render(now, "%S");
76            out.push_str(&seconds);
77            out.push('.');
78            out.push_str(&frac_seconds);
79        } else {
80            // `%.s` = Unix epoch with microsecond fraction. Independent of
81            // timezone (epoch is UTC-anchored).
82            out.push_str(&frac_epoch);
83        }
84
85        remaining = &remaining[pos + 3..];
86    }
87
88    out
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use chrono::{TimeZone, Timelike};
95
96    fn fixture_instant() -> DateTime<Utc> {
97        // 2026-05-22 14:30:45.123456 UTC — deterministic for snapshot-style
98        // comparisons in unit tests.
99        Utc.with_ymd_and_hms(2026, 5, 22, 14, 30, 45)
100            .unwrap()
101            .with_nanosecond(123_456_000)
102            .unwrap()
103    }
104
105    #[test]
106    fn default_format_matches_moreutils_default_string() {
107        assert_eq!(DEFAULT_FORMAT, "%b %d %H:%M:%S");
108    }
109
110    #[test]
111    fn default_format_under_utc_is_deterministic() {
112        let rendered = format_default(fixture_instant(), &TimezoneSource::Utc);
113        assert_eq!(rendered, "May 22 14:30:45");
114    }
115
116    #[test]
117    fn custom_format_renders_tokens() {
118        let rendered = format_with("%Y-%m-%d %H:%M:%S", fixture_instant(), &TimezoneSource::Utc);
119        assert_eq!(rendered, "2026-05-22 14:30:45");
120    }
121
122    #[test]
123    fn literal_brackets_are_preserved() {
124        let rendered = format_with("[%H:%M:%S]", fixture_instant(), &TimezoneSource::Utc);
125        assert_eq!(rendered, "[14:30:45]");
126    }
127
128    #[test]
129    fn fractional_seconds_token_expands() {
130        let rendered = format_with("%H:%M:%.S", fixture_instant(), &TimezoneSource::Utc);
131        assert_eq!(rendered, "14:30:45.123456");
132    }
133
134    #[test]
135    fn fractional_epoch_token_expands() {
136        let rendered = format_with("%.s", fixture_instant(), &TimezoneSource::Utc);
137        // 2026-05-22 14:30:45 UTC epoch = 1779798645
138        let expected_epoch: i64 = fixture_instant().timestamp();
139        assert_eq!(rendered, format!("{expected_epoch}.123456"));
140    }
141
142    #[test]
143    fn both_fractional_tokens_in_one_string() {
144        let rendered = format_with(
145            "%H:%M:%.S epoch=%.s",
146            fixture_instant(),
147            &TimezoneSource::Utc,
148        );
149        let expected_epoch: i64 = fixture_instant().timestamp();
150        assert_eq!(
151            rendered,
152            format!("14:30:45.123456 epoch={expected_epoch}.123456"),
153        );
154    }
155}