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///
9/// Used when upserting [`chronon_core::Job`] rows with [`chronon_core::ScheduleKind::Cron`]:
10/// set `job.cron_expr` (and optional `timezone`); `CoordinatorService::upsert_job` calls
11/// [`Self::parse`] and stores `next_run_at`.
12///
13/// Syntax: standard five-field cron (`minute hour day-of-month month day-of-week`). An optional
14/// sixth field enables seconds (`CronExpr::has_seconds`). Timezone names follow `chrono-tz`
15/// (e.g. `"America/New_York"`); omit for UTC.
16///
17/// # Examples
18///
19/// ```
20/// use chronon_scheduler::CronExpr;
21///
22/// let cron = CronExpr::parse("0 2 * * *", Some("UTC")).unwrap();
23/// assert_eq!(cron.expression(), "0 2 * * *");
24/// assert!(!cron.has_seconds());
25/// assert!(cron.next_from_now().is_some());
26///
27/// let with_secs = CronExpr::parse("0 0 2 * * *", None).unwrap();
28/// assert!(with_secs.has_seconds());
29/// ```
30#[derive(Debug, Clone)]
31pub struct CronExpr {
32    expr: String,
33    schedule: croner::Cron,
34    timezone: Tz,
35}
36
37impl CronExpr {
38    /// Parse a cron expression with an optional timezone.
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// use chronon_scheduler::CronExpr;
44    ///
45    /// let cron = CronExpr::parse("0 0 * * *", None).unwrap();
46    /// assert_eq!(cron.expression(), "0 0 * * *");
47    /// assert!(cron.next_from_now().is_some());
48    /// ```
49    pub fn parse(expr: &str, timezone: Option<&str>) -> Result<Self> {
50        let schedule = croner::Cron::new(expr)
51            .with_seconds_optional()
52            .parse()
53            .map_err(|e| ChrononError::InvalidCron(format!("{expr}: {e}")))?;
54
55        let tz: Tz = match timezone {
56            Some(tz_str) => tz_str
57                .parse()
58                .map_err(|_| ChrononError::InvalidTimezone(tz_str.to_string()))?,
59            None => Tz::UTC,
60        };
61
62        Ok(Self {
63            expr: expr.to_string(),
64            schedule,
65            timezone: tz,
66        })
67    }
68
69    /// Calculate the next run time after the given datetime.
70    pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
71        let after_tz = after.with_timezone(&self.timezone);
72        self.schedule
73            .find_next_occurrence(&after_tz, false)
74            .ok()
75            .map(|dt| dt.with_timezone(&Utc))
76    }
77
78    /// Calculate the next run time from now.
79    pub fn next_from_now(&self) -> Option<DateTime<Utc>> {
80        self.next_after(Utc::now())
81    }
82
83    /// Get the original cron expression.
84    pub fn expression(&self) -> &str {
85        &self.expr
86    }
87
88    /// Get the timezone.
89    pub fn timezone(&self) -> &Tz {
90        &self.timezone
91    }
92
93    /// Check if the expression uses seconds (6 fields).
94    pub fn has_seconds(&self) -> bool {
95        self.expr.split_whitespace().count() == 6
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use chrono::{Datelike, TimeZone, Timelike};
103
104    #[test]
105    fn parse_valid_cron() {
106        assert!(CronExpr::parse("0 0 * * *", None).is_ok());
107    }
108
109    #[test]
110    fn parse_with_timezone() {
111        let cron = CronExpr::parse("0 9 * * *", Some("America/New_York")).unwrap();
112        assert_eq!(*cron.timezone(), chrono_tz::America::New_York);
113    }
114
115    #[test]
116    fn parse_invalid_cron() {
117        assert!(CronExpr::parse("invalid", None).is_err());
118    }
119
120    #[test]
121    fn next_after_daily_noon() {
122        let cron = CronExpr::parse("0 12 * * *", None).unwrap();
123        let base = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
124        let next = cron.next_after(base).unwrap();
125        assert_eq!(next.hour(), 12);
126        assert_eq!(next.day(), 1);
127    }
128}