use std::sync::LazyLock;
use jiff::civil::{Date, Time};
use jiff::tz::TimeZone;
use jiff::Zoned;
use crate::ast::*;
use crate::error::ScheduleError;
static EPOCH_MONDAY: LazyLock<Date> = LazyLock::new(|| Date::new(1970, 1, 5).unwrap());
static EPOCH_DATE: LazyLock<Date> = LazyLock::new(|| Date::new(1970, 1, 1).unwrap());
fn resolve_tz(tz: &Option<String>) -> Result<TimeZone, ScheduleError> {
match tz {
Some(name) => TimeZone::get(name)
.map_err(|e| ScheduleError::eval(format!("invalid timezone '{name}': {e}"))),
None => Ok(TimeZone::UTC),
}
}
fn to_time(tod: &TimeOfDay) -> Time {
Time::new(tod.hour as i8, tod.minute as i8, 0, 0).unwrap()
}
fn at_time_on_date(date: Date, time: Time, tz: &TimeZone) -> Result<Zoned, ScheduleError> {
let dt = date.to_datetime(time);
dt.to_zoned(tz.clone())
.map_err(|e| ScheduleError::eval(format!("cannot create zoned datetime: {e}")))
}
fn matches_day_filter(date: Date, filter: &DayFilter) -> bool {
let wd = Weekday::from_jiff(date.weekday());
match filter {
DayFilter::Every => true,
DayFilter::Weekday => matches!(
wd,
Weekday::Monday
| Weekday::Tuesday
| Weekday::Wednesday
| Weekday::Thursday
| Weekday::Friday
),
DayFilter::Weekend => matches!(wd, Weekday::Saturday | Weekday::Sunday),
DayFilter::Days(days) => days.contains(&wd),
}
}
fn last_day_of_month(year: i16, month: i8) -> Date {
if month == 12 {
Date::new(year, 12, 31).unwrap()
} else {
Date::new(year, month + 1, 1).unwrap().yesterday().unwrap()
}
}
fn last_weekday_of_month(year: i16, month: i8) -> Date {
let mut d = last_day_of_month(year, month);
loop {
let wd = d.weekday();
if wd != jiff::civil::Weekday::Saturday && wd != jiff::civil::Weekday::Sunday {
return d;
}
d = d.yesterday().unwrap();
}
}
fn nth_weekday_of_month(year: i16, month: i8, weekday: Weekday, n: u8) -> Option<Date> {
let target_wd = weekday.to_jiff();
let first = Date::new(year, month, 1).ok()?;
let mut d = first;
while d.weekday() != target_wd {
d = d.tomorrow().ok()?;
}
for _ in 1..n {
d = d.checked_add(jiff::Span::new().days(7)).ok()?;
}
if d.month() != month {
None
} else {
Some(d)
}
}
fn last_weekday_in_month(year: i16, month: i8, weekday: Weekday) -> Date {
let target_wd = weekday.to_jiff();
let mut d = last_day_of_month(year, month);
while d.weekday() != target_wd {
d = d.yesterday().unwrap();
}
d
}
fn nearest_weekday(
year: i16,
month: i8,
target_day: u8,
direction: Option<NearestDirection>,
) -> Option<Date> {
let last = last_day_of_month(year, month);
let last_day = last.day() as u8;
if target_day > last_day {
return None;
}
let date = Date::new(year, month, target_day as i8).ok()?;
let wd = date.weekday();
use jiff::civil::Weekday as JiffWd;
match (wd, direction) {
(
JiffWd::Monday
| JiffWd::Tuesday
| JiffWd::Wednesday
| JiffWd::Thursday
| JiffWd::Friday,
_,
) => Some(date),
(JiffWd::Saturday, None) => {
if target_day == 1 {
Some(date.checked_add(jiff::Span::new().days(2)).ok()?)
} else {
Some(date.yesterday().ok()?)
}
}
(JiffWd::Saturday, Some(NearestDirection::Next)) => {
Some(date.checked_add(jiff::Span::new().days(2)).ok()?)
}
(JiffWd::Saturday, Some(NearestDirection::Previous)) => {
Some(date.yesterday().ok()?)
}
(JiffWd::Sunday, None) => {
if target_day >= last_day {
Some(date.checked_add(jiff::Span::new().days(-2)).ok()?)
} else {
Some(date.tomorrow().ok()?)
}
}
(JiffWd::Sunday, Some(NearestDirection::Next)) => {
Some(date.tomorrow().ok()?)
}
(JiffWd::Sunday, Some(NearestDirection::Previous)) => {
Some(date.checked_add(jiff::Span::new().days(-2)).ok()?)
}
}
}
fn weeks_between(a: Date, b: Date) -> i64 {
let span = a.until(b).unwrap();
span.get_days() as i64 / 7
}
fn days_between(a: Date, b: Date) -> i64 {
a.until(b).unwrap().get_days() as i64
}
fn months_between_ym(a: Date, b: Date) -> i64 {
(b.year() as i64 * 12 + b.month() as i64) - (a.year() as i64 * 12 + a.month() as i64)
}
struct ParsedExceptions {
named: Vec<(u8, u8)>, iso_dates: Vec<Date>,
}
impl ParsedExceptions {
fn from_exceptions(exceptions: &[Exception]) -> Self {
let mut named = Vec::new();
let mut iso_dates = Vec::new();
for exc in exceptions {
match exc {
Exception::Named { month, day } => {
named.push((month.number(), *day));
}
Exception::Iso(s) => {
if let Ok(d) = s.parse::<Date>() {
iso_dates.push(d);
}
}
}
}
ParsedExceptions { named, iso_dates }
}
fn is_excepted(&self, date: Date) -> bool {
for &(m, d) in &self.named {
if date.month() == m as i8 && date.day() == d as i8 {
return true;
}
}
for &exc_date in &self.iso_dates {
if date == exc_date {
return true;
}
}
false
}
}
fn matches_during(date: Date, during: &[MonthName]) -> bool {
if during.is_empty() {
return true;
}
let m = date.month() as u8;
during.iter().any(|mn| mn.number() == m)
}
fn next_during_month(date: Date, during: &[MonthName]) -> Date {
let current_month = date.month() as u8;
let mut months: Vec<u8> = during.iter().map(|mn| mn.number()).collect();
months.sort();
for &m in &months {
if m > current_month {
return Date::new(date.year(), m as i8, 1).unwrap();
}
}
Date::new(date.year() + 1, months[0] as i8, 1).unwrap()
}
fn resolve_until(until: &UntilSpec, now: &Zoned) -> Result<Date, ScheduleError> {
match until {
UntilSpec::Iso(s) => s
.parse()
.map_err(|e| ScheduleError::eval(format!("invalid until date '{s}': {e}"))),
UntilSpec::Named { month, day } => {
let year = now.date().year();
for y in [year, year + 1] {
if let Ok(d) = Date::new(y, month.number() as i8, *day as i8) {
if d >= now.date() {
return Ok(d);
}
}
}
Date::new(year + 1, month.number() as i8, *day as i8)
.map_err(|e| ScheduleError::eval(format!("invalid until date: {e}")))
}
}
}
fn time_matches_with_dst(
date: Date,
times: &[TimeOfDay],
tz: &TimeZone,
zdt: &Zoned,
) -> Result<bool, ScheduleError> {
for tod in times {
let t = to_time(tod);
if zdt.time().hour() == t.hour() && zdt.time().minute() == t.minute() {
return Ok(true);
}
let resolved = at_time_on_date(date, t, tz)?;
if resolved.timestamp() == zdt.timestamp() {
return Ok(true);
}
}
Ok(false)
}
fn earliest_future_at_times(
date: Date,
times: &[TimeOfDay],
tz: &TimeZone,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let mut best: Option<Zoned> = None;
for tod in times {
let t = to_time(tod);
let candidate = at_time_on_date(date, t, tz)?;
if candidate > *now {
best = Some(match best {
Some(prev) if candidate < prev => candidate,
Some(prev) => prev,
None => candidate,
});
}
}
Ok(best)
}
pub fn next_from(schedule: &Schedule, now: &Zoned) -> Result<Option<Zoned>, ScheduleError> {
let tz = resolve_tz(&schedule.timezone)?;
let anchor = schedule.anchor;
let until_date = match &schedule.until {
Some(until) => Some(resolve_until(until, now)?),
None => None,
};
let parsed_exceptions = ParsedExceptions::from_exceptions(&schedule.except);
let has_exceptions = !schedule.except.is_empty();
let has_during = !schedule.during.is_empty();
let needs_tz_conversion = until_date.is_some() || has_during || has_exceptions;
let handles_during_internally = matches!(
&schedule.expr,
ScheduleExpr::MonthRepeat {
target: MonthTarget::NearestWeekday {
direction: Some(_),
..
},
..
}
);
let mut current = now.clone();
for _ in 0..1000 {
let candidate = next_expr(&schedule.expr, &tz, &anchor, ¤t, &schedule.during)?;
let candidate = match candidate {
Some(c) => c,
None => return Ok(None),
};
let c_date = if needs_tz_conversion {
Some(candidate.with_time_zone(tz.clone()).date())
} else {
None
};
if let Some(ref until) = until_date {
if c_date.unwrap() > *until {
return Ok(None);
}
}
if has_during
&& !handles_during_internally
&& !matches_during(c_date.unwrap(), &schedule.during)
{
let skip_to = next_during_month(c_date.unwrap(), &schedule.during);
current = at_time_on_date(skip_to, Time::new(0, 0, 0, 0).unwrap(), &tz)?
.checked_add(jiff::Span::new().seconds(-1))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
continue;
}
if has_exceptions && parsed_exceptions.is_excepted(c_date.unwrap()) {
let next_day = c_date
.unwrap()
.tomorrow()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
current = at_time_on_date(next_day, Time::new(0, 0, 0, 0).unwrap(), &tz)?
.checked_add(jiff::Span::new().seconds(-1))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
continue;
}
return Ok(Some(candidate));
}
Ok(None)
}
fn next_expr(
expr: &ScheduleExpr,
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
during: &[MonthName],
) -> Result<Option<Zoned>, ScheduleError> {
match expr {
ScheduleExpr::DayRepeat {
interval,
days,
times,
} => next_day_repeat(*interval, days, times, tz, anchor, now),
ScheduleExpr::IntervalRepeat {
interval,
unit,
from,
to,
day_filter,
} => next_interval_repeat(*interval, *unit, from, to, day_filter, tz, now),
ScheduleExpr::WeekRepeat {
interval,
days,
times,
} => next_week_repeat(*interval, days, times, tz, anchor, now),
ScheduleExpr::MonthRepeat {
interval,
target,
times,
} => next_month_repeat(*interval, target, times, tz, anchor, now, during),
ScheduleExpr::SingleDate { date, times } => next_single_date(date, times, tz, now),
ScheduleExpr::YearRepeat {
interval,
target,
times,
} => next_year_repeat(*interval, target, times, tz, anchor, now),
}
}
pub fn next_n_from(
schedule: &Schedule,
now: &Zoned,
n: usize,
) -> Result<Vec<Zoned>, ScheduleError> {
Occurrences::new(schedule, now.clone()).take(n).collect()
}
pub struct Occurrences<'a> {
schedule: &'a Schedule,
current: Zoned,
}
impl<'a> Occurrences<'a> {
pub fn new(schedule: &'a Schedule, from: Zoned) -> Self {
Self {
schedule,
current: from,
}
}
}
impl Iterator for Occurrences<'_> {
type Item = Result<Zoned, ScheduleError>;
fn next(&mut self) -> Option<Self::Item> {
match next_from(self.schedule, &self.current) {
Ok(Some(dt)) => {
match dt.checked_add(jiff::Span::new().minutes(1)) {
Ok(c) => self.current = c,
Err(e) => return Some(Err(ScheduleError::eval(format!("overflow: {e}")))),
}
Some(Ok(dt))
}
Ok(None) => None, Err(e) => Some(Err(e)),
}
}
}
pub struct BoundedOccurrences<'a> {
inner: Occurrences<'a>,
to: Zoned,
}
impl<'a> BoundedOccurrences<'a> {
pub fn new(schedule: &'a Schedule, from: Zoned, to: Zoned) -> Self {
Self {
inner: Occurrences::new(schedule, from),
to,
}
}
}
impl Iterator for BoundedOccurrences<'_> {
type Item = Result<Zoned, ScheduleError>;
fn next(&mut self) -> Option<Self::Item> {
match self.inner.next() {
Some(Ok(dt)) if dt <= self.to => Some(Ok(dt)),
Some(Ok(_)) => None, Some(Err(e)) => Some(Err(e)),
None => None,
}
}
}
pub fn between<'a>(schedule: &'a Schedule, from: &Zoned, to: &Zoned) -> BoundedOccurrences<'a> {
BoundedOccurrences::new(schedule, from.clone(), to.clone())
}
pub fn matches(schedule: &Schedule, datetime: &Zoned) -> Result<bool, ScheduleError> {
let tz = resolve_tz(&schedule.timezone)?;
let zdt = datetime.with_time_zone(tz.clone());
let date = zdt.date();
if !matches_during(date, &schedule.during) {
return Ok(false);
}
if !schedule.except.is_empty() {
let parsed_exceptions = ParsedExceptions::from_exceptions(&schedule.except);
if parsed_exceptions.is_excepted(date) {
return Ok(false);
}
}
if let Some(ref until) = schedule.until {
let until_date = resolve_until(until, datetime)?;
if date > until_date {
return Ok(false);
}
}
match &schedule.expr {
ScheduleExpr::DayRepeat {
interval,
days,
times,
} => {
if !matches_day_filter(date, days) {
return Ok(false);
}
if !time_matches_with_dst(date, times, &tz, &zdt)? {
return Ok(false);
}
if *interval > 1 {
let anchor_date = schedule.anchor.unwrap_or(*EPOCH_DATE);
let day_offset = days_between(anchor_date, date);
return Ok(day_offset >= 0 && day_offset % (*interval as i64) == 0);
}
Ok(true)
}
ScheduleExpr::IntervalRepeat {
interval,
unit,
from,
to,
day_filter,
} => {
if let Some(df) = day_filter {
if !matches_day_filter(date, df) {
return Ok(false);
}
}
let from_t = to_time(from);
let to_t = to_time(to);
let from_resolved = at_time_on_date(date, from_t, &tz)?;
let to_resolved = at_time_on_date(date, to_t, &tz)?;
let current_secs = zdt.timestamp().as_second();
let from_secs = from_resolved.timestamp().as_second();
let to_secs = to_resolved.timestamp().as_second();
if current_secs < from_secs || current_secs > to_secs {
return Ok(false);
}
let elapsed_secs = current_secs - from_secs;
let step_secs: i64 = match unit {
IntervalUnit::Minutes => *interval as i64 * 60,
IntervalUnit::Hours => *interval as i64 * 3600,
};
Ok(elapsed_secs >= 0 && elapsed_secs % step_secs == 0)
}
ScheduleExpr::WeekRepeat {
interval,
days,
times,
} => {
let wd = Weekday::from_jiff(date.weekday());
if !days.contains(&wd) {
return Ok(false);
}
if !time_matches_with_dst(date, times, &tz, &zdt)? {
return Ok(false);
}
let anchor_date = schedule.anchor.unwrap_or(*EPOCH_MONDAY);
let weeks = weeks_between(anchor_date, date);
Ok(weeks >= 0 && weeks % (*interval as i64) == 0)
}
ScheduleExpr::MonthRepeat {
interval,
target,
times,
} => {
if !time_matches_with_dst(date, times, &tz, &zdt)? {
return Ok(false);
}
if *interval > 1 {
let anchor_date = schedule.anchor.unwrap_or(*EPOCH_DATE);
let month_offset = months_between_ym(anchor_date, date);
if month_offset < 0 || month_offset % (*interval as i64) != 0 {
return Ok(false);
}
}
match target {
MonthTarget::Days(_) => {
let expanded = target.expand_days();
Ok(expanded.contains(&(date.day() as u8)))
}
MonthTarget::LastDay => {
let last = last_day_of_month(date.year(), date.month());
Ok(date == last)
}
MonthTarget::LastWeekday => {
let last_wd = last_weekday_of_month(date.year(), date.month());
Ok(date == last_wd)
}
MonthTarget::NearestWeekday { day, direction } => {
match nearest_weekday(date.year(), date.month(), *day, *direction) {
Some(target_date) => Ok(date == target_date),
None => Ok(false),
}
}
MonthTarget::OrdinalWeekday { ordinal, weekday } => {
let target_date = match ordinal {
OrdinalPosition::Last => {
last_weekday_in_month(date.year(), date.month(), *weekday)
}
_ => {
match ordinal_to_n(*ordinal).and_then(|n| {
nth_weekday_of_month(date.year(), date.month(), *weekday, n)
}) {
Some(d) => d,
None => return Ok(false),
}
}
};
Ok(date == target_date)
}
}
}
ScheduleExpr::SingleDate {
date: date_spec,
times,
} => {
if !time_matches_with_dst(date, times, &tz, &zdt)? {
return Ok(false);
}
match date_spec {
DateSpec::Iso(s) => {
let target: Date = s
.parse()
.map_err(|e| ScheduleError::eval(format!("invalid date '{s}': {e}")))?;
Ok(date == target)
}
DateSpec::Named { month, day } => {
Ok(date.month() == month.number() as i8 && date.day() == *day as i8)
}
}
}
ScheduleExpr::YearRepeat {
interval,
target,
times,
} => {
if !time_matches_with_dst(date, times, &tz, &zdt)? {
return Ok(false);
}
if *interval > 1 {
let anchor_year = schedule.anchor.unwrap_or(*EPOCH_DATE).year();
let year_offset = date.year() as i64 - anchor_year as i64;
if year_offset < 0 || year_offset % (*interval as i64) != 0 {
return Ok(false);
}
}
match target {
YearTarget::Date { month, day } => {
Ok(date.month() == month.number() as i8 && date.day() == *day as i8)
}
YearTarget::OrdinalWeekday {
ordinal,
weekday,
month,
} => {
if date.month() != month.number() as i8 {
return Ok(false);
}
let target_date = match ordinal {
OrdinalPosition::Last => {
last_weekday_in_month(date.year(), date.month(), *weekday)
}
_ => {
match ordinal_to_n(*ordinal).and_then(|n| {
nth_weekday_of_month(date.year(), date.month(), *weekday, n)
}) {
Some(d) => d,
None => return Ok(false),
}
}
};
Ok(date == target_date)
}
YearTarget::DayOfMonth { day, month } => {
Ok(date.month() == month.number() as i8 && date.day() == *day as i8)
}
YearTarget::LastWeekday { month } => {
if date.month() != month.number() as i8 {
return Ok(false);
}
let target_date = last_weekday_of_month(date.year(), date.month());
Ok(date == target_date)
}
}
}
}
}
pub fn previous_from(schedule: &Schedule, now: &Zoned) -> Result<Option<Zoned>, ScheduleError> {
let tz = resolve_tz(&schedule.timezone)?;
let anchor = schedule.anchor;
let starting_date = anchor;
let until_date = match &schedule.until {
Some(until) => Some(resolve_until(until, now)?),
None => None,
};
let parsed_exceptions = ParsedExceptions::from_exceptions(&schedule.except);
let has_exceptions = !schedule.except.is_empty();
let has_during = !schedule.during.is_empty();
let handles_during_internally = matches!(
&schedule.expr,
ScheduleExpr::MonthRepeat {
target: MonthTarget::NearestWeekday {
direction: Some(_),
..
},
..
}
);
let mut current = now.clone();
for _ in 0..1000 {
let candidate = prev_expr(&schedule.expr, &tz, &anchor, ¤t, &schedule.during)?;
let candidate = match candidate {
Some(c) => c,
None => return Ok(None),
};
let c_date = candidate.with_time_zone(tz.clone()).date();
if let Some(start) = starting_date {
if c_date < start {
return Ok(None);
}
if c_date == start {
}
}
if let Some(ref until) = until_date {
if c_date > *until {
current = at_time_on_date(*until, Time::new(23, 59, 59, 0).unwrap(), &tz)?;
continue;
}
}
if has_during && !handles_during_internally && !matches_during(c_date, &schedule.during) {
let skip_to = prev_during_month(c_date, &schedule.during);
current = at_time_on_date(skip_to, Time::new(23, 59, 59, 0).unwrap(), &tz)?
.checked_add(jiff::Span::new().seconds(1))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
continue;
}
if has_exceptions && parsed_exceptions.is_excepted(c_date) {
let prev_day = c_date
.yesterday()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
current = at_time_on_date(prev_day, Time::new(23, 59, 59, 0).unwrap(), &tz)?
.checked_add(jiff::Span::new().seconds(1))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
continue;
}
return Ok(Some(candidate));
}
Ok(None)
}
fn prev_expr(
expr: &ScheduleExpr,
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
during: &[MonthName],
) -> Result<Option<Zoned>, ScheduleError> {
match expr {
ScheduleExpr::DayRepeat {
interval,
days,
times,
} => prev_day_repeat(*interval, days, times, tz, anchor, now),
ScheduleExpr::IntervalRepeat {
interval,
unit,
from,
to,
day_filter,
} => prev_interval_repeat(*interval, *unit, from, to, day_filter, tz, now),
ScheduleExpr::WeekRepeat {
interval,
days,
times,
} => prev_week_repeat(*interval, days, times, tz, anchor, now),
ScheduleExpr::MonthRepeat {
interval,
target,
times,
} => prev_month_repeat(*interval, target, times, tz, anchor, now, during),
ScheduleExpr::SingleDate { date, times } => prev_single_date(date, times, tz, now),
ScheduleExpr::YearRepeat {
interval,
target,
times,
} => prev_year_repeat(*interval, target, times, tz, anchor, now),
}
}
fn prev_during_month(date: Date, during: &[MonthName]) -> Date {
let mut m = date.month();
let mut y = date.year();
if m == 1 {
m = 12;
y -= 1;
} else {
m -= 1;
}
for _ in 0..12 {
if let Some(month_name) = month_number_to_name(m as u8) {
if during.contains(&month_name) {
return last_day_of_month(y, m);
}
}
if m == 1 {
m = 12;
y -= 1;
} else {
m -= 1;
}
}
date.yesterday().unwrap_or(date)
}
fn month_number_to_name(n: u8) -> Option<MonthName> {
match n {
1 => Some(MonthName::January),
2 => Some(MonthName::February),
3 => Some(MonthName::March),
4 => Some(MonthName::April),
5 => Some(MonthName::May),
6 => Some(MonthName::June),
7 => Some(MonthName::July),
8 => Some(MonthName::August),
9 => Some(MonthName::September),
10 => Some(MonthName::October),
11 => Some(MonthName::November),
12 => Some(MonthName::December),
_ => None,
}
}
fn ordinal_to_n(ord: OrdinalPosition) -> Option<u8> {
match ord {
OrdinalPosition::First => Some(1),
OrdinalPosition::Second => Some(2),
OrdinalPosition::Third => Some(3),
OrdinalPosition::Fourth => Some(4),
OrdinalPosition::Fifth => Some(5),
OrdinalPosition::Last => None,
}
}
fn next_day_repeat(
interval: u32,
days: &DayFilter,
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let mut date = now_in_tz.date();
if interval <= 1 {
if matches_day_filter(date, days) {
if let Some(candidate) = earliest_future_at_times(date, times, tz, now)? {
return Ok(Some(candidate));
}
}
for _ in 0..8 {
date = date
.tomorrow()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
if matches_day_filter(date, days) {
if let Some(candidate) = earliest_future_at_times(date, times, tz, now)? {
return Ok(Some(candidate));
}
}
}
return Ok(None);
}
let anchor_date = anchor.unwrap_or(*EPOCH_DATE);
let interval_i64 = interval as i64;
let offset = days_between(anchor_date, date);
let remainder = offset.rem_euclid(interval_i64);
let aligned_date = if remainder == 0 {
date
} else {
date.checked_add(jiff::Span::new().days(interval_i64 - remainder))
.map_err(|e| ScheduleError::eval(format!("{e}")))?
};
let mut cur = aligned_date;
for _ in 0..2 {
if let Some(candidate) = earliest_future_at_times(cur, times, tz, now)? {
return Ok(Some(candidate));
}
cur = cur
.checked_add(jiff::Span::new().days(interval_i64))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
}
Ok(None)
}
fn next_interval_repeat(
interval: u32,
unit: IntervalUnit,
from: &TimeOfDay,
to: &TimeOfDay,
day_filter: &Option<DayFilter>,
tz: &TimeZone,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let from_t = to_time(from);
let to_t = to_time(to);
let step_minutes: i64 = match unit {
IntervalUnit::Minutes => interval as i64,
IntervalUnit::Hours => interval as i64 * 60,
};
let from_minutes = from_t.hour() as i64 * 60 + from_t.minute() as i64;
let to_minutes = to_t.hour() as i64 * 60 + to_t.minute() as i64;
let mut date = now_in_tz.date();
for _ in 0..400 {
if let Some(df) = day_filter {
if !matches_day_filter(date, df) {
date = date
.tomorrow()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
continue;
}
}
let now_minutes = if date == now_in_tz.date() {
now_in_tz.time().hour() as i64 * 60 + now_in_tz.time().minute() as i64
} else {
-1 };
let next_slot = if now_minutes < from_minutes {
from_minutes
} else {
let elapsed = now_minutes - from_minutes;
from_minutes + (elapsed / step_minutes + 1) * step_minutes
};
if next_slot <= to_minutes {
let h = (next_slot / 60) as i8;
let m = (next_slot % 60) as i8;
let t = Time::new(h, m, 0, 0).unwrap();
let candidate = at_time_on_date(date, t, tz)?;
if candidate > *now {
return Ok(Some(candidate));
}
}
date = date
.tomorrow()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
}
Ok(None)
}
fn next_week_repeat(
interval: u32,
days: &[Weekday],
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let anchor_date = anchor.unwrap_or(*EPOCH_MONDAY);
let date = now_in_tz.date();
let mut sorted_days: Vec<Weekday> = days.to_vec();
sorted_days.sort_by_key(|d| d.to_jiff().to_monday_one_offset());
let dow_offset = date.weekday().to_monday_one_offset() as i64 - 1;
let current_monday = date
.checked_add(jiff::Span::new().days(-dow_offset))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
let anchor_dow_offset = anchor_date.weekday().to_monday_one_offset() as i64 - 1;
let anchor_monday = anchor_date
.checked_add(jiff::Span::new().days(-anchor_dow_offset))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
let weeks_since_anchor = weeks_between(anchor_monday, current_monday);
let first_aligned_monday = if weeks_since_anchor < 0 {
anchor_monday
} else {
let remainder = weeks_since_anchor % (interval as i64);
if remainder == 0 {
current_monday
} else {
current_monday
.checked_add(jiff::Span::new().days((interval as i64 - remainder) * 7))
.map_err(|e| ScheduleError::eval(format!("{e}")))?
}
};
let mut cur_monday = first_aligned_monday;
for _ in 0..2 {
for wd in &sorted_days {
let day_offset = wd.to_jiff().to_monday_one_offset() as i64 - 1;
let target_date = cur_monday
.checked_add(jiff::Span::new().days(day_offset))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
if let Some(candidate) = earliest_future_at_times(target_date, times, tz, now)? {
return Ok(Some(candidate));
}
}
let skip_weeks = interval as i64;
cur_monday = cur_monday
.checked_add(jiff::Span::new().days(skip_weeks * 7))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
}
Ok(None)
}
fn next_month_repeat(
interval: u32,
target: &MonthTarget,
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
during: &[MonthName],
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let mut year = now_in_tz.date().year();
let mut month = now_in_tz.date().month();
let anchor_date = anchor.unwrap_or(*EPOCH_DATE);
let max_iter = if interval > 1 {
24 * interval as usize
} else {
24
};
let apply_during_filter = !during.is_empty()
&& matches!(
target,
MonthTarget::NearestWeekday {
direction: Some(_),
..
}
);
for _ in 0..max_iter {
if apply_during_filter && !during.iter().any(|mn| mn.number() == month as u8) {
month += 1;
if month > 12 {
month = 1;
year += 1;
}
continue;
}
if interval > 1 {
let cur = Date::new(year, month, 1).unwrap();
let month_offset = months_between_ym(anchor_date, cur);
if month_offset < 0 || month_offset.rem_euclid(interval as i64) != 0 {
month += 1;
if month > 12 {
month = 1;
year += 1;
}
continue;
}
}
let date_candidates = match target {
MonthTarget::Days(_) => {
let expanded = target.expand_days();
let mut c = Vec::new();
for day_num in expanded {
let last = last_day_of_month(year, month);
if (day_num as i8) <= last.day() {
if let Ok(date) = Date::new(year, month, day_num as i8) {
c.push(date);
}
}
}
c
}
MonthTarget::LastDay => {
vec![last_day_of_month(year, month)]
}
MonthTarget::LastWeekday => {
vec![last_weekday_of_month(year, month)]
}
MonthTarget::NearestWeekday { day, direction } => {
match nearest_weekday(year, month, *day, *direction) {
Some(d) => vec![d],
None => vec![],
}
}
MonthTarget::OrdinalWeekday { ordinal, weekday } => match ordinal {
OrdinalPosition::Last => vec![last_weekday_in_month(year, month, *weekday)],
_ => ordinal_to_n(*ordinal)
.and_then(|n| nth_weekday_of_month(year, month, *weekday, n))
.into_iter()
.collect(),
},
};
let mut best: Option<Zoned> = None;
for date in date_candidates {
if let Some(candidate) = earliest_future_at_times(date, times, tz, now)? {
best = Some(match best {
Some(prev) if candidate < prev => candidate,
Some(prev) => prev,
None => candidate,
});
}
}
if best.is_some() {
return Ok(best);
}
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
Ok(None)
}
fn next_single_date(
date_spec: &DateSpec,
times: &[TimeOfDay],
tz: &TimeZone,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
match date_spec {
DateSpec::Iso(s) => {
let date: Date = s
.parse()
.map_err(|e| ScheduleError::eval(format!("invalid date '{s}': {e}")))?;
earliest_future_at_times(date, times, tz, now)
}
DateSpec::Named { month, day } => {
let start_year = now_in_tz.date().year();
for y in 0..8 {
let year = start_year + y;
if let Ok(date) = Date::new(year, month.number() as i8, *day as i8) {
if let Some(candidate) = earliest_future_at_times(date, times, tz, now)? {
return Ok(Some(candidate));
}
}
}
Ok(None)
}
}
}
fn next_year_repeat(
interval: u32,
target: &YearTarget,
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let start_year = now_in_tz.date().year();
let anchor_year = anchor.unwrap_or(*EPOCH_DATE).year();
let max_iter = if interval > 1 { 8 * interval as i16 } else { 8 };
for y in 0..max_iter {
let year = start_year + y;
if interval > 1 {
let year_offset = (year as i64) - (anchor_year as i64);
if year_offset < 0 || year_offset.rem_euclid(interval as i64) != 0 {
continue;
}
}
let target_date = match target {
YearTarget::Date { month, day } => {
Date::new(year, month.number() as i8, *day as i8).ok()
}
YearTarget::OrdinalWeekday {
ordinal,
weekday,
month,
} => {
let m = month.number() as i8;
match ordinal {
OrdinalPosition::Last => Some(last_weekday_in_month(year, m, *weekday)),
_ => ordinal_to_n(*ordinal)
.and_then(|n| nth_weekday_of_month(year, m, *weekday, n)),
}
}
YearTarget::DayOfMonth { day, month } => {
Date::new(year, month.number() as i8, *day as i8).ok()
}
YearTarget::LastWeekday { month } => {
Some(last_weekday_of_month(year, month.number() as i8))
}
};
if let Some(date) = target_date {
if let Some(candidate) = earliest_future_at_times(date, times, tz, now)? {
return Ok(Some(candidate));
}
}
}
Ok(None)
}
fn prev_day_repeat(
interval: u32,
days: &DayFilter,
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let mut date = now_in_tz.date();
if interval <= 1 {
if matches_day_filter(date, days) {
if let Some(candidate) = latest_past_at_times(date, times, tz, now)? {
return Ok(Some(candidate));
}
}
for _ in 0..8 {
date = date
.yesterday()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
if matches_day_filter(date, days) {
if let Some(candidate) = latest_at_times(date, times, tz)? {
return Ok(Some(candidate));
}
}
}
return Ok(None);
}
let anchor_date = anchor.unwrap_or(*EPOCH_DATE);
let interval_i64 = interval as i64;
let offset = days_between(anchor_date, date);
let remainder = offset.rem_euclid(interval_i64);
let aligned_date = if remainder == 0 {
date
} else {
date.checked_add(jiff::Span::new().days(-remainder))
.map_err(|e| ScheduleError::eval(format!("{e}")))?
};
let mut cur = aligned_date;
for _ in 0..2 {
if let Some(candidate) = latest_past_at_times(cur, times, tz, now)? {
return Ok(Some(candidate));
}
if let Some(candidate) = latest_at_times(cur, times, tz)? {
if candidate < *now {
return Ok(Some(candidate));
}
}
cur = cur
.checked_add(jiff::Span::new().days(-interval_i64))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
if let Some(candidate) = latest_at_times(cur, times, tz)? {
return Ok(Some(candidate));
}
}
Ok(None)
}
fn prev_interval_repeat(
interval: u32,
unit: IntervalUnit,
from: &TimeOfDay,
to: &TimeOfDay,
day_filter: &Option<DayFilter>,
tz: &TimeZone,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let from_t = to_time(from);
let to_t = to_time(to);
let step_minutes: i64 = match unit {
IntervalUnit::Minutes => interval as i64,
IntervalUnit::Hours => interval as i64 * 60,
};
let mut date = now_in_tz.date();
let now_time = now_in_tz.time();
let now_minutes = now_time.hour() as i64 * 60 + now_time.minute() as i64;
let from_minutes = from_t.hour() as i64 * 60 + from_t.minute() as i64;
let to_minutes = to_t.hour() as i64 * 60 + to_t.minute() as i64;
for _ in 0..8 {
if let Some(ref df) = day_filter {
if !matches_day_filter(date, df) {
date = date
.yesterday()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
continue;
}
}
let search_until = if date == now_in_tz.date() {
now_minutes.min(to_minutes)
} else {
to_minutes
};
if search_until >= from_minutes {
let slots_in_range = (search_until - from_minutes) / step_minutes;
let last_slot_minutes = from_minutes + slots_in_range * step_minutes;
if date == now_in_tz.date() && last_slot_minutes >= now_minutes {
let prev_slot = last_slot_minutes - step_minutes;
if prev_slot >= from_minutes {
let h = (prev_slot / 60) as i8;
let m = (prev_slot % 60) as i8;
let t = Time::new(h, m, 0, 0).unwrap();
return at_time_on_date(date, t, tz).map(Some);
}
} else if last_slot_minutes >= from_minutes {
let h = (last_slot_minutes / 60) as i8;
let m = (last_slot_minutes % 60) as i8;
let t = Time::new(h, m, 0, 0).unwrap();
return at_time_on_date(date, t, tz).map(Some);
}
}
date = date
.yesterday()
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
}
Ok(None)
}
fn prev_week_repeat(
interval: u32,
days: &[Weekday],
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let date = now_in_tz.date();
let days_since_monday = (date.weekday().to_monday_zero_offset()) as i64;
let current_monday = date
.checked_add(jiff::Span::new().days(-days_since_monday))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
let anchor_date = anchor.unwrap_or(*EPOCH_MONDAY);
let anchor_days_since_monday = anchor_date.weekday().to_monday_zero_offset() as i64;
let anchor_monday = anchor_date
.checked_add(jiff::Span::new().days(-anchor_days_since_monday))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
let interval_i64 = interval as i64;
let weeks = weeks_between(anchor_monday, current_monday);
let aligned = weeks >= 0 && weeks % interval_i64 == 0;
if aligned {
let mut sorted_days = days.to_vec();
sorted_days.sort_by_key(|d| d.to_jiff().to_monday_zero_offset());
sorted_days.reverse();
for wd in &sorted_days {
let day_offset = wd.to_jiff().to_monday_zero_offset() as i64;
let target_date = current_monday
.checked_add(jiff::Span::new().days(day_offset))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
if target_date < date {
if let Some(candidate) = latest_at_times(target_date, times, tz)? {
return Ok(Some(candidate));
}
} else if target_date == date {
if let Some(candidate) = latest_past_at_times(target_date, times, tz, now)? {
return Ok(Some(candidate));
}
}
}
}
let mut check_monday = if aligned {
current_monday
.checked_add(jiff::Span::new().days(-interval_i64 * 7))
.map_err(|e| ScheduleError::eval(format!("{e}")))?
} else {
let remainder = weeks.rem_euclid(interval_i64);
current_monday
.checked_add(jiff::Span::new().days(-remainder * 7))
.map_err(|e| ScheduleError::eval(format!("{e}")))?
};
for _ in 0..54 {
let wks = weeks_between(anchor_monday, check_monday);
if wks < 0 {
return Ok(None); }
let mut sorted_days = days.to_vec();
sorted_days.sort_by_key(|d| d.to_jiff().to_monday_zero_offset());
sorted_days.reverse();
for wd in &sorted_days {
let day_offset = wd.to_jiff().to_monday_zero_offset() as i64;
let target_date = check_monday
.checked_add(jiff::Span::new().days(day_offset))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
if let Some(candidate) = latest_at_times(target_date, times, tz)? {
if candidate < *now {
return Ok(Some(candidate));
}
}
}
check_monday = check_monday
.checked_add(jiff::Span::new().days(-interval_i64 * 7))
.map_err(|e| ScheduleError::eval(format!("{e}")))?;
}
Ok(None)
}
fn prev_month_repeat(
interval: u32,
target: &MonthTarget,
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
_during: &[MonthName],
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let start_date = now_in_tz.date();
let anchor_date = anchor.unwrap_or(*EPOCH_DATE);
let max_iter = if interval > 1 { 24 * interval } else { 24 };
let mut year = start_date.year();
let mut month = start_date.month();
for _ in 0..max_iter {
if interval > 1 {
let month_offset = months_between_ym(anchor_date, Date::new(year, month, 1).unwrap());
if month_offset < 0 || month_offset.rem_euclid(interval as i64) != 0 {
if month == 1 {
month = 12;
year -= 1;
} else {
month -= 1;
}
continue;
}
}
let target_dates = match target {
MonthTarget::Days(_) => {
let expanded = target.expand_days();
let mut dates: Vec<Date> = expanded
.iter()
.filter_map(|&d| Date::new(year, month, d as i8).ok())
.collect();
dates.sort();
dates.reverse(); dates
}
MonthTarget::LastDay => {
vec![last_day_of_month(year, month)]
}
MonthTarget::LastWeekday => {
vec![last_weekday_of_month(year, month)]
}
MonthTarget::NearestWeekday { day, direction } => {
match nearest_weekday(year, month, *day, *direction) {
Some(d) => vec![d],
None => vec![],
}
}
MonthTarget::OrdinalWeekday { ordinal, weekday } => match ordinal {
OrdinalPosition::Last => vec![last_weekday_in_month(year, month, *weekday)],
_ => ordinal_to_n(*ordinal)
.and_then(|n| nth_weekday_of_month(year, month, *weekday, n))
.into_iter()
.collect(),
},
};
for date in target_dates {
if date > start_date {
continue; }
if date == start_date {
if let Some(candidate) = latest_past_at_times(date, times, tz, now)? {
return Ok(Some(candidate));
}
} else {
if let Some(candidate) = latest_at_times(date, times, tz)? {
return Ok(Some(candidate));
}
}
}
if month == 1 {
month = 12;
year -= 1;
} else {
month -= 1;
}
}
Ok(None)
}
fn prev_single_date(
date_spec: &DateSpec,
times: &[TimeOfDay],
tz: &TimeZone,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let now_date = now_in_tz.date();
let target_date = match date_spec {
DateSpec::Iso(s) => s
.parse::<Date>()
.map_err(|e| ScheduleError::eval(format!("invalid date '{s}': {e}")))?,
DateSpec::Named { month, day } => {
let this_year = Date::new(now_date.year(), month.number() as i8, *day as i8).ok();
let last_year = Date::new(now_date.year() - 1, month.number() as i8, *day as i8).ok();
if let Some(d) = this_year {
if d < now_date {
d
} else if d == now_date {
if let Some(candidate) = latest_past_at_times(d, times, tz, now)? {
return Ok(Some(candidate));
}
last_year.unwrap_or(d)
} else {
last_year.unwrap_or(d)
}
} else {
return Ok(None);
}
}
};
if let DateSpec::Iso(_) = date_spec {
if target_date > now_date {
return Ok(None); }
if target_date == now_date {
return latest_past_at_times(target_date, times, tz, now);
}
return latest_at_times(target_date, times, tz);
}
latest_at_times(target_date, times, tz)
}
fn prev_year_repeat(
interval: u32,
target: &YearTarget,
times: &[TimeOfDay],
tz: &TimeZone,
anchor: &Option<jiff::civil::Date>,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let now_in_tz = now.with_time_zone(tz.clone());
let start_year = now_in_tz.date().year();
let start_date = now_in_tz.date();
let anchor_year = anchor.unwrap_or(*EPOCH_DATE).year();
let max_iter = if interval > 1 { 8 * interval as i16 } else { 8 };
for y in 0..max_iter {
let year = start_year - y;
if interval > 1 {
let year_offset = (year as i64) - (anchor_year as i64);
if year_offset < 0 || year_offset.rem_euclid(interval as i64) != 0 {
continue;
}
}
let target_date = match target {
YearTarget::Date { month, day } => {
Date::new(year, month.number() as i8, *day as i8).ok()
}
YearTarget::OrdinalWeekday {
ordinal,
weekday,
month,
} => {
let m = month.number() as i8;
match ordinal {
OrdinalPosition::Last => Some(last_weekday_in_month(year, m, *weekday)),
_ => ordinal_to_n(*ordinal)
.and_then(|n| nth_weekday_of_month(year, m, *weekday, n)),
}
}
YearTarget::DayOfMonth { day, month } => {
Date::new(year, month.number() as i8, *day as i8).ok()
}
YearTarget::LastWeekday { month } => {
Some(last_weekday_of_month(year, month.number() as i8))
}
};
if let Some(date) = target_date {
if date > start_date {
continue; }
if date == start_date {
if let Some(candidate) = latest_past_at_times(date, times, tz, now)? {
return Ok(Some(candidate));
}
} else if let Some(candidate) = latest_at_times(date, times, tz)? {
return Ok(Some(candidate));
}
}
}
Ok(None)
}
fn latest_past_at_times(
date: Date,
times: &[TimeOfDay],
tz: &TimeZone,
now: &Zoned,
) -> Result<Option<Zoned>, ScheduleError> {
let mut sorted_times = times.to_vec();
sorted_times.sort_by_key(|t| (t.hour, t.minute));
sorted_times.reverse();
for tod in sorted_times {
let candidate = at_time_on_date(date, to_time(&tod), tz)?;
if candidate < *now {
return Ok(Some(candidate));
}
}
Ok(None)
}
fn latest_at_times(
date: Date,
times: &[TimeOfDay],
tz: &TimeZone,
) -> Result<Option<Zoned>, ScheduleError> {
let mut sorted_times = times.to_vec();
sorted_times.sort_by_key(|t| (t.hour, t.minute));
if let Some(tod) = sorted_times.last() {
return at_time_on_date(date, to_time(tod), tz).map(Some);
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn fixed_now() -> Zoned {
let date = Date::new(2026, 2, 6).unwrap();
let time = Time::new(12, 0, 0, 0).unwrap();
date.to_datetime(time).to_zoned(TimeZone::UTC).unwrap()
}
#[test]
fn test_next_every_day() {
let s = parse("every day at 09:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 2, 7).unwrap());
assert_eq!(next.time().hour(), 9);
}
#[test]
fn test_next_every_weekday() {
let s = parse("every weekday at 9:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 2, 9).unwrap());
}
#[test]
fn test_next_weekend() {
let s = parse("every weekend at 10:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 2, 7).unwrap());
}
#[test]
fn test_next_interval() {
let s = parse("every 45 min from 09:00 to 17:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.time().hour(), 12);
assert_eq!(next.time().minute(), 45);
}
#[test]
fn test_next_month_on_day() {
let s = parse("every month on the 1st at 9:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 3, 1).unwrap());
}
#[test]
fn test_next_month_last_day() {
let s = parse("every month on the last day at 17:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 2, 28).unwrap());
}
#[test]
fn test_next_ordinal_first_monday() {
let s = parse("every month on the first monday at 10:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 3, 2).unwrap());
}
#[test]
fn test_next_single_date_iso() {
let s = parse("on 2026-03-15 at 14:30 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 3, 15).unwrap());
assert_eq!(next.time().hour(), 14);
assert_eq!(next.time().minute(), 30);
}
#[test]
fn test_next_single_date_named() {
let s = parse("on feb 14 at 9:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 2, 14).unwrap());
}
#[test]
fn test_next_n() {
let s = parse("every day at 09:00 in UTC").unwrap();
let now = fixed_now();
let results = next_n_from(&s, &now, 3).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].date(), Date::new(2026, 2, 7).unwrap());
assert_eq!(results[1].date(), Date::new(2026, 2, 8).unwrap());
assert_eq!(results[2].date(), Date::new(2026, 2, 9).unwrap());
}
#[test]
fn test_iso_date_in_past() {
let s = parse("on 2020-01-01 at 00:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap();
assert!(next.is_none());
}
#[test]
fn test_month_skip_31() {
let s = parse("every month on the 31st at 09:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 3, 31).unwrap());
}
#[test]
fn test_next_year_repeat_date() {
let s = parse("every year on dec 25 at 00:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 12, 25).unwrap());
}
#[test]
fn test_next_year_repeat_ordinal_weekday() {
let s = parse("every year on the first monday of march at 10:00 in UTC").unwrap();
let now = fixed_now();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 3, 2).unwrap());
}
#[test]
fn test_except_skips_holiday() {
let s = parse("every weekday at 09:00 except dec 25, jan 1 in UTC").unwrap();
let now = Date::new(2026, 12, 24)
.unwrap()
.to_datetime(Time::new(20, 0, 0, 0).unwrap())
.to_zoned(TimeZone::UTC)
.unwrap();
let next = next_from(&s, &now).unwrap().unwrap();
assert_eq!(next.date(), Date::new(2026, 12, 28).unwrap());
}
#[test]
fn test_until_limits_results() {
let s = parse("every day at 09:00 until 2026-02-10 in UTC").unwrap();
let now = fixed_now();
let results = next_n_from(&s, &now, 10).unwrap();
assert_eq!(results.len(), 4);
assert_eq!(
results.last().unwrap().date(),
Date::new(2026, 2, 10).unwrap()
);
}
}