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);
28
29 let schedule = Schedule::from_str(&normalized)
30 .map_err(|e| CronParseError::InvalidExpression(e.to_string()))?;
31
32 Ok(Self {
33 expression: normalized,
34 schedule: Some(schedule),
35 })
36 }
37
38 pub fn new_validated(expression: &str) -> Self {
43 let normalized = normalize_cron_expression(expression);
44 let schedule = Schedule::from_str(&normalized).ok();
45
46 Self {
47 expression: normalized,
48 schedule,
49 }
50 }
51
52 pub fn expression(&self) -> &str {
54 &self.expression
55 }
56
57 pub fn next_after(&self, _after: DateTime<Utc>) -> Option<DateTime<Utc>> {
59 self.schedule.as_ref()?.upcoming(Utc).next()
60 }
61
62 pub fn next_after_in_tz(&self, after: DateTime<Utc>, timezone: &str) -> Option<DateTime<Utc>> {
64 let tz: Tz = timezone.parse().ok()?;
65 let local_time = after.with_timezone(&tz);
66
67 self.schedule
69 .as_ref()?
70 .after(&local_time)
71 .next()
72 .map(|dt| dt.with_timezone(&Utc))
73 }
74
75 pub fn between(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<DateTime<Utc>> {
77 let Some(ref schedule) = self.schedule else {
78 return vec![];
79 };
80
81 schedule.after(&start).take_while(|dt| *dt <= end).collect()
82 }
83
84 pub fn between_in_tz(
86 &self,
87 start: DateTime<Utc>,
88 end: DateTime<Utc>,
89 timezone: &str,
90 ) -> Vec<DateTime<Utc>> {
91 let Ok(tz) = timezone.parse::<Tz>() else {
92 return vec![];
93 };
94
95 let Some(ref schedule) = self.schedule else {
96 return vec![];
97 };
98
99 let local_start = start.with_timezone(&tz);
100 let local_end = end.with_timezone(&tz);
101
102 schedule
103 .after(&local_start)
104 .take_while(|dt| *dt <= local_end)
105 .map(|dt| dt.with_timezone(&Utc))
106 .collect()
107 }
108}
109
110fn normalize_cron_expression(expr: &str) -> String {
112 let parts: Vec<&str> = expr.split_whitespace().collect();
113
114 match parts.len() {
115 5 => format!("0 {}", expr), 6 => expr.to_string(), _ => expr.to_string(), }
119}
120
121#[derive(Debug, Clone)]
123pub enum CronParseError {
124 InvalidExpression(String),
126}
127
128impl std::fmt::Display for CronParseError {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 match self {
131 Self::InvalidExpression(e) => write!(f, "Invalid cron expression: {}", e),
132 }
133 }
134}
135
136impl std::error::Error for CronParseError {}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn test_parse_five_part_cron() {
145 let schedule = CronSchedule::new("*/5 * * * *").unwrap();
146 assert_eq!(schedule.expression(), "0 */5 * * * *");
147 }
148
149 #[test]
150 fn test_parse_six_part_cron() {
151 let schedule = CronSchedule::new("30 */5 * * * *").unwrap();
152 assert_eq!(schedule.expression(), "30 */5 * * * *");
153 }
154
155 #[test]
156 fn test_next_after() {
157 let schedule = CronSchedule::new("0 0 * * * *").unwrap(); let now = Utc::now();
159 let next = schedule.next_after(now);
160 assert!(next.is_some());
161 assert!(next.unwrap() > now);
162 }
163
164 #[test]
165 fn test_invalid_cron() {
166 let result = CronSchedule::new("invalid");
167 assert!(result.is_err());
168 }
169
170 #[test]
171 fn test_between() {
172 let schedule = CronSchedule::new("0 * * * *").unwrap(); let start = Utc::now();
174 let end = start + chrono::Duration::hours(1);
175 let times = schedule.between(start, end);
176 assert!(!times.is_empty());
177 }
178}