Skip to main content

philiprehberger_cron_parser/
lib.rs

1//! # philiprehberger-cron-parser
2//!
3//! Cron expression parsing, scheduling, and human-readable descriptions.
4//! Zero external dependencies — uses only the standard library.
5//!
6//! ## Quick Start
7//!
8//! ```
9//! use philiprehberger_cron_parser::{CronExpr, DateTime};
10//!
11//! let expr = CronExpr::parse("*/15 * * * *").unwrap();
12//! let now = DateTime { year: 2026, month: 3, day: 15, hour: 10, minute: 3, second: 0 };
13//! let next = expr.next_from(&now).unwrap();
14//! assert_eq!(next, DateTime { year: 2026, month: 3, day: 15, hour: 10, minute: 15, second: 0 });
15//!
16//! // Human-readable description
17//! assert_eq!(expr.describe(), "Every 15 minutes");
18//! ```
19//!
20//! ## Supported Syntax
21//!
22//! Standard 5-field cron: `minute hour day-of-month month day-of-week`
23//!
24//! Each field supports: single values (`5`), ranges (`1-5`), steps (`*/15`, `1-5/2`),
25//! lists (`1,3,5`), and wildcards (`*`).
26//!
27//! Aliases: `@hourly`, `@daily`, `@midnight`, `@weekly`, `@monthly`, `@yearly`, `@annually`
28
29use std::fmt;
30use std::str::FromStr;
31use std::time::{SystemTime, UNIX_EPOCH};
32
33// ---------------------------------------------------------------------------
34// DateTime
35// ---------------------------------------------------------------------------
36
37/// A simple UTC date-time with second precision.
38#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
39pub struct DateTime {
40    pub year: i32,
41    pub month: u8,
42    pub day: u8,
43    pub hour: u8,
44    pub minute: u8,
45    pub second: u8,
46}
47
48/// Returns `true` if `year` is a leap year.
49pub fn is_leap_year(year: i32) -> bool {
50    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
51}
52
53/// Returns the number of days in the given month (1-12) for the given year.
54pub fn days_in_month(year: i32, month: u8) -> u8 {
55    match month {
56        1 => 31,
57        2 => {
58            if is_leap_year(year) {
59                29
60            } else {
61                28
62            }
63        }
64        3 => 31,
65        4 => 30,
66        5 => 31,
67        6 => 30,
68        7 => 31,
69        8 => 31,
70        9 => 30,
71        10 => 31,
72        11 => 30,
73        12 => 31,
74        _ => 30,
75    }
76}
77
78/// Returns the day of the week for a given date.
79/// 0 = Sunday, 1 = Monday, ..., 6 = Saturday.
80///
81/// Uses Tomohiko Sakamoto's algorithm.
82pub fn day_of_week(year: i32, month: u8, day: u8) -> u8 {
83    let t = [0i32, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
84    let mut y = year;
85    if month < 3 {
86        y -= 1;
87    }
88    let dow = (y + y / 4 - y / 100 + y / 400 + t[(month - 1) as usize] + day as i32) % 7;
89    // Ensure non-negative result
90    ((dow + 7) % 7) as u8
91}
92
93impl DateTime {
94    /// Returns the current UTC date-time derived from `SystemTime::now()`.
95    pub fn now() -> Self {
96        let secs = SystemTime::now()
97            .duration_since(UNIX_EPOCH)
98            .unwrap_or_default()
99            .as_secs();
100        Self::from_timestamp(secs as i64)
101    }
102
103    /// Constructs a `DateTime` from a Unix timestamp (seconds since 1970-01-01 00:00:00 UTC).
104    pub fn from_timestamp(mut ts: i64) -> Self {
105        let second = (ts % 60) as u8;
106        ts /= 60;
107        let minute = (ts % 60) as u8;
108        ts /= 60;
109        let hour = (ts % 24) as u8;
110        ts /= 24;
111
112        // ts is now days since epoch (1970-01-01 = day 0)
113        let mut days = ts;
114
115        // Compute year
116        let mut year: i32 = 1970;
117        loop {
118            let days_in_year: i64 = if is_leap_year(year) { 366 } else { 365 };
119            if days < days_in_year {
120                break;
121            }
122            days -= days_in_year;
123            year += 1;
124        }
125
126        // Compute month and day
127        let mut month: u8 = 1;
128        loop {
129            let dim = days_in_month(year, month) as i64;
130            if days < dim {
131                break;
132            }
133            days -= dim;
134            month += 1;
135        }
136        let day = days as u8 + 1;
137
138        DateTime {
139            year,
140            month,
141            day,
142            hour,
143            minute,
144            second,
145        }
146    }
147
148    /// Converts this `DateTime` back to a Unix timestamp (seconds since 1970-01-01 00:00:00 UTC).
149    #[must_use]
150    pub fn to_timestamp(&self) -> i64 {
151        let mut days: i64 = 0;
152
153        // Add days for complete years since 1970
154        for y in 1970..self.year {
155            days += if is_leap_year(y) { 366 } else { 365 };
156        }
157
158        // Add days for complete months in the current year
159        for m in 1..self.month {
160            days += days_in_month(self.year, m) as i64;
161        }
162
163        // Add remaining days (day is 1-based)
164        days += (self.day as i64) - 1;
165
166        days * 86400 + (self.hour as i64) * 3600 + (self.minute as i64) * 60 + (self.second as i64)
167    }
168
169    /// Advance by one minute, returning a new `DateTime` with second set to 0.
170    pub fn next_minute(&self) -> DateTime {
171        let mut year = self.year;
172        let mut month = self.month;
173        let mut day = self.day;
174        let mut hour = self.hour;
175        let mut minute = self.minute + 1;
176
177        if minute >= 60 {
178            minute = 0;
179            hour += 1;
180        }
181        if hour >= 24 {
182            hour = 0;
183            day += 1;
184        }
185        if day > days_in_month(year, month) {
186            day = 1;
187            month += 1;
188        }
189        if month > 12 {
190            month = 1;
191            year += 1;
192        }
193
194        DateTime {
195            year,
196            month,
197            day,
198            hour,
199            minute,
200            second: 0,
201        }
202    }
203
204    /// Returns the day of the week (0 = Sunday .. 6 = Saturday).
205    pub fn day_of_week(&self) -> u8 {
206        day_of_week(self.year, self.month, self.day)
207    }
208}
209
210impl Ord for DateTime {
211    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
212        self.year
213            .cmp(&other.year)
214            .then(self.month.cmp(&other.month))
215            .then(self.day.cmp(&other.day))
216            .then(self.hour.cmp(&other.hour))
217            .then(self.minute.cmp(&other.minute))
218            .then(self.second.cmp(&other.second))
219    }
220}
221
222impl PartialOrd for DateTime {
223    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
224        Some(self.cmp(other))
225    }
226}
227
228impl fmt::Display for DateTime {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        write!(
231            f,
232            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
233            self.year, self.month, self.day, self.hour, self.minute, self.second
234        )
235    }
236}
237
238// ---------------------------------------------------------------------------
239// ParseError
240// ---------------------------------------------------------------------------
241
242/// Errors that can occur when parsing a cron expression.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum ParseError {
245    /// The expression did not have exactly 5 space-separated fields.
246    InvalidFieldCount,
247    /// A field could not be parsed.
248    InvalidField {
249        field: String,
250        value: String,
251    },
252    /// An unknown alias was used (e.g. `@bogus`).
253    InvalidAlias(String),
254    /// A numeric value was outside the allowed range for its field.
255    ValueOutOfRange {
256        field: String,
257        value: u8,
258        min: u8,
259        max: u8,
260    },
261}
262
263impl fmt::Display for ParseError {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match self {
266            ParseError::InvalidFieldCount => {
267                write!(f, "cron expression must have exactly 5 fields")
268            }
269            ParseError::InvalidField { field, value } => {
270                write!(f, "invalid value '{}' for field '{}'", value, field)
271            }
272            ParseError::InvalidAlias(alias) => {
273                write!(f, "unknown alias '{}'", alias)
274            }
275            ParseError::ValueOutOfRange {
276                field,
277                value,
278                min,
279                max,
280            } => {
281                write!(
282                    f,
283                    "value {} out of range for field '{}' ({}..{})",
284                    value, field, min, max
285                )
286            }
287        }
288    }
289}
290
291impl std::error::Error for ParseError {}
292
293// ---------------------------------------------------------------------------
294// CronField (internal)
295// ---------------------------------------------------------------------------
296
297/// The set of allowed values for a single cron field, stored as a sorted `Vec<u8>`.
298#[derive(Debug, Clone, PartialEq, Eq)]
299struct CronField {
300    values: Vec<u8>,
301}
302
303impl CronField {
304    fn contains(&self, v: u8) -> bool {
305        self.values.contains(&v)
306    }
307}
308
309/// Field metadata: name, min, max.
310struct FieldSpec {
311    name: &'static str,
312    min: u8,
313    max: u8,
314}
315
316const FIELD_SPECS: [FieldSpec; 5] = [
317    FieldSpec { name: "minute", min: 0, max: 59 },
318    FieldSpec { name: "hour", min: 0, max: 23 },
319    FieldSpec { name: "day-of-month", min: 1, max: 31 },
320    FieldSpec { name: "month", min: 1, max: 12 },
321    FieldSpec { name: "day-of-week", min: 0, max: 7 },
322];
323
324/// Map month/day-of-week names to numbers. Returns `None` if not a name.
325fn name_to_number(s: &str, field_index: usize) -> Option<u8> {
326    let upper = s.to_ascii_uppercase();
327    if field_index == 3 {
328        // month
329        match upper.as_str() {
330            "JAN" => Some(1),
331            "FEB" => Some(2),
332            "MAR" => Some(3),
333            "APR" => Some(4),
334            "MAY" => Some(5),
335            "JUN" => Some(6),
336            "JUL" => Some(7),
337            "AUG" => Some(8),
338            "SEP" => Some(9),
339            "OCT" => Some(10),
340            "NOV" => Some(11),
341            "DEC" => Some(12),
342            _ => None,
343        }
344    } else if field_index == 4 {
345        // day-of-week
346        match upper.as_str() {
347            "SUN" => Some(0),
348            "MON" => Some(1),
349            "TUE" => Some(2),
350            "WED" => Some(3),
351            "THU" => Some(4),
352            "FRI" => Some(5),
353            "SAT" => Some(6),
354            _ => None,
355        }
356    } else {
357        None
358    }
359}
360
361fn parse_single_value(s: &str, field_index: usize, spec: &FieldSpec) -> Result<u8, ParseError> {
362    if let Some(v) = name_to_number(s, field_index) {
363        return Ok(v);
364    }
365    let v: u8 = s.parse().map_err(|_| ParseError::InvalidField {
366        field: spec.name.to_string(),
367        value: s.to_string(),
368    })?;
369    // Normalise day-of-week 7 -> 0
370    let v = if field_index == 4 && v == 7 { 0 } else { v };
371    if v < spec.min || v > spec.max {
372        return Err(ParseError::ValueOutOfRange {
373            field: spec.name.to_string(),
374            value: v,
375            min: spec.min,
376            max: if field_index == 4 { 6 } else { spec.max },
377        });
378    }
379    Ok(v)
380}
381
382fn parse_field(token: &str, field_index: usize) -> Result<CronField, ParseError> {
383    let spec = &FIELD_SPECS[field_index];
384    let mut all_values: Vec<u8> = Vec::new();
385
386    for part in token.split(',') {
387        // Check for step: e.g. */15 or 1-5/2
388        let (range_part, step) = if let Some(pos) = part.find('/') {
389            let step_str = &part[pos + 1..];
390            let step: u8 = step_str.parse().map_err(|_| ParseError::InvalidField {
391                field: spec.name.to_string(),
392                value: part.to_string(),
393            })?;
394            if step == 0 {
395                return Err(ParseError::InvalidField {
396                    field: spec.name.to_string(),
397                    value: part.to_string(),
398                });
399            }
400            (&part[..pos], Some(step))
401        } else {
402            (part, None)
403        };
404
405        let (start, end) = if range_part == "*" {
406            (spec.min, if field_index == 4 { 6 } else { spec.max })
407        } else if let Some(dash) = range_part.find('-') {
408            let s = parse_single_value(&range_part[..dash], field_index, spec)?;
409            let e = parse_single_value(&range_part[dash + 1..], field_index, spec)?;
410            (s, e)
411        } else {
412            let v = parse_single_value(range_part, field_index, spec)?;
413            (v, v)
414        };
415
416        if let Some(step) = step {
417            let mut v = start;
418            loop {
419                if v > end {
420                    break;
421                }
422                all_values.push(v);
423                v = v.saturating_add(step);
424            }
425        } else if start <= end {
426            for v in start..=end {
427                all_values.push(v);
428            }
429        } else {
430            // Wrap-around range for day-of-week (e.g. 5-1 means FRI,SAT,SUN,MON)
431            let upper = if field_index == 4 { 6 } else { spec.max };
432            for v in start..=upper {
433                all_values.push(v);
434            }
435            for v in spec.min..=end {
436                all_values.push(v);
437            }
438        }
439    }
440
441    all_values.sort();
442    all_values.dedup();
443
444    Ok(CronField { values: all_values })
445}
446
447// ---------------------------------------------------------------------------
448// CronExpr
449// ---------------------------------------------------------------------------
450
451/// A parsed cron expression.
452///
453/// Use [`CronExpr::parse`] to create one from a string.
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct CronExpr {
456    minute: CronField,
457    hour: CronField,
458    day_of_month: CronField,
459    month: CronField,
460    day_of_week: CronField,
461    /// The original expression string (for describe).
462    raw: String,
463}
464
465impl CronExpr {
466    /// Parse a 5-field cron expression or an alias.
467    ///
468    /// # Examples
469    ///
470    /// ```
471    /// use philiprehberger_cron_parser::CronExpr;
472    ///
473    /// let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
474    /// let hourly = CronExpr::parse("@hourly").unwrap();
475    /// ```
476    pub fn parse(expr: &str) -> Result<CronExpr, ParseError> {
477        let trimmed = expr.trim();
478
479        // Handle aliases
480        if trimmed.starts_with('@') {
481            let alias = trimmed.to_ascii_lowercase();
482            let expanded = match alias.as_str() {
483                "@hourly" => "0 * * * *",
484                "@daily" | "@midnight" => "0 0 * * *",
485                "@weekly" => "0 0 * * 0",
486                "@monthly" => "0 0 1 * *",
487                "@yearly" | "@annually" => "0 0 1 1 *",
488                _ => return Err(ParseError::InvalidAlias(trimmed.to_string())),
489            };
490            return Self::parse_fields(expanded, trimmed);
491        }
492
493        Self::parse_fields(trimmed, trimmed)
494    }
495
496    fn parse_fields(fields_str: &str, raw: &str) -> Result<CronExpr, ParseError> {
497        let tokens: Vec<&str> = fields_str.split_whitespace().collect();
498        if tokens.len() != 5 {
499            return Err(ParseError::InvalidFieldCount);
500        }
501
502        let minute = parse_field(tokens[0], 0)?;
503        let hour = parse_field(tokens[1], 1)?;
504        let day_of_month = parse_field(tokens[2], 2)?;
505        let month = parse_field(tokens[3], 3)?;
506        let day_of_week = parse_field(tokens[4], 4)?;
507
508        Ok(CronExpr {
509            minute,
510            hour,
511            day_of_month,
512            month,
513            day_of_week,
514            raw: raw.to_string(),
515        })
516    }
517
518    /// Returns `true` if the given datetime matches this cron expression.
519    ///
520    /// Only minute, hour, day, month, and day-of-week are checked; seconds are ignored.
521    ///
522    /// # Examples
523    ///
524    /// ```
525    /// use philiprehberger_cron_parser::{CronExpr, DateTime};
526    ///
527    /// let expr = CronExpr::parse("0 9 * * *").unwrap();
528    /// let dt = DateTime { year: 2026, month: 3, day: 15, hour: 9, minute: 0, second: 0 };
529    /// assert!(expr.matches(&dt));
530    /// ```
531    #[must_use]
532    pub fn matches(&self, dt: &DateTime) -> bool {
533        self.minute.contains(dt.minute)
534            && self.hour.contains(dt.hour)
535            && self.day_of_month.contains(dt.day)
536            && self.month.contains(dt.month)
537            && self.day_of_week.contains(dt.day_of_week())
538    }
539
540    /// Find the next datetime that matches this cron expression, strictly after `dt`.
541    ///
542    /// Returns `None` if no match is found within ~4 years of searching.
543    ///
544    /// # Examples
545    ///
546    /// ```
547    /// use philiprehberger_cron_parser::{CronExpr, DateTime};
548    ///
549    /// let expr = CronExpr::parse("0 9 * * *").unwrap();
550    /// let dt = DateTime { year: 2026, month: 3, day: 15, hour: 10, minute: 0, second: 0 };
551    /// let next = expr.next_from(&dt).unwrap();
552    /// assert_eq!(next.day, 16);
553    /// assert_eq!(next.hour, 9);
554    /// assert_eq!(next.minute, 0);
555    /// ```
556    #[must_use]
557    pub fn next_from(&self, dt: &DateTime) -> Option<DateTime> {
558        // Start from the next minute after dt
559        let mut candidate = DateTime {
560            year: dt.year,
561            month: dt.month,
562            day: dt.day,
563            hour: dt.hour,
564            minute: dt.minute,
565            second: 0,
566        }
567        .next_minute();
568
569        // Safety limit: don't search more than ~4 years of minutes
570        let max_iterations = 4 * 366 * 24 * 60;
571        for _ in 0..max_iterations {
572            // Fast-skip: if month doesn't match, jump to next month
573            if !self.month.contains(candidate.month) {
574                candidate = DateTime {
575                    year: candidate.year,
576                    month: candidate.month,
577                    day: 1,
578                    hour: 0,
579                    minute: 0,
580                    second: 0,
581                };
582                // Advance to next month
583                candidate.month += 1;
584                if candidate.month > 12 {
585                    candidate.month = 1;
586                    candidate.year += 1;
587                }
588                continue;
589            }
590
591            // Fast-skip: if day doesn't match both day-of-month and day-of-week
592            if !self.day_of_month.contains(candidate.day)
593                || !self.day_of_week.contains(candidate.day_of_week())
594            {
595                // Advance to next day
596                candidate = DateTime {
597                    year: candidate.year,
598                    month: candidate.month,
599                    day: candidate.day,
600                    hour: 23,
601                    minute: 59,
602                    second: 0,
603                }
604                .next_minute();
605                continue;
606            }
607
608            // Fast-skip: if hour doesn't match
609            if !self.hour.contains(candidate.hour) {
610                candidate = DateTime {
611                    year: candidate.year,
612                    month: candidate.month,
613                    day: candidate.day,
614                    hour: candidate.hour,
615                    minute: 59,
616                    second: 0,
617                }
618                .next_minute();
619                continue;
620            }
621
622            if self.matches(&candidate) {
623                return Some(candidate);
624            }
625
626            candidate = candidate.next_minute();
627        }
628
629        None
630    }
631
632    /// Returns the next `n` matching datetimes after `dt`.
633    ///
634    /// # Examples
635    ///
636    /// ```
637    /// use philiprehberger_cron_parser::{CronExpr, DateTime};
638    ///
639    /// let expr = CronExpr::parse("0 * * * *").unwrap();
640    /// let dt = DateTime { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
641    /// let times = expr.next_n_from(&dt, 3);
642    /// assert_eq!(times.len(), 3);
643    /// ```
644    #[must_use]
645    pub fn next_n_from(&self, dt: &DateTime, n: usize) -> Vec<DateTime> {
646        let mut results = Vec::with_capacity(n);
647        let mut current = *dt;
648        for _ in 0..n {
649            match self.next_from(&current) {
650                Some(next) => {
651                    results.push(next);
652                    current = next;
653                }
654                None => break,
655            }
656        }
657        results
658    }
659
660    /// Returns a human-readable description of the cron expression.
661    ///
662    /// # Examples
663    ///
664    /// ```
665    /// use philiprehberger_cron_parser::CronExpr;
666    ///
667    /// let expr = CronExpr::parse("*/15 * * * *").unwrap();
668    /// assert_eq!(expr.describe(), "Every 15 minutes");
669    /// ```
670    #[must_use]
671    pub fn describe(&self) -> String {
672        // Check for common aliases first
673        let norm = self.raw.trim().to_ascii_lowercase();
674        match norm.as_str() {
675            "@hourly" => return "Every hour".to_string(),
676            "@daily" | "@midnight" => return "Every day at midnight".to_string(),
677            "@weekly" => return "Every Sunday at midnight".to_string(),
678            "@monthly" => return "At midnight on the 1st of every month".to_string(),
679            "@yearly" | "@annually" => return "At midnight on January 1st".to_string(),
680            _ => {}
681        }
682
683        let mut parts: Vec<String> = Vec::new();
684
685        // Describe minute
686        let min_desc = describe_field(&self.minute, 0);
687        // Describe hour
688        let hour_desc = describe_field(&self.hour, 1);
689
690        // Special common patterns
691        let all_minutes = self.minute.values.len() == 60;
692        let all_hours = self.hour.values.len() == 24;
693        let all_days = self.day_of_month.values.len() == 31;
694        let all_months = self.month.values.len() == 12;
695        let all_dow = self.day_of_week.values.len() == 7;
696
697        // "Every N minutes" pattern
698        if all_hours && all_days && all_months && all_dow {
699            if let Some(step) = detect_step(&self.minute, 0, 59) {
700                if self.minute.values[0] == 0 {
701                    if step == 1 {
702                        return "Every minute".to_string();
703                    }
704                    return format!("Every {} minutes", step);
705                }
706            }
707            if all_minutes {
708                return "Every minute".to_string();
709            }
710        }
711
712        // "Every N hours" pattern
713        if all_minutes.not() && self.minute.values.len() == 1 && self.minute.values[0] == 0
714            && all_days && all_months && all_dow
715        {
716            if let Some(step) = detect_step(&self.hour, 0, 23) {
717                if self.hour.values[0] == 0 {
718                    return format!("Every {} hours", step);
719                }
720            }
721            if all_hours {
722                return "Every hour".to_string();
723            }
724        }
725
726        // Time description
727        if self.minute.values.len() == 1 && self.hour.values.len() == 1 {
728            let h = self.hour.values[0];
729            let m = self.minute.values[0];
730            let ampm = if h == 0 {
731                "12:00 AM".to_string()
732            } else if h < 12 {
733                format!("{}:{:02} AM", h, m)
734            } else if h == 12 {
735                format!("12:{:02} PM", m)
736            } else {
737                format!("{}:{:02} PM", h - 12, m)
738            };
739            parts.push(format!("At {}", ampm));
740        } else if self.minute.values.len() == 1 {
741            parts.push(format!("At minute {}", self.minute.values[0]));
742            if !all_hours {
743                parts.push(format!("past {}", hour_desc));
744            }
745        } else {
746            parts.push(min_desc);
747            if !all_hours {
748                parts.push(format!("past {}", hour_desc));
749            }
750        }
751
752        // Day-of-week
753        if !all_dow {
754            let dow_desc = describe_dow(&self.day_of_week);
755            parts.push(dow_desc);
756        }
757
758        // Day-of-month
759        if !all_days {
760            let dom_desc = describe_field(&self.day_of_month, 2);
761            parts.push(format!("on day {}", dom_desc));
762        }
763
764        // Month
765        if !all_months {
766            let month_desc = describe_month_field(&self.month);
767            parts.push(format!("in {}", month_desc));
768        }
769
770        parts.join(", ")
771    }
772}
773
774impl FromStr for CronExpr {
775    type Err = ParseError;
776
777    fn from_str(s: &str) -> Result<Self, Self::Err> {
778        CronExpr::parse(s)
779    }
780}
781
782impl fmt::Display for CronExpr {
783    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
784        write!(f, "{}", self.raw)
785    }
786}
787
788// Helper to detect a step pattern in a field's values.
789fn detect_step(field: &CronField, min: u8, max: u8) -> Option<u8> {
790    if field.values.len() < 2 {
791        return None;
792    }
793    let step = field.values[1] - field.values[0];
794    if step == 0 {
795        return None;
796    }
797    // Verify all values follow the step
798    for i in 1..field.values.len() {
799        if field.values[i] - field.values[i - 1] != step {
800            return None;
801        }
802    }
803    // Verify it covers from min with step
804    let expected_count = ((max - min) / step) + 1;
805    if field.values.len() == expected_count as usize && field.values[0] == min {
806        Some(step)
807    } else {
808        None
809    }
810}
811
812fn describe_field(field: &CronField, field_index: usize) -> String {
813    if field.values.len() == 1 {
814        return field.values[0].to_string();
815    }
816
817    // Check if it's a contiguous range
818    let is_contiguous = field
819        .values
820        .windows(2)
821        .all(|w| w[1] == w[0] + 1);
822
823    if is_contiguous && field.values.len() >= 2 {
824        let start = field.values[0];
825        let end = *field.values.last().unwrap();
826        if field_index == 1 {
827            return format!("hour {} through {}", start, end);
828        }
829        return format!("{} through {}", start, end);
830    }
831
832    // Check for step pattern
833    if let Some(step) = detect_step(field, FIELD_SPECS[field_index].min, FIELD_SPECS[field_index].max) {
834        return format!("every {} values", step);
835    }
836
837    // List
838    let strs: Vec<String> = field.values.iter().map(|v| v.to_string()).collect();
839    strs.join(", ")
840}
841
842fn describe_dow(field: &CronField) -> String {
843    let names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
844
845    // Check if contiguous
846    let is_contiguous = field
847        .values
848        .windows(2)
849        .all(|w| w[1] == w[0] + 1);
850
851    if is_contiguous && field.values.len() >= 2 {
852        let start = names[field.values[0] as usize];
853        let end = names[*field.values.last().unwrap() as usize];
854        return format!("{} through {}", start, end);
855    }
856
857    let day_names: Vec<&str> = field
858        .values
859        .iter()
860        .map(|v| names[*v as usize])
861        .collect();
862
863    if day_names.len() == 1 {
864        format!("on {}", day_names[0])
865    } else {
866        format!("on {}", day_names.join(", "))
867    }
868}
869
870fn describe_month_field(field: &CronField) -> String {
871    let names = [
872        "", "January", "February", "March", "April", "May", "June", "July", "August",
873        "September", "October", "November", "December",
874    ];
875
876    let month_names: Vec<&str> = field
877        .values
878        .iter()
879        .map(|v| names[*v as usize])
880        .collect();
881
882    month_names.join(", ")
883}
884
885trait Not {
886    fn not(&self) -> bool;
887}
888
889impl Not for bool {
890    fn not(&self) -> bool {
891        !*self
892    }
893}
894
895// ---------------------------------------------------------------------------
896// Tests
897// ---------------------------------------------------------------------------
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902
903    // -- DateTime tests --
904
905    #[test]
906    fn test_is_leap_year() {
907        assert!(is_leap_year(2000));
908        assert!(is_leap_year(2024));
909        assert!(!is_leap_year(1900));
910        assert!(!is_leap_year(2023));
911    }
912
913    #[test]
914    fn test_days_in_month() {
915        assert_eq!(days_in_month(2024, 2), 29);
916        assert_eq!(days_in_month(2023, 2), 28);
917        assert_eq!(days_in_month(2023, 1), 31);
918        assert_eq!(days_in_month(2023, 4), 30);
919    }
920
921    #[test]
922    fn test_day_of_week() {
923        // 2026-03-15 is a Sunday
924        assert_eq!(day_of_week(2026, 3, 15), 0);
925        // 2026-03-16 is a Monday
926        assert_eq!(day_of_week(2026, 3, 16), 1);
927        // 1970-01-01 was a Thursday
928        assert_eq!(day_of_week(1970, 1, 1), 4);
929    }
930
931    #[test]
932    fn test_datetime_from_timestamp() {
933        let dt = DateTime::from_timestamp(0);
934        assert_eq!(dt, DateTime { year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0 });
935
936        // 2026-03-15 00:00:00 UTC = 1773532800
937        let dt = DateTime::from_timestamp(1_773_532_800);
938        assert_eq!(dt.year, 2026);
939        assert_eq!(dt.month, 3);
940        assert_eq!(dt.day, 15);
941    }
942
943    #[test]
944    fn test_datetime_next_minute() {
945        let dt = DateTime { year: 2026, month: 12, day: 31, hour: 23, minute: 59, second: 30 };
946        let next = dt.next_minute();
947        assert_eq!(next, DateTime { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 });
948    }
949
950    #[test]
951    fn test_datetime_next_minute_end_of_feb_leap() {
952        let dt = DateTime { year: 2024, month: 2, day: 29, hour: 23, minute: 59, second: 0 };
953        let next = dt.next_minute();
954        assert_eq!(next, DateTime { year: 2024, month: 3, day: 1, hour: 0, minute: 0, second: 0 });
955    }
956
957    #[test]
958    fn test_datetime_display() {
959        let dt = DateTime { year: 2026, month: 3, day: 5, hour: 9, minute: 7, second: 3 };
960        assert_eq!(format!("{}", dt), "2026-03-05T09:07:03");
961    }
962
963    #[test]
964    fn test_datetime_ord() {
965        let a = DateTime { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
966        let b = DateTime { year: 2026, month: 1, day: 1, hour: 0, minute: 1, second: 0 };
967        assert!(a < b);
968    }
969
970    // -- CronExpr parsing tests --
971
972    #[test]
973    fn test_parse_all_wildcards() {
974        let expr = CronExpr::parse("* * * * *").unwrap();
975        assert_eq!(expr.minute.values.len(), 60);
976        assert_eq!(expr.hour.values.len(), 24);
977        assert_eq!(expr.day_of_month.values.len(), 31);
978        assert_eq!(expr.month.values.len(), 12);
979        assert_eq!(expr.day_of_week.values.len(), 7);
980    }
981
982    #[test]
983    fn test_parse_single_values() {
984        let expr = CronExpr::parse("5 9 15 3 1").unwrap();
985        assert_eq!(expr.minute.values, vec![5]);
986        assert_eq!(expr.hour.values, vec![9]);
987        assert_eq!(expr.day_of_month.values, vec![15]);
988        assert_eq!(expr.month.values, vec![3]);
989        assert_eq!(expr.day_of_week.values, vec![1]);
990    }
991
992    #[test]
993    fn test_parse_ranges() {
994        let expr = CronExpr::parse("0-5 9-17 1-15 1-6 1-5").unwrap();
995        assert_eq!(expr.minute.values, vec![0, 1, 2, 3, 4, 5]);
996        assert_eq!(expr.hour.values, vec![9, 10, 11, 12, 13, 14, 15, 16, 17]);
997        assert_eq!(expr.day_of_week.values, vec![1, 2, 3, 4, 5]);
998    }
999
1000    #[test]
1001    fn test_parse_steps() {
1002        let expr = CronExpr::parse("*/15 */6 * * *").unwrap();
1003        assert_eq!(expr.minute.values, vec![0, 15, 30, 45]);
1004        assert_eq!(expr.hour.values, vec![0, 6, 12, 18]);
1005    }
1006
1007    #[test]
1008    fn test_parse_range_with_step() {
1009        let expr = CronExpr::parse("1-10/3 * * * *").unwrap();
1010        assert_eq!(expr.minute.values, vec![1, 4, 7, 10]);
1011    }
1012
1013    #[test]
1014    fn test_parse_lists() {
1015        let expr = CronExpr::parse("0,15,30,45 * * * *").unwrap();
1016        assert_eq!(expr.minute.values, vec![0, 15, 30, 45]);
1017    }
1018
1019    #[test]
1020    fn test_parse_dow_7_is_sunday() {
1021        let expr = CronExpr::parse("0 0 * * 7").unwrap();
1022        assert_eq!(expr.day_of_week.values, vec![0]); // 7 normalised to 0
1023    }
1024
1025    #[test]
1026    fn test_parse_month_names() {
1027        let expr = CronExpr::parse("0 0 1 JAN-MAR *").unwrap();
1028        assert_eq!(expr.month.values, vec![1, 2, 3]);
1029    }
1030
1031    #[test]
1032    fn test_parse_dow_names() {
1033        let expr = CronExpr::parse("0 9 * * MON-FRI").unwrap();
1034        assert_eq!(expr.day_of_week.values, vec![1, 2, 3, 4, 5]);
1035    }
1036
1037    #[test]
1038    fn test_parse_mixed_case_names() {
1039        let expr = CronExpr::parse("0 0 1 jan *").unwrap();
1040        assert_eq!(expr.month.values, vec![1]);
1041        let expr = CronExpr::parse("0 0 * * Mon").unwrap();
1042        assert_eq!(expr.day_of_week.values, vec![1]);
1043    }
1044
1045    // -- Alias tests --
1046
1047    #[test]
1048    fn test_alias_hourly() {
1049        let expr = CronExpr::parse("@hourly").unwrap();
1050        assert_eq!(expr.minute.values, vec![0]);
1051        assert_eq!(expr.hour.values.len(), 24);
1052    }
1053
1054    #[test]
1055    fn test_alias_daily() {
1056        let expr = CronExpr::parse("@daily").unwrap();
1057        assert_eq!(expr.minute.values, vec![0]);
1058        assert_eq!(expr.hour.values, vec![0]);
1059    }
1060
1061    #[test]
1062    fn test_alias_midnight() {
1063        let a = CronExpr::parse("@midnight").unwrap();
1064        let b = CronExpr::parse("@daily").unwrap();
1065        assert_eq!(a.minute, b.minute);
1066        assert_eq!(a.hour, b.hour);
1067    }
1068
1069    #[test]
1070    fn test_alias_weekly() {
1071        let expr = CronExpr::parse("@weekly").unwrap();
1072        assert_eq!(expr.day_of_week.values, vec![0]);
1073    }
1074
1075    #[test]
1076    fn test_alias_monthly() {
1077        let expr = CronExpr::parse("@monthly").unwrap();
1078        assert_eq!(expr.day_of_month.values, vec![1]);
1079    }
1080
1081    #[test]
1082    fn test_alias_yearly() {
1083        let expr = CronExpr::parse("@yearly").unwrap();
1084        assert_eq!(expr.month.values, vec![1]);
1085        assert_eq!(expr.day_of_month.values, vec![1]);
1086    }
1087
1088    #[test]
1089    fn test_alias_annually() {
1090        let a = CronExpr::parse("@annually").unwrap();
1091        let b = CronExpr::parse("@yearly").unwrap();
1092        assert_eq!(a.month, b.month);
1093    }
1094
1095    #[test]
1096    fn test_invalid_alias() {
1097        assert!(matches!(
1098            CronExpr::parse("@bogus"),
1099            Err(ParseError::InvalidAlias(_))
1100        ));
1101    }
1102
1103    // -- Error tests --
1104
1105    #[test]
1106    fn test_invalid_field_count() {
1107        assert!(matches!(
1108            CronExpr::parse("* * *"),
1109            Err(ParseError::InvalidFieldCount)
1110        ));
1111        assert!(matches!(
1112            CronExpr::parse("* * * * * *"),
1113            Err(ParseError::InvalidFieldCount)
1114        ));
1115    }
1116
1117    #[test]
1118    fn test_invalid_field_value() {
1119        assert!(matches!(
1120            CronExpr::parse("abc * * * *"),
1121            Err(ParseError::InvalidField { .. })
1122        ));
1123    }
1124
1125    #[test]
1126    fn test_value_out_of_range() {
1127        assert!(matches!(
1128            CronExpr::parse("60 * * * *"),
1129            Err(ParseError::ValueOutOfRange { .. })
1130        ));
1131        assert!(matches!(
1132            CronExpr::parse("* 25 * * *"),
1133            Err(ParseError::ValueOutOfRange { .. })
1134        ));
1135    }
1136
1137    // -- matches() tests --
1138
1139    #[test]
1140    fn test_matches_every_minute() {
1141        let expr = CronExpr::parse("* * * * *").unwrap();
1142        let dt = DateTime { year: 2026, month: 6, day: 15, hour: 14, minute: 30, second: 0 };
1143        assert!(expr.matches(&dt));
1144    }
1145
1146    #[test]
1147    fn test_matches_specific_time() {
1148        let expr = CronExpr::parse("30 9 * * *").unwrap();
1149        let dt = DateTime { year: 2026, month: 3, day: 15, hour: 9, minute: 30, second: 0 };
1150        assert!(expr.matches(&dt));
1151        let dt2 = DateTime { year: 2026, month: 3, day: 15, hour: 10, minute: 30, second: 0 };
1152        assert!(!expr.matches(&dt2));
1153    }
1154
1155    #[test]
1156    fn test_matches_weekday() {
1157        let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
1158        // 2026-03-16 is Monday
1159        let monday = DateTime { year: 2026, month: 3, day: 16, hour: 9, minute: 0, second: 0 };
1160        assert!(expr.matches(&monday));
1161        // 2026-03-15 is Sunday
1162        let sunday = DateTime { year: 2026, month: 3, day: 15, hour: 9, minute: 0, second: 0 };
1163        assert!(!expr.matches(&sunday));
1164    }
1165
1166    // -- next_from() tests --
1167
1168    #[test]
1169    fn test_next_from_every_minute() {
1170        let expr = CronExpr::parse("* * * * *").unwrap();
1171        let dt = DateTime { year: 2026, month: 3, day: 15, hour: 10, minute: 30, second: 0 };
1172        let next = expr.next_from(&dt).unwrap();
1173        assert_eq!(next.minute, 31);
1174        assert_eq!(next.hour, 10);
1175    }
1176
1177    #[test]
1178    fn test_next_from_weekday_morning() {
1179        let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
1180        // Monday 8am -> should give Monday 9am
1181        let dt = DateTime { year: 2026, month: 3, day: 16, hour: 8, minute: 0, second: 0 };
1182        let next = expr.next_from(&dt).unwrap();
1183        assert_eq!(next, DateTime { year: 2026, month: 3, day: 16, hour: 9, minute: 0, second: 0 });
1184    }
1185
1186    #[test]
1187    fn test_next_from_weekday_after_time() {
1188        let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
1189        // Monday 10am -> should give Tuesday 9am
1190        let dt = DateTime { year: 2026, month: 3, day: 16, hour: 10, minute: 0, second: 0 };
1191        let next = expr.next_from(&dt).unwrap();
1192        assert_eq!(next, DateTime { year: 2026, month: 3, day: 17, hour: 9, minute: 0, second: 0 });
1193    }
1194
1195    #[test]
1196    fn test_next_from_friday_to_monday() {
1197        let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
1198        // Friday 10am -> Monday 9am
1199        let dt = DateTime { year: 2026, month: 3, day: 20, hour: 10, minute: 0, second: 0 };
1200        let next = expr.next_from(&dt).unwrap();
1201        // 2026-03-23 is Monday
1202        assert_eq!(next, DateTime { year: 2026, month: 3, day: 23, hour: 9, minute: 0, second: 0 });
1203    }
1204
1205    #[test]
1206    fn test_next_from_end_of_month() {
1207        let expr = CronExpr::parse("0 0 1 * *").unwrap();
1208        let dt = DateTime { year: 2026, month: 1, day: 31, hour: 12, minute: 0, second: 0 };
1209        let next = expr.next_from(&dt).unwrap();
1210        assert_eq!(next.month, 2);
1211        assert_eq!(next.day, 1);
1212    }
1213
1214    #[test]
1215    fn test_next_from_leap_year_feb_29() {
1216        let expr = CronExpr::parse("0 0 29 2 *").unwrap();
1217        let dt = DateTime { year: 2024, month: 3, day: 1, hour: 0, minute: 0, second: 0 };
1218        let next = expr.next_from(&dt).unwrap();
1219        // Next Feb 29 is 2028
1220        assert_eq!(next.year, 2028);
1221        assert_eq!(next.month, 2);
1222        assert_eq!(next.day, 29);
1223    }
1224
1225    #[test]
1226    fn test_next_from_every_15_minutes() {
1227        let expr = CronExpr::parse("*/15 * * * *").unwrap();
1228        let dt = DateTime { year: 2026, month: 3, day: 15, hour: 10, minute: 3, second: 0 };
1229        let next = expr.next_from(&dt).unwrap();
1230        assert_eq!(next.minute, 15);
1231        assert_eq!(next.hour, 10);
1232    }
1233
1234    // -- next_n_from() tests --
1235
1236    #[test]
1237    fn test_next_n_from_count() {
1238        let expr = CronExpr::parse("0 * * * *").unwrap();
1239        let dt = DateTime { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
1240        let times = expr.next_n_from(&dt, 5);
1241        assert_eq!(times.len(), 5);
1242        assert_eq!(times[0].hour, 1);
1243        assert_eq!(times[1].hour, 2);
1244        assert_eq!(times[4].hour, 5);
1245    }
1246
1247    #[test]
1248    fn test_next_n_from_returns_ordered() {
1249        let expr = CronExpr::parse("*/30 * * * *").unwrap();
1250        let dt = DateTime { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
1251        let times = expr.next_n_from(&dt, 4);
1252        for w in times.windows(2) {
1253            assert!(w[0] < w[1]);
1254        }
1255    }
1256
1257    // -- describe() tests --
1258
1259    #[test]
1260    fn test_describe_every_15_minutes() {
1261        let expr = CronExpr::parse("*/15 * * * *").unwrap();
1262        assert_eq!(expr.describe(), "Every 15 minutes");
1263    }
1264
1265    #[test]
1266    fn test_describe_every_minute() {
1267        let expr = CronExpr::parse("* * * * *").unwrap();
1268        assert_eq!(expr.describe(), "Every minute");
1269    }
1270
1271    #[test]
1272    fn test_describe_at_specific_time_weekdays() {
1273        let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
1274        let desc = expr.describe();
1275        assert!(desc.contains("9:00 AM"), "got: {}", desc);
1276        assert!(desc.contains("Monday through Friday"), "got: {}", desc);
1277    }
1278
1279    #[test]
1280    fn test_describe_monthly() {
1281        let expr = CronExpr::parse("0 9 1 * *").unwrap();
1282        let desc = expr.describe();
1283        assert!(desc.contains("9:00 AM"), "got: {}", desc);
1284        assert!(desc.contains("day"), "got: {}", desc);
1285    }
1286
1287    #[test]
1288    fn test_describe_hourly_alias() {
1289        let expr = CronExpr::parse("@hourly").unwrap();
1290        assert_eq!(expr.describe(), "Every hour");
1291    }
1292
1293    #[test]
1294    fn test_describe_daily_alias() {
1295        let expr = CronExpr::parse("@daily").unwrap();
1296        assert_eq!(expr.describe(), "Every day at midnight");
1297    }
1298
1299    #[test]
1300    fn test_describe_yearly_alias() {
1301        let expr = CronExpr::parse("@yearly").unwrap();
1302        assert_eq!(expr.describe(), "At midnight on January 1st");
1303    }
1304
1305    // -- Edge cases --
1306
1307    #[test]
1308    fn test_end_of_year_rollover() {
1309        let expr = CronExpr::parse("0 0 1 1 *").unwrap();
1310        let dt = DateTime { year: 2026, month: 12, day: 31, hour: 23, minute: 59, second: 0 };
1311        let next = expr.next_from(&dt).unwrap();
1312        assert_eq!(next, DateTime { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 });
1313    }
1314
1315    #[test]
1316    fn test_specific_month_and_dow() {
1317        let expr = CronExpr::parse("0 10 * 6 MON").unwrap();
1318        let dt = DateTime { year: 2026, month: 5, day: 1, hour: 0, minute: 0, second: 0 };
1319        let next = expr.next_from(&dt).unwrap();
1320        assert_eq!(next.month, 6);
1321        assert_eq!(next.day_of_week(), 1); // Monday
1322        assert_eq!(next.hour, 10);
1323        assert_eq!(next.minute, 0);
1324    }
1325
1326    #[test]
1327    fn test_datetime_now_is_reasonable() {
1328        let now = DateTime::now();
1329        assert!(now.year >= 2024);
1330        assert!((1..=12).contains(&now.month));
1331        assert!((1..=31).contains(&now.day));
1332    }
1333
1334    #[test]
1335    fn test_parse_list_with_names() {
1336        let expr = CronExpr::parse("0 0 * * MON,WED,FRI").unwrap();
1337        assert_eq!(expr.day_of_week.values, vec![1, 3, 5]);
1338    }
1339
1340    #[test]
1341    fn test_parse_month_list_names() {
1342        let expr = CronExpr::parse("0 0 1 JAN,JUN,DEC *").unwrap();
1343        assert_eq!(expr.month.values, vec![1, 6, 12]);
1344    }
1345
1346    #[test]
1347    fn test_zero_step_is_error() {
1348        assert!(CronExpr::parse("*/0 * * * *").is_err());
1349    }
1350
1351    #[test]
1352    fn test_parse_error_display() {
1353        let err = ParseError::InvalidFieldCount;
1354        assert_eq!(format!("{}", err), "cron expression must have exactly 5 fields");
1355
1356        let err = ParseError::InvalidAlias("@bogus".to_string());
1357        assert!(format!("{}", err).contains("@bogus"));
1358    }
1359}