Skip to main content

forge_core/cron/
schedule.rs

1use chrono::{DateTime, Utc};
2use chrono_tz::Tz;
3use cron::Schedule;
4use std::str::FromStr;
5
6/// A parsed cron schedule.
7#[derive(Debug, Clone)]
8pub struct CronSchedule {
9    /// The cron expression string.
10    expression: String,
11    /// Parsed schedule (cached).
12    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    /// Create a new cron schedule from an expression.
26    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    /// Create a cron schedule from an expression that was already validated at compile time.
39    ///
40    /// Falls back to a non-firing schedule if parsing somehow fails, which cannot happen
41    /// when the macro has already validated the expression.
42    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    /// Get the cron expression string.
53    pub fn expression(&self) -> &str {
54        &self.expression
55    }
56
57    /// Get the next scheduled time after the given time.
58    pub fn next_after(&self, _after: DateTime<Utc>) -> Option<DateTime<Utc>> {
59        self.schedule.as_ref()?.upcoming(Utc).next()
60    }
61
62    /// Get the next scheduled time after the given time in a specific timezone.
63    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        // Get upcoming times in the target timezone
68        self.schedule
69            .as_ref()?
70            .after(&local_time)
71            .next()
72            .map(|dt| dt.with_timezone(&Utc))
73    }
74
75    /// Get all scheduled times between two times.
76    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    /// Get all scheduled times between two times in a specific timezone.
85    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
110/// Normalize a cron expression to include seconds.
111fn 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), // Add "0" for seconds
116        6 => expr.to_string(),      // Already has seconds
117        _ => expr.to_string(),      // Let the parser handle the error
118    }
119}
120
121/// Cron parsing error.
122#[derive(Debug, Clone)]
123pub enum CronParseError {
124    /// Invalid cron expression.
125    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(); // Every hour
158        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(); // Every minute
173        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}