1#![allow(clippy::module_name_repetitions)]
50
51use std::collections::BTreeMap;
52use std::fmt;
53
54use serde::{Deserialize, Deserializer, Serialize, Serializer};
55use time::{Date, Month, UtcOffset};
56
57use crate::error::{TixError, render_diagnostics};
58
59pub const MAX_INTERVAL: u32 = 1000;
65
66pub const MAX_EXPRESSION_LEN: usize = 128;
72
73const EXECUTABLE_MARKERS: &[char] = &[';', '`', '$', '|', '&', '\n', '\r', '\0'];
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "kebab-case")]
91pub enum RuleKind {
92 Daily,
94 Weekly,
96 Monthly,
98 Interval,
101 CronLike,
103}
104
105impl RuleKind {
106 pub const fn as_str(self) -> &'static str {
108 match self {
109 RuleKind::Daily => "daily",
110 RuleKind::Weekly => "weekly",
111 RuleKind::Monthly => "monthly",
112 RuleKind::Interval => "interval",
113 RuleKind::CronLike => "cron-like",
114 }
115 }
116
117 const fn allows_unit(self) -> bool {
119 matches!(self, RuleKind::Interval)
120 }
121
122 const fn allows_expression(self) -> bool {
124 matches!(self, RuleKind::CronLike)
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum IntervalUnit {
132 Days,
134 Weeks,
136 Months,
138}
139
140impl IntervalUnit {
141 pub const fn as_str(self) -> &'static str {
143 match self {
144 IntervalUnit::Days => "days",
145 IntervalUnit::Weeks => "weeks",
146 IntervalUnit::Months => "months",
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
160pub enum Timezone {
161 Fixed(UtcOffset),
163 Named(String),
167 Invalid(String),
169}
170
171impl Timezone {
172 pub fn parse(raw: &str) -> Self {
174 let value = raw.trim();
175 if value.eq_ignore_ascii_case("utc") || value.eq_ignore_ascii_case("z") {
176 return Timezone::Fixed(UtcOffset::UTC);
177 }
178 if let Some(offset) = parse_offset(value) {
179 return Timezone::Fixed(offset);
180 }
181 if value.starts_with('+') || value.starts_with('-') {
182 return Timezone::Invalid(raw.to_string());
183 }
184 Timezone::Named(raw.to_string())
185 }
186
187 pub const fn is_supported(&self) -> bool {
189 matches!(self, Timezone::Fixed(_))
190 }
191}
192
193impl fmt::Display for Timezone {
194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 match self {
199 Timezone::Fixed(offset) => {
200 let (hours, minutes, seconds) = offset.as_hms();
201 let sign = if hours < 0 || minutes < 0 || seconds < 0 {
202 '-'
203 } else {
204 '+'
205 };
206 if seconds == 0 {
207 write!(
208 f,
209 "{sign}{:02}:{:02}",
210 hours.unsigned_abs(),
211 minutes.unsigned_abs()
212 )
213 } else {
214 write!(
215 f,
216 "{sign}{:02}:{:02}:{:02}",
217 hours.unsigned_abs(),
218 minutes.unsigned_abs(),
219 seconds.unsigned_abs()
220 )
221 }
222 }
223 Timezone::Named(raw) | Timezone::Invalid(raw) => f.write_str(raw),
224 }
225 }
226}
227
228impl Serialize for Timezone {
229 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
230 serializer.serialize_str(&self.to_string())
231 }
232}
233
234impl<'de> Deserialize<'de> for Timezone {
235 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
236 Ok(Timezone::parse(&String::deserialize(deserializer)?))
237 }
238}
239
240fn parse_offset(value: &str) -> Option<UtcOffset> {
246 let bytes = value.as_bytes();
247 let sign = match bytes.first()? {
248 b'+' => 1_i8,
249 b'-' => -1_i8,
250 _ => return None,
251 };
252 let rest = &bytes[1..];
253 if rest.len() != 5 && rest.len() != 8 {
254 return None;
255 }
256 if rest[2] != b':' {
257 return None;
258 }
259 if rest.len() == 8 && rest[5] != b':' {
260 return None;
261 }
262 let digits: Vec<u8> = rest.iter().copied().filter(|c| *c != b':').collect();
263 if !digits.iter().all(u8::is_ascii_digit) {
264 return None;
265 }
266 let number =
267 |pair: usize| -> i8 { (digits[pair] - b'0') as i8 * 10 + (digits[pair + 1] - b'0') as i8 };
268 let hours = sign * number(0);
269 let minutes = sign * number(2);
270 let seconds = if digits.len() == 6 {
271 sign * number(4)
272 } else {
273 0
274 };
275 if hours.unsigned_abs() > 23 || minutes.unsigned_abs() > 59 || seconds.unsigned_abs() > 59 {
279 return None;
280 }
281 UtcOffset::from_hms(hours, minutes, seconds).ok()
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304pub struct RecurrenceRule {
305 pub kind: RuleKind,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub interval: Option<u32>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub anchor: Option<String>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub timezone: Option<Timezone>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub expression: Option<String>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub unit: Option<IntervalUnit>,
327 #[serde(flatten)]
329 pub extensions: BTreeMap<String, serde_yaml::Value>,
330}
331
332impl RecurrenceRule {
333 pub fn new(kind: RuleKind) -> Self {
335 Self {
336 kind,
337 interval: None,
338 anchor: None,
339 timezone: None,
340 expression: None,
341 unit: None,
342 extensions: BTreeMap::new(),
343 }
344 }
345
346 pub fn with_interval(mut self, interval: u32) -> Self {
348 self.interval = Some(interval);
349 self
350 }
351
352 pub fn with_anchor(mut self, anchor: impl Into<String>) -> Self {
354 self.anchor = Some(anchor.into());
355 self
356 }
357
358 pub fn with_timezone(mut self, timezone: Timezone) -> Self {
360 self.timezone = Some(timezone);
361 self
362 }
363
364 pub fn with_expression(mut self, expression: impl Into<String>) -> Self {
366 self.expression = Some(expression.into());
367 self
368 }
369
370 pub fn with_unit(mut self, unit: IntervalUnit) -> Self {
372 self.unit = Some(unit);
373 self
374 }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub enum RuleDiagnosticKind {
384 MissingAnchor,
386 InvalidAnchor,
388 InvalidInterval,
390 MissingInterval,
392 MissingUnit,
394 UnexpectedUnit,
396 UnexpectedExpression,
398 MissingExpression,
400 ExpressionTooLong,
402 ExecutablePayload,
404 MalformedCronExpression,
406 InvalidTimezone,
408 UnsupportedTimezone,
410 TimezoneMismatch,
412 CalendarRange,
414}
415
416impl RuleDiagnosticKind {
417 pub const fn as_str(self) -> &'static str {
419 match self {
420 RuleDiagnosticKind::MissingAnchor => "missing_anchor",
421 RuleDiagnosticKind::InvalidAnchor => "invalid_anchor",
422 RuleDiagnosticKind::InvalidInterval => "invalid_interval",
423 RuleDiagnosticKind::MissingInterval => "missing_interval",
424 RuleDiagnosticKind::MissingUnit => "missing_unit",
425 RuleDiagnosticKind::UnexpectedUnit => "unexpected_unit",
426 RuleDiagnosticKind::UnexpectedExpression => "unexpected_expression",
427 RuleDiagnosticKind::MissingExpression => "missing_expression",
428 RuleDiagnosticKind::ExpressionTooLong => "expression_too_long",
429 RuleDiagnosticKind::ExecutablePayload => "executable_payload",
430 RuleDiagnosticKind::MalformedCronExpression => "malformed_cron_expression",
431 RuleDiagnosticKind::InvalidTimezone => "invalid_timezone",
432 RuleDiagnosticKind::UnsupportedTimezone => "unsupported_timezone",
433 RuleDiagnosticKind::TimezoneMismatch => "timezone_mismatch",
434 RuleDiagnosticKind::CalendarRange => "calendar_range",
435 }
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct RuleDiagnostic {
447 pub kind: RuleDiagnosticKind,
449 pub field: &'static str,
452 pub value: String,
454 pub message: String,
456}
457
458impl RuleDiagnostic {
459 fn new(
460 kind: RuleDiagnosticKind,
461 field: &'static str,
462 value: impl Into<String>,
463 message: impl Into<String>,
464 ) -> Self {
465 Self {
466 kind,
467 field,
468 value: value.into(),
469 message: message.into(),
470 }
471 }
472}
473
474#[derive(Debug, Clone, PartialEq, Eq)]
476pub struct RuleError {
477 pub errors: Vec<RuleDiagnostic>,
479}
480
481impl RuleError {
482 fn new(errors: Vec<RuleDiagnostic>) -> Self {
484 Self { errors }
485 }
486
487 fn single(error: RuleDiagnostic) -> Self {
489 Self {
490 errors: vec![error],
491 }
492 }
493
494 pub fn diagnostics(&self) -> &[RuleDiagnostic] {
496 &self.errors
497 }
498
499 pub fn first(&self) -> &RuleDiagnostic {
501 &self.errors[0]
502 }
503}
504
505impl fmt::Display for RuleError {
506 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509 if self.errors.is_empty() {
510 return f.write_str("recurrence rule validation failed");
511 }
512 f.write_str(&render_diagnostics(
513 "E-tix-recurrence",
514 self.errors
515 .iter()
516 .map(|e| (e.kind.as_str(), e.message.as_str())),
517 ))
518 }
519}
520
521impl std::error::Error for RuleError {}
522
523impl From<RuleError> for TixError {
526 fn from(err: RuleError) -> TixError {
527 TixError::Refused(err.to_string())
528 }
529}
530
531fn missing_anchor(kind: RuleKind) -> RuleDiagnostic {
532 RuleDiagnostic::new(
533 RuleDiagnosticKind::MissingAnchor,
534 "anchor",
535 "",
536 format!("anchor: is required for a {} rule", kind.as_str()),
537 )
538}
539
540fn invalid_anchor(value: &str) -> RuleDiagnostic {
541 RuleDiagnostic::new(
542 RuleDiagnosticKind::InvalidAnchor,
543 "anchor",
544 value,
545 format!("anchor: `{value}` is not a valid `YYYY-MM-DD` calendar date"),
546 )
547}
548
549fn invalid_interval(value: u32) -> RuleDiagnostic {
550 RuleDiagnostic::new(
551 RuleDiagnosticKind::InvalidInterval,
552 "interval",
553 value.to_string(),
554 format!("interval: must be between 1 and {MAX_INTERVAL} (got {value})"),
555 )
556}
557
558fn missing_interval() -> RuleDiagnostic {
559 RuleDiagnostic::new(
560 RuleDiagnosticKind::MissingInterval,
561 "interval",
562 "",
563 "interval: is required for an interval rule",
564 )
565}
566
567fn missing_unit() -> RuleDiagnostic {
568 RuleDiagnostic::new(
569 RuleDiagnosticKind::MissingUnit,
570 "unit",
571 "",
572 "unit: is required for an interval rule",
573 )
574}
575
576fn unexpected_unit() -> RuleDiagnostic {
577 RuleDiagnostic::new(
578 RuleDiagnosticKind::UnexpectedUnit,
579 "unit",
580 "",
581 "unit: is only allowed on an interval rule",
582 )
583}
584
585fn unexpected_expression() -> RuleDiagnostic {
586 RuleDiagnostic::new(
587 RuleDiagnosticKind::UnexpectedExpression,
588 "expression",
589 "",
590 "expression: is only allowed on a cron-like rule",
591 )
592}
593
594fn missing_expression() -> RuleDiagnostic {
595 RuleDiagnostic::new(
596 RuleDiagnosticKind::MissingExpression,
597 "expression",
598 "",
599 "expression: is required for a cron-like rule",
600 )
601}
602
603fn expression_too_long(value: &str) -> RuleDiagnostic {
604 RuleDiagnostic::new(
605 RuleDiagnosticKind::ExpressionTooLong,
606 "expression",
607 value,
608 format!(
609 "expression: must be at most {MAX_EXPRESSION_LEN} characters (got {})",
610 value.len()
611 ),
612 )
613}
614
615fn executable_payload(value: &str) -> RuleDiagnostic {
616 RuleDiagnostic::new(
617 RuleDiagnosticKind::ExecutablePayload,
618 "expression",
619 value,
620 format!(
621 "expression: `{value}` contains characters that are not part of a cron-like expression"
622 ),
623 )
624}
625
626fn malformed_expression(value: &str) -> RuleDiagnostic {
627 RuleDiagnostic::new(
628 RuleDiagnosticKind::MalformedCronExpression,
629 "expression",
630 value,
631 format!(
632 "expression: `{value}` is not a supported cron-like expression \
633 (only @daily, @weekly, and @monthly are accepted; a general parser needs the reviewed dependency)"
634 ),
635 )
636}
637
638fn unsupported_timezone(value: &str) -> RuleDiagnostic {
639 RuleDiagnostic::new(
640 RuleDiagnosticKind::UnsupportedTimezone,
641 "timezone",
642 value,
643 format!(
644 "timezone: rule `{value}` is a named zone; only fixed UTC offsets are supported \
645 (daylight-saving needs a reviewed tzdb)"
646 ),
647 )
648}
649
650fn invalid_timezone(value: &str) -> RuleDiagnostic {
651 RuleDiagnostic::new(
652 RuleDiagnosticKind::InvalidTimezone,
653 "timezone",
654 value,
655 format!("timezone: rule `{value}` is not a fixed UTC offset (use ±HH:MM or UTC)"),
656 )
657}
658
659fn context_unsupported_timezone(value: &str) -> RuleDiagnostic {
660 RuleDiagnostic::new(
661 RuleDiagnosticKind::UnsupportedTimezone,
662 "timezone",
663 value,
664 format!(
665 "timezone: context `{value}` is a named zone; only fixed UTC offsets are supported \
666 (daylight-saving needs a reviewed tzdb)"
667 ),
668 )
669}
670
671fn context_invalid_timezone(value: &str) -> RuleDiagnostic {
672 RuleDiagnostic::new(
673 RuleDiagnosticKind::InvalidTimezone,
674 "timezone",
675 value,
676 format!("timezone: context `{value}` is not a fixed UTC offset (use ±HH:MM or UTC)"),
677 )
678}
679
680fn timezone_mismatch(rule: UtcOffset, context: UtcOffset) -> RuleDiagnostic {
681 let rule = Timezone::Fixed(rule);
682 let context = Timezone::Fixed(context);
683 RuleDiagnostic::new(
684 RuleDiagnosticKind::TimezoneMismatch,
685 "timezone",
686 rule.to_string(),
687 format!("timezone: rule offset `{rule}` does not match context offset `{context}`"),
688 )
689}
690
691fn calendar_range(anchor: Date, today: Date) -> RuleDiagnostic {
692 RuleDiagnostic::new(
693 RuleDiagnosticKind::CalendarRange,
694 "anchor",
695 anchor.to_string(),
696 format!(
697 "anchor: the next occurrence after `{today}` falls outside the supported calendar range"
698 ),
699 )
700}
701
702impl RecurrenceRule {
703 pub fn validate(&self) -> Result<(), RuleError> {
709 let errors = self.diagnostics();
710 if errors.is_empty() {
711 Ok(())
712 } else {
713 Err(RuleError::new(errors))
714 }
715 }
716
717 pub fn diagnostics(&self) -> Vec<RuleDiagnostic> {
719 let mut errors = Vec::new();
720
721 match self.anchor.as_deref() {
723 None => errors.push(missing_anchor(self.kind)),
724 Some(raw) if parse_date(raw).is_none() => errors.push(invalid_anchor(raw)),
725 Some(_) => {}
726 }
727
728 if let Some(interval) = self.interval {
730 if !(1..=MAX_INTERVAL).contains(&interval) {
731 errors.push(invalid_interval(interval));
732 }
733 } else if self.kind == RuleKind::Interval {
734 errors.push(missing_interval());
735 }
736
737 if self.unit.is_some() && !self.kind.allows_unit() {
739 errors.push(unexpected_unit());
740 } else if self.unit.is_none() && self.kind.allows_unit() {
741 errors.push(missing_unit());
742 }
743
744 self.push_expression_diagnostics(&mut errors);
746
747 match &self.timezone {
749 Some(Timezone::Named(raw)) => errors.push(unsupported_timezone(raw)),
750 Some(Timezone::Invalid(raw)) => errors.push(invalid_timezone(raw)),
751 _ => {}
752 }
753
754 errors
755 }
756
757 fn push_expression_diagnostics(&self, errors: &mut Vec<RuleDiagnostic>) {
760 match self.expression.as_deref() {
761 Some(raw) if self.kind.allows_expression() => {
762 if raw.len() > MAX_EXPRESSION_LEN {
763 errors.push(expression_too_long(raw));
764 } else if raw.contains(EXECUTABLE_MARKERS) {
765 errors.push(executable_payload(raw));
766 } else if let Err(diagnostic) = BoundedCronParser.parse(raw) {
767 errors.push(diagnostic);
768 }
769 }
770 Some(_) => errors.push(unexpected_expression()),
771 None if self.kind.allows_expression() => errors.push(missing_expression()),
772 None => {}
773 }
774 }
775
776 pub fn anchor_date(&self) -> Result<Date, RuleError> {
778 let raw = self
779 .anchor
780 .as_deref()
781 .ok_or_else(|| RuleError::single(missing_anchor(self.kind)))?;
782 parse_date(raw).ok_or_else(|| RuleError::single(invalid_anchor(raw)))
783 }
784
785 pub fn anchor_key(&self) -> Result<DateKey, RuleError> {
787 Ok(DateKey::from_date(self.anchor_date()?))
788 }
789
790 pub fn summary(&self) -> String {
795 let interval = self.interval.unwrap_or(1);
796 match self.kind {
797 RuleKind::Daily if interval == 1 => "daily".to_string(),
798 RuleKind::Daily => format!("every {interval} days"),
799 RuleKind::Weekly if interval == 1 => "weekly".to_string(),
800 RuleKind::Weekly => format!("every {interval} weeks"),
801 RuleKind::Monthly if interval == 1 => "monthly".to_string(),
802 RuleKind::Monthly => format!("every {interval} months"),
803 RuleKind::Interval => {
804 let unit = self.unit.map_or("days", IntervalUnit::as_str);
805 format!("every {interval} {unit}")
806 }
807 RuleKind::CronLike => {
808 format!(
809 "cron-like `{}`",
810 self.expression.as_deref().unwrap_or_default()
811 )
812 }
813 }
814 }
815}
816
817#[derive(Debug, Clone, PartialEq, Eq)]
827pub struct RuleContext {
828 pub today: Date,
830 pub timezone: Option<Timezone>,
833}
834
835impl RuleContext {
836 pub const fn on(today: Date) -> Self {
838 Self {
839 today,
840 timezone: None,
841 }
842 }
843
844 pub fn with_timezone(mut self, timezone: Timezone) -> Self {
846 self.timezone = Some(timezone);
847 self
848 }
849}
850
851#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
857pub struct DateKey(String);
858
859impl DateKey {
860 pub fn from_date(date: Date) -> Self {
862 Self(date.to_string())
863 }
864
865 pub fn as_str(&self) -> &str {
867 &self.0
868 }
869}
870
871impl fmt::Display for DateKey {
872 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
873 f.write_str(&self.0)
874 }
875}
876
877enum Schedule {
879 Days(u32),
881 Weeks(u32),
883 Months(u32),
885}
886
887impl RecurrenceRule {
888 pub fn next_occurrence(&self, context: &RuleContext) -> Result<DateKey, RuleError> {
912 self.validate()?;
913 self.validate_context(context)?;
914 let anchor = self.anchor_date()?;
915 let today = context.today;
916 let date = match self.schedule()? {
917 Schedule::Days(step) => next_day_step(anchor, today, step)?,
918 Schedule::Weeks(step) => next_day_step(anchor, today, step.saturating_mul(7))?,
919 Schedule::Months(step) => next_month_step(anchor, today, step)?,
920 };
921 Ok(DateKey::from_date(date))
922 }
923
924 fn validate_context(&self, context: &RuleContext) -> Result<(), RuleError> {
926 let mut errors = Vec::new();
927 match &context.timezone {
928 Some(Timezone::Named(raw)) => errors.push(context_unsupported_timezone(raw)),
929 Some(Timezone::Invalid(raw)) => errors.push(context_invalid_timezone(raw)),
930 _ => {}
931 }
932 if let (Some(Timezone::Fixed(rule)), Some(Timezone::Fixed(injected))) =
933 (self.timezone.as_ref(), context.timezone.as_ref())
934 && rule != injected
935 {
936 errors.push(timezone_mismatch(*rule, *injected));
937 }
938 if errors.is_empty() {
939 Ok(())
940 } else {
941 Err(RuleError::new(errors))
942 }
943 }
944
945 fn schedule(&self) -> Result<Schedule, RuleError> {
948 let interval = self.interval.unwrap_or(1);
949 Ok(match self.kind {
950 RuleKind::Daily => Schedule::Days(interval),
951 RuleKind::Weekly => Schedule::Weeks(interval),
952 RuleKind::Monthly => Schedule::Months(interval),
953 RuleKind::Interval => match self.unit {
954 Some(IntervalUnit::Days) => Schedule::Days(interval),
955 Some(IntervalUnit::Weeks) => Schedule::Weeks(interval),
956 Some(IntervalUnit::Months) => Schedule::Months(interval),
957 None => return Err(RuleError::single(missing_unit())),
958 },
959 RuleKind::CronLike => match BoundedCronParser
960 .parse(self.expression.as_deref().unwrap_or_default())
961 .map_err(RuleError::single)?
962 {
963 CronSchedule::Daily => Schedule::Days(1),
964 CronSchedule::Weekly => Schedule::Weeks(1),
965 CronSchedule::Monthly => Schedule::Months(1),
966 },
967 })
968 }
969}
970
971fn parse_date(value: &str) -> Option<Date> {
977 if !crate::model::validate_date(value) {
978 return None;
979 }
980 let year: i32 = value.get(0..4)?.parse().ok()?;
981 let month: u8 = value.get(5..7)?.parse().ok()?;
982 let day: u8 = value.get(8..10)?.parse().ok()?;
983 Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
984}
985
986fn next_day_step(anchor: Date, today: Date, step: u32) -> Result<Date, RuleError> {
988 let anchor_day = i64::from(anchor.to_julian_day());
989 let today_day = i64::from(today.to_julian_day());
990 let step = i64::from(step);
991 let elapsed = today_day - anchor_day;
992 let next = if elapsed < 0 { 0 } else { elapsed / step + 1 };
993 let julian = i32::try_from(anchor_day + next * step)
994 .map_err(|_| RuleError::single(calendar_range(anchor, today)))?;
995 Date::from_julian_day(julian).map_err(|_| RuleError::single(calendar_range(anchor, today)))
996}
997
998fn next_month_step(anchor: Date, today: Date, step: u32) -> Result<Date, RuleError> {
1001 let step = i64::from(step);
1002 let anchor_month = month_index(anchor);
1003 let today_month = month_index(today);
1004 let mut candidate = if today_month <= anchor_month {
1005 anchor_month
1006 } else {
1007 anchor_month + ((today_month - anchor_month) / step) * step
1008 };
1009 loop {
1010 let (year, month) = split_month_index(candidate)
1011 .ok_or_else(|| RuleError::single(calendar_range(anchor, today)))?;
1012 let day = anchor.day().min(month.length(year));
1013 let date = Date::from_calendar_date(year, month, day)
1014 .map_err(|_| RuleError::single(calendar_range(anchor, today)))?;
1015 if date > today {
1016 return Ok(date);
1017 }
1018 candidate += step;
1019 }
1020}
1021
1022fn month_index(date: Date) -> i64 {
1024 i64::from(date.year()) * 12 + i64::from(u8::from(date.month())) - 1
1025}
1026
1027fn split_month_index(index: i64) -> Option<(i32, Month)> {
1029 let year = i32::try_from(index.div_euclid(12)).ok()?;
1030 let month = u8::try_from(index.rem_euclid(12) + 1).ok()?;
1031 Month::try_from(month).ok().map(|month| (year, month))
1032}
1033
1034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1043pub enum CronSchedule {
1044 Daily,
1046 Weekly,
1048 Monthly,
1050}
1051
1052pub trait CronParser: fmt::Debug {
1060 fn parse(&self, expression: &str) -> Result<CronSchedule, RuleDiagnostic>;
1062}
1063
1064#[derive(Debug, Clone, Copy, Default)]
1073pub struct BoundedCronParser;
1074
1075impl CronParser for BoundedCronParser {
1076 fn parse(&self, expression: &str) -> Result<CronSchedule, RuleDiagnostic> {
1077 match expression.trim().to_ascii_lowercase().as_str() {
1078 "@daily" => Ok(CronSchedule::Daily),
1079 "@weekly" => Ok(CronSchedule::Weekly),
1080 "@monthly" => Ok(CronSchedule::Monthly),
1081 _ => Err(malformed_expression(expression)),
1082 }
1083 }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 #[allow(clippy::wildcard_imports)]
1089 use super::*;
1090
1091 fn date(year: i32, month: u8, day: u8) -> Date {
1092 Date::from_calendar_date(year, Month::try_from(month).expect("1..=12"), day)
1093 .expect("test date is valid")
1094 }
1095
1096 fn weekly(anchor: &str) -> RecurrenceRule {
1097 RecurrenceRule::new(RuleKind::Weekly).with_anchor(anchor)
1098 }
1099
1100 #[test]
1101 fn validate_accepts_every_family() {
1102 let rules = [
1103 RecurrenceRule::new(RuleKind::Daily).with_anchor("2026-01-05"),
1104 weekly("2026-01-05"),
1105 RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-31"),
1106 RecurrenceRule::new(RuleKind::Interval)
1107 .with_anchor("2026-01-05")
1108 .with_interval(2)
1109 .with_unit(IntervalUnit::Weeks),
1110 RecurrenceRule::new(RuleKind::CronLike)
1111 .with_anchor("2026-01-05")
1112 .with_expression("@weekly"),
1113 ];
1114 for rule in rules {
1115 assert!(
1116 rule.validate().is_ok(),
1117 "expected {:?} to be valid",
1118 rule.kind
1119 );
1120 }
1121 }
1122
1123 #[test]
1124 fn validate_collects_every_diagnostic_not_just_the_first() {
1125 let rule = RecurrenceRule::new(RuleKind::Interval).with_interval(0);
1126 let errors = rule.validate().expect_err("rule is invalid").errors;
1127 let kinds: Vec<_> = errors.iter().map(|e| e.kind).collect();
1128 assert_eq!(
1129 kinds,
1130 vec![
1131 RuleDiagnosticKind::MissingAnchor,
1132 RuleDiagnosticKind::InvalidInterval,
1133 RuleDiagnosticKind::MissingUnit,
1134 ]
1135 );
1136 }
1137
1138 #[test]
1139 fn daily_interval_defaults_to_one_day() {
1140 let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("2026-01-05");
1141 let next = rule
1142 .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1143 .expect("valid rule");
1144 assert_eq!(next.as_str(), "2026-01-06");
1145 }
1146
1147 #[test]
1148 fn anchor_in_the_future_is_the_next_occurrence() {
1149 let next = weekly("2026-03-02")
1150 .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1151 .expect("valid rule");
1152 assert_eq!(next.as_str(), "2026-03-02");
1153 }
1154
1155 #[test]
1156 fn weekly_interval_steps_by_whole_weeks() {
1157 let rule = weekly("2026-01-05").with_interval(2);
1158 let next = rule
1159 .next_occurrence(&RuleContext::on(date(2026, 1, 12)))
1160 .expect("valid rule");
1161 assert_eq!(next.as_str(), "2026-01-19");
1162 }
1163
1164 #[test]
1165 fn occurrence_on_the_context_date_is_not_the_next_one() {
1166 let next = weekly("2026-01-05")
1167 .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1168 .expect("valid rule");
1169 assert_eq!(next.as_str(), "2026-01-12");
1170 }
1171
1172 #[test]
1173 fn monthly_step_reanchors_on_the_last_day_of_a_short_month() {
1174 let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-31");
1175 let next = rule
1176 .next_occurrence(&RuleContext::on(date(2026, 1, 31)))
1177 .expect("valid rule");
1178 assert_eq!(next.as_str(), "2026-02-28");
1179 let following = rule
1180 .next_occurrence(&RuleContext::on(date(2026, 2, 28)))
1181 .expect("valid rule");
1182 assert_eq!(following.as_str(), "2026-03-31");
1183 }
1184
1185 #[test]
1186 fn monthly_step_keeps_the_original_day_after_a_clamped_month() {
1187 let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-30");
1188 let next = rule
1189 .next_occurrence(&RuleContext::on(date(2026, 2, 28)))
1190 .expect("valid rule");
1191 assert_eq!(next.as_str(), "2026-03-30");
1192 }
1193
1194 #[test]
1195 fn monthly_leap_day_anchor_clamps_in_a_common_year() {
1196 let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2024-02-29");
1197 let next = rule
1198 .next_occurrence(&RuleContext::on(date(2024, 2, 29)))
1199 .expect("valid rule");
1200 assert_eq!(next.as_str(), "2024-03-29");
1201 let common_year = rule
1202 .next_occurrence(&RuleContext::on(date(2025, 2, 1)))
1203 .expect("valid rule");
1204 assert_eq!(common_year.as_str(), "2025-02-28");
1205 }
1206
1207 #[test]
1208 fn far_anchor_does_not_scan_every_step() {
1209 let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("1970-01-01");
1210 let next = rule
1211 .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1212 .expect("valid rule");
1213 assert_eq!(next.as_str(), "2026-01-06");
1214 }
1215
1216 #[test]
1217 fn occurrence_beyond_the_calendar_range_is_a_diagnostic_not_a_panic() {
1218 let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("9999-12-31");
1219 let error = rule
1220 .next_occurrence(&RuleContext::on(date(9999, 12, 31)))
1221 .expect_err("occurrence would fall outside the supported range");
1222 assert_eq!(error.first().kind, RuleDiagnosticKind::CalendarRange);
1223 }
1224
1225 #[test]
1226 fn interval_rule_without_unit_is_reported() {
1227 let rule = RecurrenceRule::new(RuleKind::Interval)
1228 .with_anchor("2026-01-05")
1229 .with_interval(3);
1230 assert_eq!(
1231 rule.validate().expect_err("invalid").first().kind,
1232 RuleDiagnosticKind::MissingUnit
1233 );
1234 }
1235
1236 #[test]
1237 fn a_named_zone_on_the_context_is_refused() {
1238 let context =
1239 RuleContext::on(date(2026, 1, 5)).with_timezone(Timezone::parse("Europe/Berlin"));
1240 let error = weekly("2026-01-05")
1241 .next_occurrence(&context)
1242 .expect_err("named context zone is refused");
1243 assert_eq!(error.first().kind, RuleDiagnosticKind::UnsupportedTimezone);
1244 assert_eq!(error.first().value, "Europe/Berlin");
1245 }
1246
1247 #[test]
1248 fn matching_offsets_are_accepted() {
1249 let rule = weekly("2026-01-05").with_timezone(Timezone::parse("+01:00"));
1250 let context = RuleContext::on(date(2026, 1, 12)).with_timezone(Timezone::parse("+01:00"));
1251 assert_eq!(
1252 rule.next_occurrence(&context).expect("valid rule").as_str(),
1253 "2026-01-19"
1254 );
1255 }
1256
1257 #[test]
1258 fn timezone_text_round_trips_through_its_classification() {
1259 assert_eq!(
1260 Timezone::parse("+05:30"),
1261 Timezone::Fixed(UtcOffset::from_hms(5, 30, 0).expect("valid offset"))
1262 );
1263 assert_eq!(Timezone::parse("UTC").to_string(), "+00:00");
1264 assert_eq!(
1265 Timezone::parse("+1:00"),
1266 Timezone::Invalid("+1:00".to_string())
1267 );
1268 assert_eq!(
1269 Timezone::parse("America/New_York"),
1270 Timezone::Named("America/New_York".to_string())
1271 );
1272 assert!(Timezone::parse("+02:00").is_supported());
1273 assert!(!Timezone::parse("America/New_York").is_supported());
1274 }
1275
1276 #[test]
1277 fn summary_renders_each_family() {
1278 assert_eq!(
1279 RecurrenceRule::new(RuleKind::Daily)
1280 .with_anchor("2026-01-05")
1281 .summary(),
1282 "daily"
1283 );
1284 assert_eq!(
1285 weekly("2026-01-05").with_interval(2).summary(),
1286 "every 2 weeks"
1287 );
1288 assert_eq!(
1289 RecurrenceRule::new(RuleKind::Interval)
1290 .with_unit(IntervalUnit::Months)
1291 .with_interval(3)
1292 .summary(),
1293 "every 3 months"
1294 );
1295 assert_eq!(
1296 RecurrenceRule::new(RuleKind::CronLike)
1297 .with_expression("@weekly")
1298 .summary(),
1299 "cron-like `@weekly`"
1300 );
1301 }
1302
1303 #[test]
1304 fn anchor_date_uses_the_crate_calendar_authority() {
1305 let rule = weekly("2026-01-05");
1306 assert_eq!(rule.anchor_date().expect("valid"), date(2026, 1, 5));
1307 assert_eq!(rule.anchor_key().expect("valid").as_str(), "2026-01-05");
1308 let bad = weekly("2026-02-30");
1309 assert_eq!(
1310 bad.anchor_date().expect_err("not a date").first().kind,
1311 RuleDiagnosticKind::InvalidAnchor
1312 );
1313 }
1314
1315 #[test]
1316 fn date_key_orders_chronologically() {
1317 let january = DateKey::from_date(date(2026, 1, 5));
1318 let february = DateKey::from_date(date(2026, 2, 5));
1319 let next_year = DateKey::from_date(date(2027, 1, 5));
1320 assert!(january < february);
1321 assert!(february < next_year);
1322 assert_eq!(january.to_string(), "2026-01-05");
1323 }
1324
1325 #[test]
1326 fn bounded_parser_accepts_the_closed_vocabulary() {
1327 assert_eq!(
1328 BoundedCronParser.parse("@daily").expect("supported"),
1329 CronSchedule::Daily
1330 );
1331 assert_eq!(
1332 BoundedCronParser.parse(" @WEEKLY ").expect("supported"),
1333 CronSchedule::Weekly
1334 );
1335 assert_eq!(
1336 BoundedCronParser.parse("@monthly").expect("supported"),
1337 CronSchedule::Monthly
1338 );
1339 }
1340
1341 #[test]
1342 fn parse_offset_rejects_out_of_range_components() {
1343 assert!(parse_offset("+24:00").is_none());
1344 assert!(parse_offset("+01:60").is_none());
1345 assert!(parse_offset("+00:00:60").is_none());
1346 assert!(parse_offset("+00:0").is_none());
1347 assert!(parse_offset("+01:0a").is_none());
1348 assert_eq!(
1349 parse_offset("+23:59:59"),
1350 Some(UtcOffset::from_hms(23, 59, 59).expect("valid offset"))
1351 );
1352 assert_eq!(
1353 parse_offset("-08:00"),
1354 Some(UtcOffset::from_hms(-8, 0, 0).expect("valid offset"))
1355 );
1356 }
1357}