forge_workspace/mcp/cron/
schedule.rs1use 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
23fn parse_cron(expr: &str) -> Result<Cron, CronError> {
27 CronParser::builder().sloppy_ranges(true).build().parse(expr)
28}
29
30pub(crate) fn validate_cron_expr(expr: &str) -> Result<(), String> {
34 parse_cron(expr).map(|_| ()).map_err(|e| e.to_string())
35}
36
37pub fn next_fire_after(kind: &CronKind, after: SystemTime) -> Option<SystemTime> {
42 next_fire_after_in_tz(kind, after, &Local)
43}
44
45fn 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
59fn 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
73pub(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 #[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 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 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 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}