Skip to main content

foxtive_cron/
builder.rs

1use crate::contracts::{Schedule, ValidatedSchedule};
2use crate::{CronError, CronResult};
3use chrono::{DateTime, NaiveDate, Utc};
4use chrono_tz::Tz;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::time::Duration;
8use rand::RngExt;
9
10/// Represents the months of the year.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum Month {
13    January = 1,
14    February = 2,
15    March = 3,
16    April = 4,
17    May = 5,
18    June = 6,
19    July = 7,
20    August = 8,
21    September = 9,
22    October = 10,
23    November = 11,
24    December = 12,
25}
26
27/// Represents the days of the week.
28/// Note: These values map to cron crate format where Sunday=1, Monday=2, ..., Saturday=7
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum Weekday {
31    Sunday = 0,
32    Monday = 1,
33    Tuesday = 2,
34    Wednesday = 3,
35    Thursday = 4,
36    Friday = 5,
37    Saturday = 6,
38}
39
40/// Represents the different ways a cron field can be specified.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42enum CronField {
43    /// All values (*)
44    All,
45    /// A single specific value (e.g., 5)
46    Value(u32),
47    /// A range of values (e.g., 1-5)
48    Range(u32, u32),
49    /// An interval/step (e.g., */15 or 1-30/5)
50    Step(Box<CronField>, u32),
51    /// A list of specific components (e.g., 1,3,5-10)
52    List(Vec<CronField>),
53}
54
55impl fmt::Display for CronField {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            CronField::All => write!(f, "*"),
59            CronField::Value(v) => write!(f, "{}", v),
60            CronField::Range(start, end) => write!(f, "{}-{}", start, end),
61            CronField::Step(base, step) => write!(f, "{}/{}", base, step),
62            CronField::List(values) => {
63                let s: Vec<String> = values.iter().map(|v| v.to_string()).collect();
64                write!(f, "{}", s.join(","))
65            }
66        }
67    }
68}
69
70/// A structure representing a cron expression with advanced features like
71/// timezones, exclusion rules, and jitter.
72///
73/// Use `CronExpression::builder()` to create a new instance fluently.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct CronExpression {
76    seconds: CronField,
77    minutes: CronField,
78    hours: CronField,
79    day_of_month: CronField,
80    month: CronField,
81    day_of_week: CronField,
82    year: CronField,
83
84    timezone: Option<Tz>,
85    jitter: Option<Duration>,
86    blackout_dates: Vec<NaiveDate>,
87
88    #[serde(skip)]
89    error: Option<String>,
90    #[serde(skip)]
91    validated: Option<ValidatedSchedule>,
92}
93
94impl Default for CronExpression {
95    fn default() -> Self {
96        Self {
97            seconds: CronField::Value(0),
98            minutes: CronField::All,
99            hours: CronField::All,
100            day_of_month: CronField::All,
101            month: CronField::All,
102            day_of_week: CronField::All,
103            year: CronField::All,
104            timezone: None,
105            jitter: None,
106            blackout_dates: Vec::new(),
107            error: None,
108            validated: None,
109        }
110    }
111}
112
113impl Schedule for CronExpression {
114    fn next_after(&self, after: &DateTime<Utc>, tz: Tz) -> Option<DateTime<Utc>> {
115        let active_tz = self.timezone.unwrap_or(tz);
116
117        // Loop to handle blackout dates (if next time is blacked out, find the one after)
118        let mut current_after = *after;
119
120        for _ in 0..100 {
121            // Safety limit to prevent infinite loops
122            let mut next = if let Some(v) = &self.validated {
123                v.next_after(&current_after, active_tz)
124            } else if let Ok(v) = self.to_validated() {
125                v.next_after(&current_after, active_tz)
126            } else {
127                return None;
128            }?;
129
130            // Apply Jitter if specified
131            if let Some(jitter_dur) = self.jitter {
132                let mut rng = rand::rng();
133                let millis = jitter_dur.as_millis();
134                if millis > 0 {
135                    let offset = rng.random_range(0..millis);
136                    next += Duration::from_millis(offset as u64);
137                }
138            }
139
140            // Check Blackout Dates
141            let date = next.date_naive();
142            if self.blackout_dates.contains(&date) {
143                current_after = next;
144                continue;
145            }
146
147            return Some(next);
148        }
149
150        None
151    }
152}
153
154impl CronExpression {
155    /// Create a new builder for a `CronExpression`.
156    pub fn builder() -> Self {
157        Self::default()
158    }
159
160    /// Set a fixed timezone for this schedule.
161    pub fn with_timezone(mut self, tz: Tz) -> Self {
162        self.timezone = Some(tz);
163        self
164    }
165
166    /// Adds a random jitter to the execution time.
167    ///
168    /// The job will run at the scheduled time plus a random duration
169    /// between 0 and `max_jitter`.
170    pub fn with_jitter(mut self, max_jitter: Duration) -> Self {
171        self.jitter = Some(max_jitter);
172        self
173    }
174
175    /// Exclude specific dates from the schedule.
176    pub fn exclude_date(mut self, date: NaiveDate) -> Self {
177        self.blackout_dates.push(date);
178        self
179    }
180
181    /// Exclude a list of dates.
182    pub fn exclude_dates(mut self, dates: Vec<NaiveDate>) -> Self {
183        self.blackout_dates.extend(dates);
184        self
185    }
186
187    // --- Presets ---
188
189    pub fn hourly(mut self) -> Self {
190        self.seconds = CronField::Value(0);
191        self.minutes = CronField::Value(0);
192        self.hours = CronField::All;
193        self
194    }
195
196    pub fn daily(mut self) -> Self {
197        self.seconds = CronField::Value(0);
198        self.minutes = CronField::Value(0);
199        self.hours = CronField::Value(0);
200        self
201    }
202
203    pub fn weekly(mut self) -> Self {
204        self = self.daily();
205        self.day_of_week = CronField::Value(1);
206        self
207    }
208
209    pub fn monthly(mut self) -> Self {
210        self = self.daily();
211        self.day_of_month = CronField::Value(1);
212        self
213    }
214
215    // --- Field Methods ---
216
217    fn validate_range(&mut self, val: u32, min: u32, max: u32, field: &str) {
218        if val < min || val > max {
219            self.error = Some(format!(
220                "Invalid value {} for field {}: must be between {} and {}",
221                val, field, min, max
222            ));
223        }
224    }
225
226    pub fn second(mut self, second: u32) -> Self {
227        self.validate_range(second, 0, 59, "seconds");
228        self.seconds = CronField::Value(second);
229        self
230    }
231
232    pub fn every_second(mut self) -> Self {
233        self.seconds = CronField::All;
234        self
235    }
236
237    pub fn seconds_interval(mut self, interval: u32) -> Self {
238        self.validate_range(interval, 1, 59, "seconds interval");
239        self.seconds = CronField::Step(Box::new(CronField::All), interval);
240        self
241    }
242
243    pub fn minute(mut self, minute: u32) -> Self {
244        self.validate_range(minute, 0, 59, "minutes");
245        self.minutes = CronField::Value(minute);
246        self
247    }
248
249    pub fn every_minute(mut self) -> Self {
250        self.minutes = CronField::All;
251        self
252    }
253
254    pub fn minutes_interval(mut self, interval: u32) -> Self {
255        self.validate_range(interval, 1, 59, "minutes interval");
256        self.minutes = CronField::Step(Box::new(CronField::All), interval);
257        self
258    }
259
260    pub fn hour(mut self, hour: u32) -> Self {
261        self.validate_range(hour, 0, 23, "hours");
262        self.hours = CronField::Value(hour);
263        self
264    }
265
266    pub fn hours_list(mut self, hours: &[u32]) -> Self {
267        let mut fields = Vec::new();
268        for &h in hours {
269            self.validate_range(h, 0, 23, "hours list");
270            fields.push(CronField::Value(h));
271        }
272        self.hours = CronField::List(fields);
273        self
274    }
275
276    pub fn every_hour(mut self) -> Self {
277        self.hours = CronField::All;
278        self
279    }
280
281    pub fn hours_range(mut self, start: u32, end: u32) -> Self {
282        self.validate_range(start, 0, 23, "hours range start");
283        self.validate_range(end, 0, 23, "hours range end");
284        if start >= end {
285            self.error = Some(format!(
286                "Hours range start ({}) must be less than end ({})",
287                start, end
288            ));
289        }
290        self.hours = CronField::Range(start, end);
291        self
292    }
293
294    pub fn day_of_month(mut self, day: u32) -> Self {
295        self.validate_range(day, 1, 31, "day of month");
296        self.day_of_month = CronField::Value(day);
297        self
298    }
299
300    pub fn month(mut self, month: Month) -> Self {
301        self.month = CronField::Value(month as u32);
302        self
303    }
304
305    pub fn day_of_week(mut self, day: Weekday) -> Self {
306        // Cron crate format: Sunday=1, Monday=2, ..., Saturday=7
307        // Our enum: Sunday=0, Monday=1, ..., Saturday=6
308        let cron_value = (day as u32) + 1;
309        self.day_of_week = CronField::Value(cron_value);
310        self
311    }
312
313    pub fn weekdays_only(mut self) -> Self {
314        // Cron crate: Monday=2 through Friday=6
315        self.day_of_week = CronField::Range(2, 6);
316        self
317    }
318
319    pub fn weekends_only(mut self) -> Self {
320        // Cron crate: Saturday=7, Sunday=1
321        self.day_of_week = CronField::List(vec![CronField::Value(7), CronField::Value(1)]);
322        self
323    }
324
325    pub fn sundays_only(mut self) -> Self {
326        // Cron crate: Sunday=1
327        self.day_of_week = CronField::Value(1);
328        self
329    }
330
331    pub fn mondays_only(mut self) -> Self {
332        self.day_of_week = CronField::Value(2);
333        self
334    }
335
336    pub fn tuesdays_only(mut self) -> Self {
337        self.day_of_week = CronField::Value(3);
338        self
339    }
340
341    pub fn wednesdays_only(mut self) -> Self {
342        self.day_of_week = CronField::Value(4);
343        self
344    }
345
346    pub fn thursdays_only(mut self) -> Self {
347        self.day_of_week = CronField::Value(5);
348        self
349    }
350
351    pub fn fridays_only(mut self) -> Self {
352        self.day_of_week = CronField::Value(6);
353        self
354    }
355
356    pub fn saturdays_only(mut self) -> Self {
357        self.day_of_week = CronField::Value(7);
358        self
359    }
360
361    pub fn quarterly(mut self) -> Self {
362        self.month = CronField::List(vec![
363            CronField::Value(1),  // January
364            CronField::Value(4),  // April
365            CronField::Value(7),  // July
366            CronField::Value(10), // October
367        ]);
368        self.day_of_month = CronField::Value(1);
369        self.seconds = CronField::Value(0);
370        self.minutes = CronField::Value(0);
371        self.hours = CronField::Value(0);
372        self
373    }
374
375    pub fn year(mut self, year: u32) -> Self {
376        self.year = CronField::Value(year);
377        self
378    }
379
380    pub fn build(&self) -> String {
381        format!(
382            "{} {} {} {} {} {} {}",
383            self.seconds,
384            self.minutes,
385            self.hours,
386            self.day_of_month,
387            self.month,
388            self.day_of_week,
389            self.year
390        )
391    }
392
393    pub fn to_validated(&self) -> CronResult<ValidatedSchedule> {
394        if let Some(err) = &self.error {
395            return Err(CronError::InvalidSchedule(err.clone()));
396        }
397        ValidatedSchedule::parse(&self.build())
398    }
399}
400
401impl fmt::Display for CronExpression {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        write!(f, "{}", self.build())
404    }
405}