Skip to main content

tmprl_core/
timerange.rs

1//! Parsing the time window a backfill runs over.
2
3use std::fmt;
4
5/// How a backfill's runs behave when they collide with each other.
6///
7/// A schedule's own policy is usually `Skip`, which discards a run whenever the previous one
8/// is still going. A backfill replays many scheduled times at once, so under `Skip` almost
9/// every one is dropped and the backfill silently does nothing. Temporal's own documentation
10/// says to override it, which is why a backfill carries a policy of its own.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Overlap {
13    /// Run them one after another, none discarded. The safe default for a backfill.
14    #[default]
15    BufferAll,
16    /// Run them all at once. Fastest, and only safe when the workflow tolerates it.
17    AllowAll,
18    Skip,
19    BufferOne,
20    CancelOther,
21    TerminateOther,
22}
23
24impl Overlap {
25    /// The proto enum value. Kept beside the names so the two cannot drift.
26    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    /// The spelling `temporal schedule backfill --overlap-policy` takes.
38    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    /// Every policy, for parsing a name and for listing them in an error.
50    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/// A half-open window, epoch millis.
67#[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
94/// Read `START..END`, the form a backfill is asked for.
95///
96/// An empty end means now, so `-7d..` is the last week. Both sides accept an absolute
97/// `YYYY-MM-DD` or RFC 3339 instant, a negative offset like `-7d`, or `now`.
98pub 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
113/// One end of a range: `now`, an offset such as `-36h`, or an absolute instant.
114pub 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
126/// `90s`, `30m`, `36h`, `7d`, `2w`, as milliseconds.
127fn 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
141/// `YYYY-MM-DD`, optionally `THH:MM[:SS]` and a trailing `Z`.
142///
143/// Always read as UTC. A backfill window is compared against scheduled times the server
144/// holds in UTC, so guessing a local zone would move the window by hours without saying so.
145fn 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            // Drop a fractional part rather than refusing the whole instant over it.
165            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
183/// Days since 1970-01-01 for a proleptic Gregorian date.
184///
185/// Howard Hinnant's `days_from_civil`, which is exact for every year in range and avoids
186/// taking a date library as a dependency for the one conversion tmprl needs.
187fn 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
209/// An instant as RFC 3339, which is what `temporal schedule backfill` takes.
210pub 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
218/// The inverse of [`days_from_civil`].
219fn 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
232/// Read what the backfill prompt collects: a range, and optionally a policy after it.
233///
234/// One line rather than a form, because a backfill is two values and a prompt already exists.
235/// Leaving the policy off gets [`Overlap::BufferAll`], which is what Temporal's own
236/// documentation tells you to use.
237pub 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        // Only a trailing word that names a policy is one; anything else is part of the
241        // range, so a typo is reported as a bad range rather than silently ignored.
242        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    /// 2026-09-06T00:00:00Z.
258    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        // `-7d..` is the last week, which is the shape most backfills are asked for.
289        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        // Rolling 2026-02-30 into March would backfill a window nobody asked for.
304        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        // A schedule's own policy is usually Skip, under which a backfill discards almost
341        // every run it replays and appears to do nothing.
342        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        // Treating it as a policy typo and ignoring it would run the backfill under a
367        // policy the reader did not ask for.
368        let e = parse_backfill("-1d..now BuffrAll", NOW).unwrap_err();
369        assert!(e.contains("cannot read"), "{e}");
370    }
371}