1use crate::mode::CompatibilityMode;
16use chrono::{DateTime, Utc};
17use regex::Regex;
18
19#[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 HumanDateTime,
41}
42
43impl RelativeRewriter {
44 pub fn for_mode(mode: CompatibilityMode) -> Self {
52 let mut patterns: Vec<RecognizedPattern> = Vec::new();
53
54 if mode == CompatibilityMode::Strict {
55 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 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 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 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 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 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 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), }
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 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
161fn 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 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}