Skip to main content

chronon_scheduler/
cron.rs

1//! Cron expression parsing and next-run calculation.
2
3use chrono::{DateTime, Utc};
4use chrono_tz::Tz;
5use chronon_core::{ChrononError, Result};
6
7/// A parsed cron expression ready for next-run calculations.
8#[derive(Debug, Clone)]
9pub struct CronExpr {
10    expr: String,
11    schedule: croner::Cron,
12    timezone: Tz,
13}
14
15impl CronExpr {
16    /// Parse a cron expression with an optional timezone.
17    ///
18    /// # Examples
19    ///
20    /// ```
21    /// use chronon_scheduler::CronExpr;
22    ///
23    /// let cron = CronExpr::parse("0 0 * * *", None).unwrap();
24    /// assert_eq!(cron.expression(), "0 0 * * *");
25    /// assert!(cron.next_from_now().is_some());
26    /// ```
27    pub fn parse(expr: &str, timezone: Option<&str>) -> Result<Self> {
28        let schedule = croner::Cron::new(expr)
29            .with_seconds_optional()
30            .parse()
31            .map_err(|e| ChrononError::InvalidCron(format!("{expr}: {e}")))?;
32
33        let tz: Tz = match timezone {
34            Some(tz_str) => tz_str
35                .parse()
36                .map_err(|_| ChrononError::InvalidTimezone(tz_str.to_string()))?,
37            None => Tz::UTC,
38        };
39
40        Ok(Self {
41            expr: expr.to_string(),
42            schedule,
43            timezone: tz,
44        })
45    }
46
47    /// Calculate the next run time after the given datetime.
48    pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
49        let after_tz = after.with_timezone(&self.timezone);
50        self.schedule
51            .find_next_occurrence(&after_tz, false)
52            .ok()
53            .map(|dt| dt.with_timezone(&Utc))
54    }
55
56    /// Calculate the next run time from now.
57    pub fn next_from_now(&self) -> Option<DateTime<Utc>> {
58        self.next_after(Utc::now())
59    }
60
61    /// Get the original cron expression.
62    pub fn expression(&self) -> &str {
63        &self.expr
64    }
65
66    /// Get the timezone.
67    pub fn timezone(&self) -> &Tz {
68        &self.timezone
69    }
70
71    /// Check if the expression uses seconds (6 fields).
72    pub fn has_seconds(&self) -> bool {
73        self.expr.split_whitespace().count() == 6
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use chrono::{Datelike, TimeZone, Timelike};
81
82    #[test]
83    fn parse_valid_cron() {
84        assert!(CronExpr::parse("0 0 * * *", None).is_ok());
85    }
86
87    #[test]
88    fn parse_with_timezone() {
89        let cron = CronExpr::parse("0 9 * * *", Some("America/New_York")).unwrap();
90        assert_eq!(*cron.timezone(), chrono_tz::America::New_York);
91    }
92
93    #[test]
94    fn parse_invalid_cron() {
95        assert!(CronExpr::parse("invalid", None).is_err());
96    }
97
98    #[test]
99    fn next_after_daily_noon() {
100        let cron = CronExpr::parse("0 12 * * *", None).unwrap();
101        let base = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
102        let next = cron.next_after(base).unwrap();
103        assert_eq!(next.hour(), 12);
104        assert_eq!(next.day(), 1);
105    }
106}