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        // Normalize expression (add seconds if missing)
28        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    /// Get the cron expression string.
40    pub fn expression(&self) -> &str {
41        &self.expression
42    }
43
44    /// Get the next scheduled time after the given time.
45    pub fn next_after(&self, _after: DateTime<Utc>) -> Option<DateTime<Utc>> {
46        self.schedule.as_ref()?.upcoming(Utc).next()
47    }
48
49    /// Get the next scheduled time after the given time in a specific timezone.
50    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        // Get upcoming times in the target timezone
55        self.schedule
56            .as_ref()?
57            .after(&local_time)
58            .next()
59            .map(|dt| dt.with_timezone(&Utc))
60    }
61
62    /// Get all scheduled times between two times.
63    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    /// Get all scheduled times between two times in a specific timezone.
72    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
97/// Normalize a cron expression to include seconds.
98fn 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), // Add "0" for seconds
103        6 => expr.to_string(),      // Already has seconds
104        _ => expr.to_string(),      // Let the parser handle the error
105    }
106}
107
108/// Cron parsing error.
109#[derive(Debug, Clone)]
110pub enum CronParseError {
111    /// Invalid cron expression.
112    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)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_parse_five_part_cron() {
131        let schedule = CronSchedule::new("*/5 * * * *").unwrap();
132        assert_eq!(schedule.expression(), "0 */5 * * * *");
133    }
134
135    #[test]
136    fn test_parse_six_part_cron() {
137        let schedule = CronSchedule::new("30 */5 * * * *").unwrap();
138        assert_eq!(schedule.expression(), "30 */5 * * * *");
139    }
140
141    #[test]
142    fn test_next_after() {
143        let schedule = CronSchedule::new("0 0 * * * *").unwrap(); // Every hour
144        let now = Utc::now();
145        let next = schedule.next_after(now);
146        assert!(next.is_some());
147        assert!(next.unwrap() > now);
148    }
149
150    #[test]
151    fn test_invalid_cron() {
152        let result = CronSchedule::new("invalid");
153        assert!(result.is_err());
154    }
155
156    #[test]
157    fn test_between() {
158        let schedule = CronSchedule::new("0 * * * *").unwrap(); // Every minute
159        let start = Utc::now();
160        let end = start + chrono::Duration::hours(1);
161        let times = schedule.between(start, end);
162        assert!(!times.is_empty());
163    }
164}