Skip to main content

ironflow_engine/
schedule.rs

1//! [`CronSchedule`] -- validated cron expression newtype.
2//!
3//! Wraps [`croner::Cron`] to guarantee that any `CronSchedule` value
4//! holds a syntactically valid cron expression. Construction
5//! is fallible; once built the value is safe to pass to
6//! `tokio_cron_scheduler` without further validation.
7
8use std::fmt;
9use std::str::FromStr;
10
11use croner::Cron;
12use serde::{Deserialize, Serialize};
13
14/// A validated cron expression.
15///
16/// Internally wraps a [`croner::Cron`], guaranteeing that the expression
17/// has been parsed and validated at construction time.
18///
19/// [`as_str`](CronSchedule::as_str) returns the original expression
20/// as provided by the user, not the normalized form.
21///
22/// # Examples
23///
24/// ```
25/// use ironflow_engine::schedule::CronSchedule;
26///
27/// let sched = CronSchedule::new("0 0 * * * *").unwrap();
28/// assert_eq!(sched.as_str(), "0 0 * * * *");
29///
30/// let bad = CronSchedule::new("not a cron");
31/// assert!(bad.is_err());
32/// ```
33#[derive(Debug, Clone)]
34pub struct CronSchedule {
35    inner: Cron,
36    raw: String,
37}
38
39impl CronSchedule {
40    /// Parse and validate a cron expression.
41    ///
42    /// Accepts 5-field (standard) or 6-field (with seconds) expressions,
43    /// as supported by [`croner`].
44    ///
45    /// # Errors
46    ///
47    /// Returns an error string if the expression is syntactically invalid.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use ironflow_engine::schedule::CronSchedule;
53    ///
54    /// assert!(CronSchedule::new("0 */5 * * * *").is_ok());
55    /// assert!(CronSchedule::new("garbage").is_err());
56    /// ```
57    pub fn new(expression: &str) -> Result<Self, String> {
58        let inner = Cron::from_str(expression)
59            .map_err(|e| format!("invalid cron expression '{expression}': {e}"))?;
60        Ok(Self {
61            inner,
62            raw: expression.to_string(),
63        })
64    }
65
66    /// Returns the original cron expression string as provided to [`new`](Self::new).
67    pub fn as_str(&self) -> &str {
68        &self.raw
69    }
70}
71
72impl fmt::Display for CronSchedule {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str(&self.raw)
75    }
76}
77
78impl PartialEq for CronSchedule {
79    fn eq(&self, other: &Self) -> bool {
80        self.inner == other.inner
81    }
82}
83
84impl Eq for CronSchedule {}
85
86impl Serialize for CronSchedule {
87    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
88    where
89        S: serde::Serializer,
90    {
91        serializer.serialize_str(&self.raw)
92    }
93}
94
95impl<'de> Deserialize<'de> for CronSchedule {
96    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
97    where
98        D: serde::Deserializer<'de>,
99    {
100        let s = String::deserialize(deserializer)?;
101        Self::new(&s).map_err(serde::de::Error::custom)
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn valid_six_field_expression() {
111        let sched = CronSchedule::new("0 0 * * * *").unwrap();
112        assert_eq!(sched.as_str(), "0 0 * * * *");
113    }
114
115    #[test]
116    fn valid_five_field_expression() {
117        let sched = CronSchedule::new("*/5 * * * *").unwrap();
118        assert_eq!(sched.as_str(), "*/5 * * * *");
119    }
120
121    #[test]
122    fn valid_complex_expression() {
123        let sched = CronSchedule::new("0 30 9 * * MON-FRI").unwrap();
124        assert_eq!(sched.as_str(), "0 30 9 * * MON-FRI");
125    }
126
127    #[test]
128    fn invalid_expression_returns_error() {
129        let result = CronSchedule::new("not a cron");
130        assert!(result.is_err());
131        let err = result.unwrap_err();
132        assert!(err.contains("invalid cron expression"));
133    }
134
135    #[test]
136    fn empty_expression_returns_error() {
137        assert!(CronSchedule::new("").is_err());
138    }
139
140    #[test]
141    fn display_shows_original_expression() {
142        let sched = CronSchedule::new("0 0 12 * * *").unwrap();
143        assert_eq!(format!("{sched}"), "0 0 12 * * *");
144    }
145
146    #[test]
147    fn semantic_equality() {
148        let a = CronSchedule::new("0 0 * * * MON-FRI").unwrap();
149        let b = CronSchedule::new("0 0 * * * 1-5").unwrap();
150        assert_eq!(a, b);
151    }
152
153    #[test]
154    fn inequality_on_different_expressions() {
155        let a = CronSchedule::new("0 0 * * * *").unwrap();
156        let b = CronSchedule::new("0 30 * * * *").unwrap();
157        assert_ne!(a, b);
158    }
159
160    #[test]
161    fn serde_roundtrip() {
162        let sched = CronSchedule::new("0 */5 * * * *").unwrap();
163        let json = serde_json::to_string(&sched).unwrap();
164        assert_eq!(json, "\"0 */5 * * * *\"");
165        let back: CronSchedule = serde_json::from_str(&json).unwrap();
166        assert_eq!(back, sched);
167    }
168
169    #[test]
170    fn deserialize_invalid_expression_fails() {
171        let result: Result<CronSchedule, _> = serde_json::from_str("\"garbage\"");
172        assert!(result.is_err());
173    }
174}