Skip to main content

rusty_ts/
relative.rs

1//! Relative-mode (`-r`) timestamp rewriter.
2//!
3//! Per `spec.md` FR-009 and FR-025:
4//!
5//! - **Default mode**: recognizes ISO-8601, RFC-3339, and Unix epoch
6//!   (integer or fractional) timestamps. Other timestamp formats pass
7//!   through unchanged.
8//! - **Strict mode** (FR-025): recognizes the full moreutils `ts -r` set,
9//!   including human-readable formats moreutils ships regexes for.
10//!
11//! The recognized timestamps are rewritten in place to a human-relative
12//! form ("3.2s ago", "1m12s ago", ...). Lines without recognizable
13//! timestamps pass through unchanged.
14
15use crate::mode::CompatibilityMode;
16use chrono::{DateTime, Utc};
17use regex::Regex;
18
19/// Container for the precompiled regex set used by relative-mode rewriting.
20/// Built once at startup; cloned cheaply via `Arc` internals.
21#[derive(Debug)]
22pub struct RelativeRewriter {
23    patterns: Vec<RecognizedPattern>,
24}
25
26#[derive(Debug)]
27struct RecognizedPattern {
28    re: Regex,
29    parser: ParserKind,
30}
31
32#[derive(Debug, Clone, Copy)]
33enum ParserKind {
34    Iso8601,
35    Rfc3339,
36    UnixEpoch,
37    /// Strict-only: additional human-readable formats moreutils recognizes.
38    /// For v0.1.0 we ship the "%Y-%m-%d %H:%M:%S" date-time pattern as the
39    /// Strict superset baseline; future work expands this set.
40    HumanDateTime,
41}
42
43impl RelativeRewriter {
44    /// Build a rewriter configured for the given mode. Compiles each
45    /// regex once — callers should hold the rewriter for the lifetime
46    /// of the invocation.
47    ///
48    /// Pattern ordering matters: more-specific (longer match) patterns are
49    /// listed first, so that when overlap resolution runs (in `rewrite`)
50    /// the more-specific match wins.
51    pub fn for_mode(mode: CompatibilityMode) -> Self {
52        let mut patterns: Vec<RecognizedPattern> = Vec::new();
53
54        if mode == CompatibilityMode::Strict {
55            // Strict mode adds the moreutils human-date-time pattern.
56            // Listed first so it wins over the shorter ISO-8601 date-only
57            // pattern when both could match.
58            patterns.push(RecognizedPattern {
59                re: Regex::new(r"\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\b")
60                    .expect("human date-time regex"),
61                parser: ParserKind::HumanDateTime,
62            });
63        }
64
65        // RFC-3339: 2026-05-22T14:30:45Z or 2026-05-22T14:30:45.123+02:00
66        patterns.push(RecognizedPattern {
67            re: Regex::new(
68                r"\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})\b",
69            )
70            .expect("RFC-3339 regex"),
71            parser: ParserKind::Rfc3339,
72        });
73        // ISO-8601 date-only: 2026-05-22 (less specific; placed after RFC-3339)
74        patterns.push(RecognizedPattern {
75            re: Regex::new(r"\b\d{4}-\d{2}-\d{2}\b").expect("ISO-8601 date regex"),
76            parser: ParserKind::Iso8601,
77        });
78        // Unix epoch (integer + optional fractional): 1779798645 or 1779798645.123456
79        // Conservative bound: 10-digit epoch (current era) with optional fractional part.
80        patterns.push(RecognizedPattern {
81            re: Regex::new(r"\b1\d{9}(?:\.\d+)?\b").expect("Unix epoch regex"),
82            parser: ParserKind::UnixEpoch,
83        });
84
85        Self { patterns }
86    }
87
88    /// Rewrite a single line, replacing each recognized timestamp with its
89    /// relative form against the supplied reference instant. Lines with no
90    /// recognizable timestamp pass through unchanged.
91    pub fn rewrite(&self, line: &str, reference: DateTime<Utc>) -> String {
92        let mut output = String::with_capacity(line.len());
93        let mut cursor = 0usize;
94
95        // Walk all patterns and collect non-overlapping matches sorted by
96        // start position.
97        let mut matches: Vec<(usize, usize, ParserKind)> = Vec::new();
98        for pat in &self.patterns {
99            for m in pat.re.find_iter(line) {
100                matches.push((m.start(), m.end(), pat.parser));
101            }
102        }
103        matches.sort_by_key(|m| m.0);
104
105        // Resolve overlaps by preferring earlier (and longer-tied) matches.
106        let mut filtered: Vec<(usize, usize, ParserKind)> = Vec::new();
107        for m in matches {
108            if let Some(prev) = filtered.last() {
109                if m.0 < prev.1 {
110                    continue;
111                }
112            }
113            filtered.push(m);
114        }
115
116        for (start, end, parser) in filtered {
117            output.push_str(&line[cursor..start]);
118            let token = &line[start..end];
119            match parse_token(token, parser) {
120                Some(parsed) => output.push_str(&relative_form(reference, parsed)),
121                None => output.push_str(token), // unparseable — leave alone
122            }
123            cursor = end;
124        }
125        output.push_str(&line[cursor..]);
126        output
127    }
128}
129
130fn parse_token(token: &str, parser: ParserKind) -> Option<DateTime<Utc>> {
131    match parser {
132        ParserKind::Rfc3339 => DateTime::parse_from_rfc3339(token)
133            .ok()
134            .map(|dt| dt.with_timezone(&Utc)),
135        ParserKind::Iso8601 => {
136            // Date-only → interpret as midnight UTC of that date.
137            chrono::NaiveDate::parse_from_str(token, "%Y-%m-%d")
138                .ok()
139                .and_then(|d| d.and_hms_opt(0, 0, 0))
140                .map(|naive| naive.and_utc())
141        }
142        ParserKind::UnixEpoch => {
143            if let Some(dot_pos) = token.find('.') {
144                let secs: i64 = token[..dot_pos].parse().ok()?;
145                let frac: f64 = format!("0.{}", &token[dot_pos + 1..]).parse().ok()?;
146                let nsecs = (frac * 1_000_000_000.0) as u32;
147                DateTime::<Utc>::from_timestamp(secs, nsecs)
148            } else {
149                let secs: i64 = token.parse().ok()?;
150                DateTime::<Utc>::from_timestamp(secs, 0)
151            }
152        }
153        ParserKind::HumanDateTime => {
154            chrono::NaiveDateTime::parse_from_str(token, "%Y-%m-%d %H:%M:%S")
155                .ok()
156                .map(|naive| naive.and_utc())
157        }
158    }
159}
160
161/// Render a relative-duration string in the moreutils style. Outputs forms
162/// like "5s ago", "1m23s ago", "2h ago", "1d2h ago", or "now". Future at the
163/// reference instant produces "in 5s" / etc.
164fn relative_form(reference: DateTime<Utc>, target: DateTime<Utc>) -> String {
165    use chrono::Duration;
166    let delta: Duration = reference.signed_duration_since(target);
167    let (sign, ago_or_in) = if delta.num_seconds() >= 0 {
168        ("", "ago")
169    } else {
170        ("", "in")
171    };
172    let total = delta.num_seconds().unsigned_abs();
173    if total == 0 {
174        return "now".into();
175    }
176    let days = total / 86_400;
177    let hours = (total % 86_400) / 3_600;
178    let mins = (total % 3_600) / 60;
179    let secs = total % 60;
180
181    let body = if days > 0 {
182        format!("{days}d{hours}h")
183    } else if hours > 0 {
184        format!("{hours}h{mins}m")
185    } else if mins > 0 {
186        format!("{mins}m{secs}s")
187    } else {
188        format!("{secs}s")
189    };
190
191    if ago_or_in == "ago" {
192        format!("{sign}{body} ago")
193    } else {
194        format!("in {body}")
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use chrono::TimeZone;
202
203    fn reference() -> DateTime<Utc> {
204        Utc.with_ymd_and_hms(2026, 5, 22, 14, 30, 45).unwrap()
205    }
206
207    #[test]
208    fn default_subset_recognizes_rfc3339() {
209        let rewriter = RelativeRewriter::for_mode(CompatibilityMode::Default);
210        let line = "Event at 2026-05-22T14:30:42Z happened.";
211        let out = rewriter.rewrite(line, reference());
212        assert!(
213            out.contains("3s ago"),
214            "expected '3s ago' replacement; got {out:?}",
215        );
216    }
217
218    #[test]
219    fn default_subset_recognizes_unix_epoch() {
220        let rewriter = RelativeRewriter::for_mode(CompatibilityMode::Default);
221        let epoch = reference().timestamp() - 65;
222        let line = format!("epoch={epoch} now=...");
223        let out = rewriter.rewrite(&line, reference());
224        assert!(
225            out.contains("1m5s ago"),
226            "expected '1m5s ago' replacement; got {out:?}",
227        );
228    }
229
230    #[test]
231    fn line_without_timestamp_passes_through() {
232        let rewriter = RelativeRewriter::for_mode(CompatibilityMode::Default);
233        let line = "plain text no timestamp here";
234        let out = rewriter.rewrite(line, reference());
235        assert_eq!(out, line);
236    }
237
238    #[test]
239    fn default_mode_does_not_match_human_date_time() {
240        let rewriter = RelativeRewriter::for_mode(CompatibilityMode::Default);
241        let line = "Event at 2026-05-22 14:30:42 happened.";
242        let out = rewriter.rewrite(line, reference());
243        // Default mode recognizes the date-only `2026-05-22` portion via
244        // ISO-8601 pattern but leaves the time portion alone.
245        assert!(
246            out.contains("14:30:42"),
247            "time component should pass through in Default mode; got {out:?}",
248        );
249    }
250
251    #[test]
252    fn strict_mode_recognizes_human_date_time() {
253        let rewriter = RelativeRewriter::for_mode(CompatibilityMode::Strict);
254        let line = "Event at 2026-05-22 14:30:42 happened.";
255        let out = rewriter.rewrite(line, reference());
256        assert!(
257            out.contains("3s ago"),
258            "Strict mode should rewrite human date-time; got {out:?}",
259        );
260    }
261
262    #[test]
263    fn relative_form_zero_delta_is_now() {
264        assert_eq!(relative_form(reference(), reference()), "now");
265    }
266
267    #[test]
268    fn relative_form_future_uses_in_prefix() {
269        let future = reference() + chrono::Duration::seconds(45);
270        let out = relative_form(reference(), future);
271        assert!(out.starts_with("in "), "got {out:?}");
272    }
273}