1use std::fmt;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Overlap {
13 #[default]
15 BufferAll,
16 AllowAll,
18 Skip,
19 BufferOne,
20 CancelOther,
21 TerminateOther,
22}
23
24impl Overlap {
25 pub fn code(self) -> i32 {
27 match self {
28 Overlap::Skip => 1,
29 Overlap::BufferOne => 2,
30 Overlap::BufferAll => 3,
31 Overlap::CancelOther => 4,
32 Overlap::TerminateOther => 5,
33 Overlap::AllowAll => 6,
34 }
35 }
36
37 pub fn name(self) -> &'static str {
39 match self {
40 Overlap::Skip => "Skip",
41 Overlap::BufferOne => "BufferOne",
42 Overlap::BufferAll => "BufferAll",
43 Overlap::CancelOther => "CancelOther",
44 Overlap::TerminateOther => "TerminateOther",
45 Overlap::AllowAll => "AllowAll",
46 }
47 }
48
49 pub const ALL: [Overlap; 6] = [
51 Overlap::Skip,
52 Overlap::BufferOne,
53 Overlap::BufferAll,
54 Overlap::CancelOther,
55 Overlap::TerminateOther,
56 Overlap::AllowAll,
57 ];
58
59 pub fn parse(s: &str) -> Option<Overlap> {
60 Overlap::ALL
61 .into_iter()
62 .find(|p| p.name().eq_ignore_ascii_case(s))
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct TimeRange {
69 pub start_ms: i64,
70 pub end_ms: i64,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum RangeError {
75 NoSeparator,
76 BadInstant(String),
77 NotBefore,
78}
79
80impl fmt::Display for RangeError {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 RangeError::NoSeparator => write!(f, "expected START..END"),
84 RangeError::BadInstant(s) => {
85 write!(f, "cannot read `{s}` as a time: try 2026-09-01, -7d or now")
86 }
87 RangeError::NotBefore => write!(f, "the start must be before the end"),
88 }
89 }
90}
91
92impl std::error::Error for RangeError {}
93
94pub fn parse_range(input: &str, now_ms: i64) -> Result<TimeRange, RangeError> {
99 let (a, b) = input.split_once("..").ok_or(RangeError::NoSeparator)?;
100 let start_ms = parse_instant(a.trim(), now_ms)?;
101 let end = b.trim();
102 let end_ms = if end.is_empty() {
103 now_ms
104 } else {
105 parse_instant(end, now_ms)?
106 };
107 if start_ms >= end_ms {
108 return Err(RangeError::NotBefore);
109 }
110 Ok(TimeRange { start_ms, end_ms })
111}
112
113pub fn parse_instant(s: &str, now_ms: i64) -> Result<i64, RangeError> {
115 if s.eq_ignore_ascii_case("now") {
116 return Ok(now_ms);
117 }
118 if let Some(rest) = s.strip_prefix('-') {
119 return parse_offset(rest)
120 .map(|ms| now_ms - ms)
121 .ok_or_else(|| RangeError::BadInstant(s.to_string()));
122 }
123 parse_absolute(s).ok_or_else(|| RangeError::BadInstant(s.to_string()))
124}
125
126fn parse_offset(s: &str) -> Option<i64> {
128 let (digits, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
129 let n: i64 = digits.parse().ok()?;
130 let scale = match unit {
131 "s" => 1_000,
132 "m" => 60_000,
133 "h" => 3_600_000,
134 "d" => 86_400_000,
135 "w" => 604_800_000,
136 _ => return None,
137 };
138 n.checked_mul(scale)
139}
140
141fn parse_absolute(s: &str) -> Option<i64> {
146 let s = s.strip_suffix('Z').unwrap_or(s);
147 let (date, time) = match s.split_once(['T', ' ']) {
148 Some((d, t)) => (d, t),
149 None => (s, ""),
150 };
151
152 let mut d = date.split('-');
153 let (y, m, day) = (num(d.next()?)?, num(d.next()?)?, num(d.next()?)?);
154 if d.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&day) {
155 return None;
156 }
157
158 let (mut h, mut min, mut sec) = (0, 0, 0);
159 if !time.is_empty() {
160 let mut t = time.split(':');
161 h = num(t.next()?)?;
162 min = num(t.next()?)?;
163 if let Some(v) = t.next() {
164 sec = num(v.split('.').next()?)?;
166 }
167 if t.next().is_some() || h > 23 || min > 59 || sec > 60 {
168 return None;
169 }
170 }
171
172 let days = days_from_civil(y, m, day)?;
173 Some((days * 86_400 + h * 3_600 + min * 60 + sec) * 1_000)
174}
175
176fn num(s: &str) -> Option<i64> {
177 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
178 return None;
179 }
180 s.parse().ok()
181}
182
183fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
188 if d > days_in_month(y, m)? {
189 return None;
190 }
191 let y = if m <= 2 { y - 1 } else { y };
192 let era = if y >= 0 { y } else { y - 399 } / 400;
193 let yoe = y - era * 400;
194 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
195 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
196 Some(era * 146_097 + doe - 719_468)
197}
198
199fn days_in_month(y: i64, m: i64) -> Option<i64> {
200 Some(match m {
201 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
202 4 | 6 | 9 | 11 => 30,
203 2 if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) => 29,
204 2 => 28,
205 _ => return None,
206 })
207}
208
209pub fn to_rfc3339(ms: i64) -> String {
211 let secs = ms.div_euclid(1_000);
212 let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
213 let (y, m, d) = civil_from_days(days);
214 let (h, min, s) = (rem / 3_600, (rem % 3_600) / 60, rem % 60);
215 format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z")
216}
217
218fn civil_from_days(z: i64) -> (i64, i64, i64) {
220 let z = z + 719_468;
221 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
222 let doe = z - era * 146_097;
223 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
224 let y = yoe + era * 400;
225 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
226 let mp = (5 * doy + 2) / 153;
227 let d = doy - (153 * mp + 2) / 5 + 1;
228 let m = if mp < 10 { mp + 3 } else { mp - 9 };
229 (if m <= 2 { y + 1 } else { y }, m, d)
230}
231
232pub fn parse_backfill(input: &str, now_ms: i64) -> Result<(TimeRange, Overlap), String> {
238 let input = input.trim();
239 let (range, policy) = match input.rsplit_once(char::is_whitespace) {
240 Some((head, tail)) => match Overlap::parse(tail) {
243 Some(p) => (head.trim(), p),
244 None => (input, Overlap::default()),
245 },
246 None => (input, Overlap::default()),
247 };
248 parse_range(range, now_ms)
249 .map(|r| (r, policy))
250 .map_err(|e| e.to_string())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 const NOW: i64 = 1_788_652_800_000;
259
260 #[test]
261 fn a_date_reads_as_utc_midnight() {
262 assert_eq!(parse_instant("2026-09-06", NOW), Ok(NOW));
263 assert_eq!(to_rfc3339(NOW), "2026-09-06T00:00:00Z");
264 }
265
266 #[test]
267 fn an_instant_round_trips_through_rfc3339() {
268 for s in [
269 "1970-01-01T00:00:00Z",
270 "2000-02-29T12:34:56Z",
271 "2026-12-31T23:59:59Z",
272 ] {
273 let ms = parse_instant(s, NOW).expect(s);
274 assert_eq!(to_rfc3339(ms), s);
275 }
276 }
277
278 #[test]
279 fn an_offset_counts_back_from_now() {
280 assert_eq!(parse_instant("-1d", NOW), Ok(NOW - 86_400_000));
281 assert_eq!(parse_instant("-2w", NOW), Ok(NOW - 2 * 604_800_000));
282 assert_eq!(parse_instant("-90m", NOW), Ok(NOW - 90 * 60_000));
283 assert_eq!(parse_instant("now", NOW), Ok(NOW));
284 }
285
286 #[test]
287 fn an_omitted_end_means_now() {
288 let r = parse_range("-7d..", NOW).unwrap();
290 assert_eq!(r.end_ms, NOW);
291 assert_eq!(r.start_ms, NOW - 7 * 86_400_000);
292 }
293
294 #[test]
295 fn a_range_needs_both_ends_in_order() {
296 assert_eq!(parse_range("-7d", NOW), Err(RangeError::NoSeparator));
297 assert_eq!(parse_range("now..-7d", NOW), Err(RangeError::NotBefore));
298 assert_eq!(parse_range("now..now", NOW), Err(RangeError::NotBefore));
299 }
300
301 #[test]
302 fn an_impossible_date_is_refused_rather_than_rolled_over() {
303 for s in ["2026-02-30", "2026-13-01", "2026-09-32", "2026-09", "hello"] {
305 assert!(
306 matches!(parse_instant(s, NOW), Err(RangeError::BadInstant(_))),
307 "{s} should not parse"
308 );
309 }
310 }
311
312 #[test]
313 fn a_leap_day_is_accepted_only_in_a_leap_year() {
314 assert!(parse_instant("2024-02-29", NOW).is_ok());
315 assert!(parse_instant("2000-02-29", NOW).is_ok(), "divisible by 400");
316 assert!(
317 parse_instant("1900-02-29", NOW).is_err(),
318 "divisible by 100"
319 );
320 assert!(parse_instant("2026-02-29", NOW).is_err());
321 }
322
323 #[test]
324 fn a_time_of_day_is_optional_and_seconds_within_it_are_too() {
325 let day = parse_instant("2026-09-06", NOW).unwrap();
326 assert_eq!(parse_instant("2026-09-06T09:30", NOW), Ok(day + 34_200_000));
327 assert_eq!(
328 parse_instant("2026-09-06T09:30:15Z", NOW),
329 Ok(day + 34_215_000)
330 );
331 assert_eq!(
332 parse_instant("2026-09-06T09:30:15.500Z", NOW),
333 Ok(day + 34_215_000),
334 "a fractional part is dropped rather than refusing the instant"
335 );
336 }
337
338 #[test]
339 fn a_backfill_defaults_to_running_its_actions_in_order() {
340 assert_eq!(Overlap::default(), Overlap::BufferAll);
343 assert_eq!(Overlap::default().code(), 3);
344 }
345
346 #[test]
347 fn a_policy_name_matches_the_cli_spelling_in_both_directions() {
348 for p in Overlap::ALL {
349 assert_eq!(Overlap::parse(p.name()), Some(p));
350 }
351 assert_eq!(Overlap::parse("bufferall"), Some(Overlap::BufferAll));
352 assert_eq!(Overlap::parse("nonsense"), None);
353 }
354 #[test]
355 fn a_backfill_line_takes_a_range_and_an_optional_policy() {
356 let (r, p) = parse_backfill("-1d..now", NOW).unwrap();
357 assert_eq!(r.start_ms, NOW - 86_400_000);
358 assert_eq!(p, Overlap::BufferAll, "the default when none is named");
359
360 let (_, p) = parse_backfill("-1d..now AllowAll", NOW).unwrap();
361 assert_eq!(p, Overlap::AllowAll);
362 }
363
364 #[test]
365 fn a_trailing_word_that_is_not_a_policy_is_not_silently_dropped() {
366 let e = parse_backfill("-1d..now BuffrAll", NOW).unwrap_err();
369 assert!(e.contains("cannot read"), "{e}");
370 }
371}