forge_core/cron/
schedule.rs1use chrono::{DateTime, Utc};
2use chrono_tz::Tz;
3use cron::Schedule;
4use std::str::FromStr;
5
6#[derive(Debug, Clone)]
8pub struct CronSchedule {
9 expression: String,
11 schedule: Option<Schedule>,
13}
14
15impl Default for CronSchedule {
16 fn default() -> Self {
17 Self {
18 expression: "0 * * * * *".to_string(),
19 schedule: Schedule::from_str("0 * * * * *").ok(),
20 }
21 }
22}
23
24impl CronSchedule {
25 pub fn new(expression: &str) -> Result<Self, CronParseError> {
27 let normalized = normalize_cron_expression(expression);
29
30 let schedule = Schedule::from_str(&normalized)
31 .map_err(|e| CronParseError::InvalidExpression(e.to_string()))?;
32
33 Ok(Self {
34 expression: normalized,
35 schedule: Some(schedule),
36 })
37 }
38
39 pub fn expression(&self) -> &str {
41 &self.expression
42 }
43
44 pub fn next_after(&self, _after: DateTime<Utc>) -> Option<DateTime<Utc>> {
46 self.schedule.as_ref()?.upcoming(Utc).next()
47 }
48
49 pub fn next_after_in_tz(&self, after: DateTime<Utc>, timezone: &str) -> Option<DateTime<Utc>> {
51 let tz: Tz = timezone.parse().ok()?;
52 let local_time = after.with_timezone(&tz);
53
54 self.schedule
56 .as_ref()?
57 .after(&local_time)
58 .next()
59 .map(|dt| dt.with_timezone(&Utc))
60 }
61
62 pub fn between(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<DateTime<Utc>> {
64 let Some(ref schedule) = self.schedule else {
65 return vec![];
66 };
67
68 schedule.after(&start).take_while(|dt| *dt <= end).collect()
69 }
70
71 pub fn between_in_tz(
73 &self,
74 start: DateTime<Utc>,
75 end: DateTime<Utc>,
76 timezone: &str,
77 ) -> Vec<DateTime<Utc>> {
78 let Ok(tz) = timezone.parse::<Tz>() else {
79 return vec![];
80 };
81
82 let Some(ref schedule) = self.schedule else {
83 return vec![];
84 };
85
86 let local_start = start.with_timezone(&tz);
87 let local_end = end.with_timezone(&tz);
88
89 schedule
90 .after(&local_start)
91 .take_while(|dt| *dt <= local_end)
92 .map(|dt| dt.with_timezone(&Utc))
93 .collect()
94 }
95}
96
97fn normalize_cron_expression(expr: &str) -> String {
99 let parts: Vec<&str> = expr.split_whitespace().collect();
100
101 match parts.len() {
102 5 => format!("0 {}", expr), 6 => expr.to_string(), _ => expr.to_string(), }
106}
107
108#[derive(Debug, Clone)]
110pub enum CronParseError {
111 InvalidExpression(String),
113}
114
115impl std::fmt::Display for CronParseError {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 Self::InvalidExpression(e) => write!(f, "Invalid cron expression: {}", e),
119 }
120 }
121}
122
123impl std::error::Error for CronParseError {}
124
125#[cfg(test)]
126#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn test_parse_five_part_cron() {
132 let schedule = CronSchedule::new("*/5 * * * *").unwrap();
133 assert_eq!(schedule.expression(), "0 */5 * * * *");
134 }
135
136 #[test]
137 fn test_parse_six_part_cron() {
138 let schedule = CronSchedule::new("30 */5 * * * *").unwrap();
139 assert_eq!(schedule.expression(), "30 */5 * * * *");
140 }
141
142 #[test]
143 fn test_next_after() {
144 let schedule = CronSchedule::new("0 0 * * * *").unwrap(); let now = Utc::now();
146 let next = schedule.next_after(now);
147 assert!(next.is_some());
148 assert!(next.unwrap() > now);
149 }
150
151 #[test]
152 fn test_invalid_cron() {
153 let result = CronSchedule::new("invalid");
154 assert!(result.is_err());
155 }
156
157 #[test]
158 fn test_between() {
159 let schedule = CronSchedule::new("0 * * * *").unwrap(); let start = Utc::now();
161 let end = start + chrono::Duration::hours(1);
162 let times = schedule.between(start, end);
163 assert!(!times.is_empty());
164 }
165}