#![allow(clippy::module_name_repetitions)]
use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use time::{Date, Month, UtcOffset};
use crate::error::{TixError, render_diagnostics};
pub const MAX_INTERVAL: u32 = 1000;
pub const MAX_EXPRESSION_LEN: usize = 128;
const EXECUTABLE_MARKERS: &[char] = &[';', '`', '$', '|', '&', '\n', '\r', '\0'];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RuleKind {
Daily,
Weekly,
Monthly,
Interval,
CronLike,
}
impl RuleKind {
pub const fn as_str(self) -> &'static str {
match self {
RuleKind::Daily => "daily",
RuleKind::Weekly => "weekly",
RuleKind::Monthly => "monthly",
RuleKind::Interval => "interval",
RuleKind::CronLike => "cron-like",
}
}
const fn allows_unit(self) -> bool {
matches!(self, RuleKind::Interval)
}
const fn allows_expression(self) -> bool {
matches!(self, RuleKind::CronLike)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IntervalUnit {
Days,
Weeks,
Months,
}
impl IntervalUnit {
pub const fn as_str(self) -> &'static str {
match self {
IntervalUnit::Days => "days",
IntervalUnit::Weeks => "weeks",
IntervalUnit::Months => "months",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Timezone {
Fixed(UtcOffset),
Named(String),
Invalid(String),
}
impl Timezone {
pub fn parse(raw: &str) -> Self {
let value = raw.trim();
if value.eq_ignore_ascii_case("utc") || value.eq_ignore_ascii_case("z") {
return Timezone::Fixed(UtcOffset::UTC);
}
if let Some(offset) = parse_offset(value) {
return Timezone::Fixed(offset);
}
if value.starts_with('+') || value.starts_with('-') {
return Timezone::Invalid(raw.to_string());
}
Timezone::Named(raw.to_string())
}
pub const fn is_supported(&self) -> bool {
matches!(self, Timezone::Fixed(_))
}
}
impl fmt::Display for Timezone {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Timezone::Fixed(offset) => {
let (hours, minutes, seconds) = offset.as_hms();
let sign = if hours < 0 || minutes < 0 || seconds < 0 {
'-'
} else {
'+'
};
if seconds == 0 {
write!(
f,
"{sign}{:02}:{:02}",
hours.unsigned_abs(),
minutes.unsigned_abs()
)
} else {
write!(
f,
"{sign}{:02}:{:02}:{:02}",
hours.unsigned_abs(),
minutes.unsigned_abs(),
seconds.unsigned_abs()
)
}
}
Timezone::Named(raw) | Timezone::Invalid(raw) => f.write_str(raw),
}
}
}
impl Serialize for Timezone {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Timezone {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Timezone::parse(&String::deserialize(deserializer)?))
}
}
fn parse_offset(value: &str) -> Option<UtcOffset> {
let bytes = value.as_bytes();
let sign = match bytes.first()? {
b'+' => 1_i8,
b'-' => -1_i8,
_ => return None,
};
let rest = &bytes[1..];
if rest.len() != 5 && rest.len() != 8 {
return None;
}
if rest[2] != b':' {
return None;
}
if rest.len() == 8 && rest[5] != b':' {
return None;
}
let digits: Vec<u8> = rest.iter().copied().filter(|c| *c != b':').collect();
if !digits.iter().all(u8::is_ascii_digit) {
return None;
}
let number =
|pair: usize| -> i8 { (digits[pair] - b'0') as i8 * 10 + (digits[pair + 1] - b'0') as i8 };
let hours = sign * number(0);
let minutes = sign * number(2);
let seconds = if digits.len() == 6 {
sign * number(4)
} else {
0
};
if hours.unsigned_abs() > 23 || minutes.unsigned_abs() > 59 || seconds.unsigned_abs() > 59 {
return None;
}
UtcOffset::from_hms(hours, minutes, seconds).ok()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecurrenceRule {
pub kind: RuleKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<Timezone>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expression: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<IntervalUnit>,
#[serde(flatten)]
pub extensions: BTreeMap<String, serde_yaml::Value>,
}
impl RecurrenceRule {
pub fn new(kind: RuleKind) -> Self {
Self {
kind,
interval: None,
anchor: None,
timezone: None,
expression: None,
unit: None,
extensions: BTreeMap::new(),
}
}
pub fn with_interval(mut self, interval: u32) -> Self {
self.interval = Some(interval);
self
}
pub fn with_anchor(mut self, anchor: impl Into<String>) -> Self {
self.anchor = Some(anchor.into());
self
}
pub fn with_timezone(mut self, timezone: Timezone) -> Self {
self.timezone = Some(timezone);
self
}
pub fn with_expression(mut self, expression: impl Into<String>) -> Self {
self.expression = Some(expression.into());
self
}
pub fn with_unit(mut self, unit: IntervalUnit) -> Self {
self.unit = Some(unit);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleDiagnosticKind {
MissingAnchor,
InvalidAnchor,
InvalidInterval,
MissingInterval,
MissingUnit,
UnexpectedUnit,
UnexpectedExpression,
MissingExpression,
ExpressionTooLong,
ExecutablePayload,
MalformedCronExpression,
InvalidTimezone,
UnsupportedTimezone,
TimezoneMismatch,
CalendarRange,
}
impl RuleDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
RuleDiagnosticKind::MissingAnchor => "missing_anchor",
RuleDiagnosticKind::InvalidAnchor => "invalid_anchor",
RuleDiagnosticKind::InvalidInterval => "invalid_interval",
RuleDiagnosticKind::MissingInterval => "missing_interval",
RuleDiagnosticKind::MissingUnit => "missing_unit",
RuleDiagnosticKind::UnexpectedUnit => "unexpected_unit",
RuleDiagnosticKind::UnexpectedExpression => "unexpected_expression",
RuleDiagnosticKind::MissingExpression => "missing_expression",
RuleDiagnosticKind::ExpressionTooLong => "expression_too_long",
RuleDiagnosticKind::ExecutablePayload => "executable_payload",
RuleDiagnosticKind::MalformedCronExpression => "malformed_cron_expression",
RuleDiagnosticKind::InvalidTimezone => "invalid_timezone",
RuleDiagnosticKind::UnsupportedTimezone => "unsupported_timezone",
RuleDiagnosticKind::TimezoneMismatch => "timezone_mismatch",
RuleDiagnosticKind::CalendarRange => "calendar_range",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleDiagnostic {
pub kind: RuleDiagnosticKind,
pub field: &'static str,
pub value: String,
pub message: String,
}
impl RuleDiagnostic {
fn new(
kind: RuleDiagnosticKind,
field: &'static str,
value: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
field,
value: value.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleError {
pub errors: Vec<RuleDiagnostic>,
}
impl RuleError {
fn new(errors: Vec<RuleDiagnostic>) -> Self {
Self { errors }
}
fn single(error: RuleDiagnostic) -> Self {
Self {
errors: vec![error],
}
}
pub fn diagnostics(&self) -> &[RuleDiagnostic] {
&self.errors
}
pub fn first(&self) -> &RuleDiagnostic {
&self.errors[0]
}
}
impl fmt::Display for RuleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.errors.is_empty() {
return f.write_str("recurrence rule validation failed");
}
f.write_str(&render_diagnostics(
"E-tix-recurrence",
self.errors
.iter()
.map(|e| (e.kind.as_str(), e.message.as_str())),
))
}
}
impl std::error::Error for RuleError {}
impl From<RuleError> for TixError {
fn from(err: RuleError) -> TixError {
TixError::Refused(err.to_string())
}
}
fn missing_anchor(kind: RuleKind) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::MissingAnchor,
"anchor",
"",
format!("anchor: is required for a {} rule", kind.as_str()),
)
}
fn invalid_anchor(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::InvalidAnchor,
"anchor",
value,
format!("anchor: `{value}` is not a valid `YYYY-MM-DD` calendar date"),
)
}
fn invalid_interval(value: u32) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::InvalidInterval,
"interval",
value.to_string(),
format!("interval: must be between 1 and {MAX_INTERVAL} (got {value})"),
)
}
fn missing_interval() -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::MissingInterval,
"interval",
"",
"interval: is required for an interval rule",
)
}
fn missing_unit() -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::MissingUnit,
"unit",
"",
"unit: is required for an interval rule",
)
}
fn unexpected_unit() -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::UnexpectedUnit,
"unit",
"",
"unit: is only allowed on an interval rule",
)
}
fn unexpected_expression() -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::UnexpectedExpression,
"expression",
"",
"expression: is only allowed on a cron-like rule",
)
}
fn missing_expression() -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::MissingExpression,
"expression",
"",
"expression: is required for a cron-like rule",
)
}
fn expression_too_long(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::ExpressionTooLong,
"expression",
value,
format!(
"expression: must be at most {MAX_EXPRESSION_LEN} characters (got {})",
value.len()
),
)
}
fn executable_payload(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::ExecutablePayload,
"expression",
value,
format!(
"expression: `{value}` contains characters that are not part of a cron-like expression"
),
)
}
fn malformed_expression(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::MalformedCronExpression,
"expression",
value,
format!(
"expression: `{value}` is not a supported cron-like expression \
(only @daily, @weekly, and @monthly are accepted; a general parser needs the reviewed dependency)"
),
)
}
fn unsupported_timezone(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::UnsupportedTimezone,
"timezone",
value,
format!(
"timezone: rule `{value}` is a named zone; only fixed UTC offsets are supported \
(daylight-saving needs a reviewed tzdb)"
),
)
}
fn invalid_timezone(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::InvalidTimezone,
"timezone",
value,
format!("timezone: rule `{value}` is not a fixed UTC offset (use ±HH:MM or UTC)"),
)
}
fn context_unsupported_timezone(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::UnsupportedTimezone,
"timezone",
value,
format!(
"timezone: context `{value}` is a named zone; only fixed UTC offsets are supported \
(daylight-saving needs a reviewed tzdb)"
),
)
}
fn context_invalid_timezone(value: &str) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::InvalidTimezone,
"timezone",
value,
format!("timezone: context `{value}` is not a fixed UTC offset (use ±HH:MM or UTC)"),
)
}
fn timezone_mismatch(rule: UtcOffset, context: UtcOffset) -> RuleDiagnostic {
let rule = Timezone::Fixed(rule);
let context = Timezone::Fixed(context);
RuleDiagnostic::new(
RuleDiagnosticKind::TimezoneMismatch,
"timezone",
rule.to_string(),
format!("timezone: rule offset `{rule}` does not match context offset `{context}`"),
)
}
fn calendar_range(anchor: Date, today: Date) -> RuleDiagnostic {
RuleDiagnostic::new(
RuleDiagnosticKind::CalendarRange,
"anchor",
anchor.to_string(),
format!(
"anchor: the next occurrence after `{today}` falls outside the supported calendar range"
),
)
}
impl RecurrenceRule {
pub fn validate(&self) -> Result<(), RuleError> {
let errors = self.diagnostics();
if errors.is_empty() {
Ok(())
} else {
Err(RuleError::new(errors))
}
}
pub fn diagnostics(&self) -> Vec<RuleDiagnostic> {
let mut errors = Vec::new();
match self.anchor.as_deref() {
None => errors.push(missing_anchor(self.kind)),
Some(raw) if parse_date(raw).is_none() => errors.push(invalid_anchor(raw)),
Some(_) => {}
}
if let Some(interval) = self.interval {
if !(1..=MAX_INTERVAL).contains(&interval) {
errors.push(invalid_interval(interval));
}
} else if self.kind == RuleKind::Interval {
errors.push(missing_interval());
}
if self.unit.is_some() && !self.kind.allows_unit() {
errors.push(unexpected_unit());
} else if self.unit.is_none() && self.kind.allows_unit() {
errors.push(missing_unit());
}
self.push_expression_diagnostics(&mut errors);
match &self.timezone {
Some(Timezone::Named(raw)) => errors.push(unsupported_timezone(raw)),
Some(Timezone::Invalid(raw)) => errors.push(invalid_timezone(raw)),
_ => {}
}
errors
}
fn push_expression_diagnostics(&self, errors: &mut Vec<RuleDiagnostic>) {
match self.expression.as_deref() {
Some(raw) if self.kind.allows_expression() => {
if raw.len() > MAX_EXPRESSION_LEN {
errors.push(expression_too_long(raw));
} else if raw.contains(EXECUTABLE_MARKERS) {
errors.push(executable_payload(raw));
} else if let Err(diagnostic) = BoundedCronParser.parse(raw) {
errors.push(diagnostic);
}
}
Some(_) => errors.push(unexpected_expression()),
None if self.kind.allows_expression() => errors.push(missing_expression()),
None => {}
}
}
pub fn anchor_date(&self) -> Result<Date, RuleError> {
let raw = self
.anchor
.as_deref()
.ok_or_else(|| RuleError::single(missing_anchor(self.kind)))?;
parse_date(raw).ok_or_else(|| RuleError::single(invalid_anchor(raw)))
}
pub fn anchor_key(&self) -> Result<DateKey, RuleError> {
Ok(DateKey::from_date(self.anchor_date()?))
}
pub fn summary(&self) -> String {
let interval = self.interval.unwrap_or(1);
match self.kind {
RuleKind::Daily if interval == 1 => "daily".to_string(),
RuleKind::Daily => format!("every {interval} days"),
RuleKind::Weekly if interval == 1 => "weekly".to_string(),
RuleKind::Weekly => format!("every {interval} weeks"),
RuleKind::Monthly if interval == 1 => "monthly".to_string(),
RuleKind::Monthly => format!("every {interval} months"),
RuleKind::Interval => {
let unit = self.unit.map_or("days", IntervalUnit::as_str);
format!("every {interval} {unit}")
}
RuleKind::CronLike => {
format!(
"cron-like `{}`",
self.expression.as_deref().unwrap_or_default()
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleContext {
pub today: Date,
pub timezone: Option<Timezone>,
}
impl RuleContext {
pub const fn on(today: Date) -> Self {
Self {
today,
timezone: None,
}
}
pub fn with_timezone(mut self, timezone: Timezone) -> Self {
self.timezone = Some(timezone);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DateKey(String);
impl DateKey {
pub fn from_date(date: Date) -> Self {
Self(date.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DateKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
enum Schedule {
Days(u32),
Weeks(u32),
Months(u32),
}
impl RecurrenceRule {
pub fn next_occurrence(&self, context: &RuleContext) -> Result<DateKey, RuleError> {
self.validate()?;
self.validate_context(context)?;
let anchor = self.anchor_date()?;
let today = context.today;
let date = match self.schedule()? {
Schedule::Days(step) => next_day_step(anchor, today, step)?,
Schedule::Weeks(step) => next_day_step(anchor, today, step.saturating_mul(7))?,
Schedule::Months(step) => next_month_step(anchor, today, step)?,
};
Ok(DateKey::from_date(date))
}
fn validate_context(&self, context: &RuleContext) -> Result<(), RuleError> {
let mut errors = Vec::new();
match &context.timezone {
Some(Timezone::Named(raw)) => errors.push(context_unsupported_timezone(raw)),
Some(Timezone::Invalid(raw)) => errors.push(context_invalid_timezone(raw)),
_ => {}
}
if let (Some(Timezone::Fixed(rule)), Some(Timezone::Fixed(injected))) =
(self.timezone.as_ref(), context.timezone.as_ref())
&& rule != injected
{
errors.push(timezone_mismatch(*rule, *injected));
}
if errors.is_empty() {
Ok(())
} else {
Err(RuleError::new(errors))
}
}
fn schedule(&self) -> Result<Schedule, RuleError> {
let interval = self.interval.unwrap_or(1);
Ok(match self.kind {
RuleKind::Daily => Schedule::Days(interval),
RuleKind::Weekly => Schedule::Weeks(interval),
RuleKind::Monthly => Schedule::Months(interval),
RuleKind::Interval => match self.unit {
Some(IntervalUnit::Days) => Schedule::Days(interval),
Some(IntervalUnit::Weeks) => Schedule::Weeks(interval),
Some(IntervalUnit::Months) => Schedule::Months(interval),
None => return Err(RuleError::single(missing_unit())),
},
RuleKind::CronLike => match BoundedCronParser
.parse(self.expression.as_deref().unwrap_or_default())
.map_err(RuleError::single)?
{
CronSchedule::Daily => Schedule::Days(1),
CronSchedule::Weekly => Schedule::Weeks(1),
CronSchedule::Monthly => Schedule::Months(1),
},
})
}
}
fn parse_date(value: &str) -> Option<Date> {
if !crate::model::validate_date(value) {
return None;
}
let year: i32 = value.get(0..4)?.parse().ok()?;
let month: u8 = value.get(5..7)?.parse().ok()?;
let day: u8 = value.get(8..10)?.parse().ok()?;
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}
fn next_day_step(anchor: Date, today: Date, step: u32) -> Result<Date, RuleError> {
let anchor_day = i64::from(anchor.to_julian_day());
let today_day = i64::from(today.to_julian_day());
let step = i64::from(step);
let elapsed = today_day - anchor_day;
let next = if elapsed < 0 { 0 } else { elapsed / step + 1 };
let julian = i32::try_from(anchor_day + next * step)
.map_err(|_| RuleError::single(calendar_range(anchor, today)))?;
Date::from_julian_day(julian).map_err(|_| RuleError::single(calendar_range(anchor, today)))
}
fn next_month_step(anchor: Date, today: Date, step: u32) -> Result<Date, RuleError> {
let step = i64::from(step);
let anchor_month = month_index(anchor);
let today_month = month_index(today);
let mut candidate = if today_month <= anchor_month {
anchor_month
} else {
anchor_month + ((today_month - anchor_month) / step) * step
};
loop {
let (year, month) = split_month_index(candidate)
.ok_or_else(|| RuleError::single(calendar_range(anchor, today)))?;
let day = anchor.day().min(month.length(year));
let date = Date::from_calendar_date(year, month, day)
.map_err(|_| RuleError::single(calendar_range(anchor, today)))?;
if date > today {
return Ok(date);
}
candidate += step;
}
}
fn month_index(date: Date) -> i64 {
i64::from(date.year()) * 12 + i64::from(u8::from(date.month())) - 1
}
fn split_month_index(index: i64) -> Option<(i32, Month)> {
let year = i32::try_from(index.div_euclid(12)).ok()?;
let month = u8::try_from(index.rem_euclid(12) + 1).ok()?;
Month::try_from(month).ok().map(|month| (year, month))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CronSchedule {
Daily,
Weekly,
Monthly,
}
pub trait CronParser: fmt::Debug {
fn parse(&self, expression: &str) -> Result<CronSchedule, RuleDiagnostic>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BoundedCronParser;
impl CronParser for BoundedCronParser {
fn parse(&self, expression: &str) -> Result<CronSchedule, RuleDiagnostic> {
match expression.trim().to_ascii_lowercase().as_str() {
"@daily" => Ok(CronSchedule::Daily),
"@weekly" => Ok(CronSchedule::Weekly),
"@monthly" => Ok(CronSchedule::Monthly),
_ => Err(malformed_expression(expression)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u8, day: u8) -> Date {
Date::from_calendar_date(year, Month::try_from(month).expect("1..=12"), day)
.expect("test date is valid")
}
fn weekly(anchor: &str) -> RecurrenceRule {
RecurrenceRule::new(RuleKind::Weekly).with_anchor(anchor)
}
#[test]
fn validate_accepts_every_family() {
let rules = [
RecurrenceRule::new(RuleKind::Daily).with_anchor("2026-01-05"),
weekly("2026-01-05"),
RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-31"),
RecurrenceRule::new(RuleKind::Interval)
.with_anchor("2026-01-05")
.with_interval(2)
.with_unit(IntervalUnit::Weeks),
RecurrenceRule::new(RuleKind::CronLike)
.with_anchor("2026-01-05")
.with_expression("@weekly"),
];
for rule in rules {
assert!(
rule.validate().is_ok(),
"expected {:?} to be valid",
rule.kind
);
}
}
#[test]
fn validate_collects_every_diagnostic_not_just_the_first() {
let rule = RecurrenceRule::new(RuleKind::Interval).with_interval(0);
let errors = rule.validate().expect_err("rule is invalid").errors;
let kinds: Vec<_> = errors.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![
RuleDiagnosticKind::MissingAnchor,
RuleDiagnosticKind::InvalidInterval,
RuleDiagnosticKind::MissingUnit,
]
);
}
#[test]
fn daily_interval_defaults_to_one_day() {
let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("2026-01-05");
let next = rule
.next_occurrence(&RuleContext::on(date(2026, 1, 5)))
.expect("valid rule");
assert_eq!(next.as_str(), "2026-01-06");
}
#[test]
fn anchor_in_the_future_is_the_next_occurrence() {
let next = weekly("2026-03-02")
.next_occurrence(&RuleContext::on(date(2026, 1, 5)))
.expect("valid rule");
assert_eq!(next.as_str(), "2026-03-02");
}
#[test]
fn weekly_interval_steps_by_whole_weeks() {
let rule = weekly("2026-01-05").with_interval(2);
let next = rule
.next_occurrence(&RuleContext::on(date(2026, 1, 12)))
.expect("valid rule");
assert_eq!(next.as_str(), "2026-01-19");
}
#[test]
fn occurrence_on_the_context_date_is_not_the_next_one() {
let next = weekly("2026-01-05")
.next_occurrence(&RuleContext::on(date(2026, 1, 5)))
.expect("valid rule");
assert_eq!(next.as_str(), "2026-01-12");
}
#[test]
fn monthly_step_reanchors_on_the_last_day_of_a_short_month() {
let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-31");
let next = rule
.next_occurrence(&RuleContext::on(date(2026, 1, 31)))
.expect("valid rule");
assert_eq!(next.as_str(), "2026-02-28");
let following = rule
.next_occurrence(&RuleContext::on(date(2026, 2, 28)))
.expect("valid rule");
assert_eq!(following.as_str(), "2026-03-31");
}
#[test]
fn monthly_step_keeps_the_original_day_after_a_clamped_month() {
let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-30");
let next = rule
.next_occurrence(&RuleContext::on(date(2026, 2, 28)))
.expect("valid rule");
assert_eq!(next.as_str(), "2026-03-30");
}
#[test]
fn monthly_leap_day_anchor_clamps_in_a_common_year() {
let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2024-02-29");
let next = rule
.next_occurrence(&RuleContext::on(date(2024, 2, 29)))
.expect("valid rule");
assert_eq!(next.as_str(), "2024-03-29");
let common_year = rule
.next_occurrence(&RuleContext::on(date(2025, 2, 1)))
.expect("valid rule");
assert_eq!(common_year.as_str(), "2025-02-28");
}
#[test]
fn far_anchor_does_not_scan_every_step() {
let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("1970-01-01");
let next = rule
.next_occurrence(&RuleContext::on(date(2026, 1, 5)))
.expect("valid rule");
assert_eq!(next.as_str(), "2026-01-06");
}
#[test]
fn occurrence_beyond_the_calendar_range_is_a_diagnostic_not_a_panic() {
let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("9999-12-31");
let error = rule
.next_occurrence(&RuleContext::on(date(9999, 12, 31)))
.expect_err("occurrence would fall outside the supported range");
assert_eq!(error.first().kind, RuleDiagnosticKind::CalendarRange);
}
#[test]
fn interval_rule_without_unit_is_reported() {
let rule = RecurrenceRule::new(RuleKind::Interval)
.with_anchor("2026-01-05")
.with_interval(3);
assert_eq!(
rule.validate().expect_err("invalid").first().kind,
RuleDiagnosticKind::MissingUnit
);
}
#[test]
fn a_named_zone_on_the_context_is_refused() {
let context =
RuleContext::on(date(2026, 1, 5)).with_timezone(Timezone::parse("Europe/Berlin"));
let error = weekly("2026-01-05")
.next_occurrence(&context)
.expect_err("named context zone is refused");
assert_eq!(error.first().kind, RuleDiagnosticKind::UnsupportedTimezone);
assert_eq!(error.first().value, "Europe/Berlin");
}
#[test]
fn matching_offsets_are_accepted() {
let rule = weekly("2026-01-05").with_timezone(Timezone::parse("+01:00"));
let context = RuleContext::on(date(2026, 1, 12)).with_timezone(Timezone::parse("+01:00"));
assert_eq!(
rule.next_occurrence(&context).expect("valid rule").as_str(),
"2026-01-19"
);
}
#[test]
fn timezone_text_round_trips_through_its_classification() {
assert_eq!(
Timezone::parse("+05:30"),
Timezone::Fixed(UtcOffset::from_hms(5, 30, 0).expect("valid offset"))
);
assert_eq!(Timezone::parse("UTC").to_string(), "+00:00");
assert_eq!(
Timezone::parse("+1:00"),
Timezone::Invalid("+1:00".to_string())
);
assert_eq!(
Timezone::parse("America/New_York"),
Timezone::Named("America/New_York".to_string())
);
assert!(Timezone::parse("+02:00").is_supported());
assert!(!Timezone::parse("America/New_York").is_supported());
}
#[test]
fn summary_renders_each_family() {
assert_eq!(
RecurrenceRule::new(RuleKind::Daily)
.with_anchor("2026-01-05")
.summary(),
"daily"
);
assert_eq!(
weekly("2026-01-05").with_interval(2).summary(),
"every 2 weeks"
);
assert_eq!(
RecurrenceRule::new(RuleKind::Interval)
.with_unit(IntervalUnit::Months)
.with_interval(3)
.summary(),
"every 3 months"
);
assert_eq!(
RecurrenceRule::new(RuleKind::CronLike)
.with_expression("@weekly")
.summary(),
"cron-like `@weekly`"
);
}
#[test]
fn anchor_date_uses_the_crate_calendar_authority() {
let rule = weekly("2026-01-05");
assert_eq!(rule.anchor_date().expect("valid"), date(2026, 1, 5));
assert_eq!(rule.anchor_key().expect("valid").as_str(), "2026-01-05");
let bad = weekly("2026-02-30");
assert_eq!(
bad.anchor_date().expect_err("not a date").first().kind,
RuleDiagnosticKind::InvalidAnchor
);
}
#[test]
fn date_key_orders_chronologically() {
let january = DateKey::from_date(date(2026, 1, 5));
let february = DateKey::from_date(date(2026, 2, 5));
let next_year = DateKey::from_date(date(2027, 1, 5));
assert!(january < february);
assert!(february < next_year);
assert_eq!(january.to_string(), "2026-01-05");
}
#[test]
fn bounded_parser_accepts_the_closed_vocabulary() {
assert_eq!(
BoundedCronParser.parse("@daily").expect("supported"),
CronSchedule::Daily
);
assert_eq!(
BoundedCronParser.parse(" @WEEKLY ").expect("supported"),
CronSchedule::Weekly
);
assert_eq!(
BoundedCronParser.parse("@monthly").expect("supported"),
CronSchedule::Monthly
);
}
#[test]
fn parse_offset_rejects_out_of_range_components() {
assert!(parse_offset("+24:00").is_none());
assert!(parse_offset("+01:60").is_none());
assert!(parse_offset("+00:00:60").is_none());
assert!(parse_offset("+00:0").is_none());
assert!(parse_offset("+01:0a").is_none());
assert_eq!(
parse_offset("+23:59:59"),
Some(UtcOffset::from_hms(23, 59, 59).expect("valid offset"))
);
assert_eq!(
parse_offset("-08:00"),
Some(UtcOffset::from_hms(-8, 0, 0).expect("valid offset"))
);
}
}