1use std::fmt;
30use std::str::FromStr;
31use std::time::{SystemTime, UNIX_EPOCH};
32
33#[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
48pub fn is_leap_year(year: i32) -> bool {
50 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
51}
52
53pub 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
78pub 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 ((dow + 7) % 7) as u8
91}
92
93impl DateTime {
94 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 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 let mut days = ts;
114
115 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 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 #[must_use]
150 pub fn to_timestamp(&self) -> i64 {
151 let mut days: i64 = 0;
152
153 for y in 1970..self.year {
155 days += if is_leap_year(y) { 366 } else { 365 };
156 }
157
158 for m in 1..self.month {
160 days += days_in_month(self.year, m) as i64;
161 }
162
163 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum ParseError {
245 InvalidFieldCount,
247 InvalidField {
249 field: String,
250 value: String,
251 },
252 InvalidAlias(String),
254 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#[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
309struct 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
324fn name_to_number(s: &str, field_index: usize) -> Option<u8> {
326 let upper = s.to_ascii_uppercase();
327 if field_index == 3 {
328 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 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 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 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 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#[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 raw: String,
463}
464
465impl CronExpr {
466 pub fn parse(expr: &str) -> Result<CronExpr, ParseError> {
477 let trimmed = expr.trim();
478
479 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 #[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 #[must_use]
557 pub fn next_from(&self, dt: &DateTime) -> Option<DateTime> {
558 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 let max_iterations = 4 * 366 * 24 * 60;
571 for _ in 0..max_iterations {
572 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 candidate.month += 1;
584 if candidate.month > 12 {
585 candidate.month = 1;
586 candidate.year += 1;
587 }
588 continue;
589 }
590
591 if !self.day_of_month.contains(candidate.day)
593 || !self.day_of_week.contains(candidate.day_of_week())
594 {
595 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 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 #[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(¤t) {
650 Some(next) => {
651 results.push(next);
652 current = next;
653 }
654 None => break,
655 }
656 }
657 results
658 }
659
660 #[must_use]
671 pub fn describe(&self) -> String {
672 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 let min_desc = describe_field(&self.minute, 0);
687 let hour_desc = describe_field(&self.hour, 1);
689
690 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 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 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 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 if !all_dow {
754 let dow_desc = describe_dow(&self.day_of_week);
755 parts.push(dow_desc);
756 }
757
758 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 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
788fn 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 for i in 1..field.values.len() {
799 if field.values[i] - field.values[i - 1] != step {
800 return None;
801 }
802 }
803 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 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 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 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 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#[cfg(test)]
900mod tests {
901 use super::*;
902
903 #[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 assert_eq!(day_of_week(2026, 3, 15), 0);
925 assert_eq!(day_of_week(2026, 3, 16), 1);
927 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 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 #[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]); }
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 #[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 #[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 #[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 let monday = DateTime { year: 2026, month: 3, day: 16, hour: 9, minute: 0, second: 0 };
1160 assert!(expr.matches(&monday));
1161 let sunday = DateTime { year: 2026, month: 3, day: 15, hour: 9, minute: 0, second: 0 };
1163 assert!(!expr.matches(&sunday));
1164 }
1165
1166 #[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 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 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 let dt = DateTime { year: 2026, month: 3, day: 20, hour: 10, minute: 0, second: 0 };
1200 let next = expr.next_from(&dt).unwrap();
1201 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 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 #[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 #[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 #[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); 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}