Skip to main content

forge_workspace/mcp/cron/
schedule.rs

1//! Cron schedule math: expression parsing, next-fire, and due-check.
2//!
3//! `chrono` is confined to THIS module. Every public function takes and
4//! returns `SystemTime`, so `CronEntry`, the scheduler, and the rest of
5//! forge never touch chrono - the SystemTime <-> chrono conversion
6//! happens only inside [`next_fire_after`]'s private timezone helper.
7//!
8//! Timezone: recurring expressions evaluate in the host's LOCAL time
9//! (standard cron / crontab semantics). The public [`next_fire_after`]
10//! hard-codes `Local`; the generic `_in_tz` helper it delegates to lets
11//! tests pin a fixed offset and stay deterministic (CI runs UTC).
12//! `due_crons` needs no TZ - a `SystemTime` is an absolute instant, so
13//! the due-check is TZ-independent.
14
15use std::time::SystemTime;
16
17use chrono::{DateTime, Local, TimeZone, Utc};
18use croner::Cron;
19use croner::errors::CronError;
20use croner::parser::CronParser;
21use forge_primitives::cron::{CronEntry, CronId, CronKind};
22
23/// Parses with `sloppy_ranges`, which takes the shorthand step syntax
24/// (`5/5 * * * *`) a registered schedule may already carry; the strict
25/// default rejects it, and a schedule that stops parsing is dropped.
26fn parse_cron(expr: &str) -> Result<Cron, CronError> {
27    CronParser::builder().sloppy_ranges(true).build().parse(expr)
28}
29
30/// Validate a 5-field cron expression, returning a human-readable error
31/// on failure. `cron__create` calls this to reject a malformed schedule
32/// at registration time with a clear message.
33pub(crate) fn validate_cron_expr(expr: &str) -> Result<(), String> {
34    parse_cron(expr).map(|_| ()).map_err(|e| e.to_string())
35}
36
37/// The next fire for a cron `kind` strictly after `after`, evaluated in
38/// the host's LOCAL timezone. `None` for a run-once whose instant has
39/// passed, or a recurring expression that fails to parse / has no
40/// upcoming occurrence.
41pub fn next_fire_after(kind: &CronKind, after: SystemTime) -> Option<SystemTime> {
42    next_fire_after_in_tz(kind, after, &Local)
43}
44
45/// TZ-generic core of [`next_fire_after`]. Split out so tests inject a
46/// fixed offset (deterministic regardless of the host TZ) while prod
47/// passes `Local`.
48fn next_fire_after_in_tz<Tz: TimeZone>(
49    kind: &CronKind,
50    after: SystemTime,
51    tz: &Tz,
52) -> Option<SystemTime> {
53    match kind {
54        CronKind::Once(at) => (*at > after).then_some(*at),
55        CronKind::Recurring(expr) => next_recurring_fire_in_tz(expr, after, tz),
56    }
57}
58
59/// Next occurrence of cron `expr` strictly after `after`, in `tz`. The
60/// sole SystemTime <-> chrono boundary: `after` converts in, the result
61/// converts back out, and chrono never escapes.
62fn next_recurring_fire_in_tz<Tz: TimeZone>(
63    expr: &str,
64    after: SystemTime,
65    tz: &Tz,
66) -> Option<SystemTime> {
67    let cron = parse_cron(expr).ok()?;
68    let after_dt: DateTime<Tz> = DateTime::<Utc>::from(after).with_timezone(tz);
69    let next = cron.find_next_occurrence(&after_dt, false).ok()?;
70    Some(SystemTime::from(next))
71}
72
73/// The crons whose `next_fire` is at or before `now` - the scheduler's
74/// per-tick due set, in input order. Boot catch-up is just one such tick
75/// (`Workspace::fire_due_crons` at startup): an overdue cron is due, so it
76/// fires once and `advance_or_remove_cron` moves `next_fire` past every
77/// missed slot - catch-up-once falls out of the normal fire path, no
78/// separate boot reconcile needed.
79pub(crate) fn due_crons(crons: &[CronEntry], now: SystemTime) -> Vec<CronId> {
80    crons.iter().filter(|c| c.next_fire <= now).map(|c| c.id.clone()).collect()
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use chrono::{FixedOffset, TimeZone, Utc};
87    use std::time::Duration;
88
89    fn epoch(secs: u64) -> SystemTime {
90        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
91    }
92
93    fn at_utc(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> SystemTime {
94        SystemTime::from(Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).single().expect("valid datetime"))
95    }
96
97    fn recurring_entry(id: &str, next_fire: u64) -> CronEntry {
98        CronEntry {
99            id: CronId::from(id),
100            project_name: "forge".to_owned(),
101            kind: CronKind::Recurring("0 9 * * *".to_owned()),
102            prompt: "p".to_owned(),
103            created_at: epoch(0),
104            description: None,
105            last_fire: None,
106            next_fire: epoch(next_fire),
107            team_role: None,
108            wake: true,
109        }
110    }
111
112    #[test]
113    fn due_crons_returns_entries_at_or_before_now_in_order() {
114        let crons = vec![
115            recurring_entry("past", 100),
116            recurring_entry("exact", 200),
117            recurring_entry("future", 300),
118        ];
119        let due = due_crons(&crons, epoch(200));
120        assert_eq!(due, vec![CronId::from("past"), CronId::from("exact")]);
121    }
122
123    #[test]
124    fn due_crons_empty_when_nothing_due() {
125        let crons = vec![recurring_entry("a", 500), recurring_entry("b", 600)];
126        assert!(due_crons(&crons, epoch(100)).is_empty());
127    }
128
129    #[test]
130    fn validate_accepts_five_field_and_rejects_garbage() {
131        assert!(validate_cron_expr("0 9 * * *").is_ok());
132        assert!(validate_cron_expr("*/15 * * * *").is_ok());
133        assert!(validate_cron_expr("not a cron").is_err());
134        assert!(validate_cron_expr("99 99 * * *").is_err());
135    }
136
137    /// Both halves matter: `cron__create` refuses what fails validation,
138    /// and a stored expression that stops parsing is dropped on advance.
139    #[test]
140    fn shorthand_step_syntax_still_validates_and_fires() {
141        assert!(validate_cron_expr("5/5 * * * *").is_ok());
142        let kind = CronKind::Recurring("5/5 * * * *".to_owned());
143        assert!(
144            next_fire_after_in_tz(&kind, at_utc(2024, 1, 1, 0, 0), &Utc).is_some(),
145            "a shorthand step schedule must still have a next fire"
146        );
147    }
148
149    #[test]
150    fn next_recurring_daily_slot_in_utc() {
151        // 03:30 daily; from 2024-01-01T00:00Z the next slot is 03:30Z.
152        let next = next_recurring_fire_in_tz("30 3 * * *", at_utc(2024, 1, 1, 0, 0), &Utc)
153            .expect("has next");
154        assert_eq!(next, at_utc(2024, 1, 1, 3, 30));
155    }
156
157    #[test]
158    fn next_recurring_respects_injected_timezone() {
159        // "0 9 * * *" is 09:00 IST; IST is +5:30, so 09:00 IST == 03:30 UTC.
160        let ist = FixedOffset::east_opt(5 * 3600 + 30 * 60).expect("valid offset");
161        let next = next_recurring_fire_in_tz("0 9 * * *", at_utc(2024, 1, 1, 0, 0), &ist)
162            .expect("has next");
163        assert_eq!(next, at_utc(2024, 1, 1, 3, 30), "09:00 IST resolves to 03:30 UTC");
164    }
165
166    #[test]
167    fn next_fire_after_once_is_future_instant_then_none_once_past() {
168        let once_at = at_utc(2024, 1, 1, 12, 0);
169        let kind = CronKind::Once(once_at);
170        assert_eq!(next_fire_after_in_tz(&kind, at_utc(2024, 1, 1, 11, 0), &Utc), Some(once_at));
171        assert_eq!(next_fire_after_in_tz(&kind, at_utc(2024, 1, 1, 13, 0), &Utc), None);
172    }
173
174    #[test]
175    fn next_fire_after_recurring_dispatches_to_parser() {
176        let kind = CronKind::Recurring("30 3 * * *".to_owned());
177        let next = next_fire_after_in_tz(&kind, at_utc(2024, 1, 1, 0, 0), &Utc).expect("has next");
178        assert_eq!(next, at_utc(2024, 1, 1, 3, 30));
179    }
180
181    #[test]
182    fn next_fire_after_recurring_invalid_expr_is_none() {
183        let kind = CronKind::Recurring("not a cron".to_owned());
184        assert_eq!(next_fire_after_in_tz(&kind, at_utc(2024, 1, 1, 0, 0), &Utc), None);
185    }
186
187    #[test]
188    fn next_fire_after_local_wrapper_yields_a_future_instant() {
189        // The public Local wrapper: the exact instant is host-TZ
190        // dependent, so only the monotonic property is asserted.
191        let now = SystemTime::now();
192        let next = next_fire_after(&CronKind::Recurring("*/5 * * * *".to_owned()), now)
193            .expect("an every-5-minutes cron always has a next slot");
194        assert!(next > now, "the next fire is in the future");
195    }
196}