mirror of
https://github.com/openai/codex.git
synced 2026-04-25 15:15:15 +00:00
884 lines
28 KiB
Rust
884 lines
28 KiB
Rust
//! Trigger validation and next-fire calculation for persistent thread timers.
|
|
//!
|
|
//! This module keeps calendar and delay scheduling details out of `timers.rs`.
|
|
//! It owns the persisted trigger shape, local wall-clock schedule normalization,
|
|
//! and RRULE-backed recurrence evaluation.
|
|
|
|
use chrono::DateTime;
|
|
use chrono::Duration as ChronoDuration;
|
|
use chrono::LocalResult;
|
|
use chrono::NaiveDateTime;
|
|
use chrono::NaiveTime;
|
|
use chrono::TimeZone;
|
|
use chrono::Utc;
|
|
use rrule::RRuleSet;
|
|
use rrule::Tz;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::time::Duration;
|
|
|
|
const LOCAL_DATE_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
|
|
const RRULE_DATE_TIME_FORMAT: &str = "%Y%m%dT%H%M%S";
|
|
const TIME_ONLY_FORMATS: &[&str] = &["%H:%M:%S", "%H:%M", "%H"];
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "kebab-case")]
|
|
pub enum TimerTrigger {
|
|
Delay {
|
|
seconds: u64,
|
|
repeat: Option<bool>,
|
|
},
|
|
Schedule {
|
|
dtstart: Option<String>,
|
|
rrule: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct TriggerTiming {
|
|
pub(crate) trigger: TimerTrigger,
|
|
pub(crate) pending_run: bool,
|
|
pub(crate) next_run_at: Option<i64>,
|
|
pub(crate) timer_delay: Option<Duration>,
|
|
}
|
|
|
|
impl TimerTrigger {
|
|
pub(crate) fn is_recurring(&self) -> bool {
|
|
match self {
|
|
Self::Delay { repeat, .. } => repeat.unwrap_or(false),
|
|
Self::Schedule { rrule, .. } => rrule.as_ref().is_some_and(|rrule| !rrule.is_empty()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn is_idle_recurring(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Delay {
|
|
seconds: 0,
|
|
repeat: Some(true),
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn timing_for_new_trigger(
|
|
trigger: TimerTrigger,
|
|
created_at: DateTime<Utc>,
|
|
now: DateTime<Utc>,
|
|
) -> Result<TriggerTiming, String> {
|
|
let timezone = local_timezone();
|
|
timing_for_new_trigger_with_timezone(trigger, created_at, now, timezone)
|
|
}
|
|
|
|
pub(crate) fn timing_for_restored_trigger(
|
|
trigger: TimerTrigger,
|
|
created_at: i64,
|
|
persisted_pending_run: bool,
|
|
persisted_next_run_at: Option<i64>,
|
|
now: DateTime<Utc>,
|
|
) -> Result<TriggerTiming, String> {
|
|
let timezone = local_timezone();
|
|
timing_for_restored_trigger_with_timezone(
|
|
trigger,
|
|
created_at,
|
|
persisted_pending_run,
|
|
persisted_next_run_at,
|
|
now,
|
|
timezone,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn next_run_after_due(
|
|
trigger: &TimerTrigger,
|
|
created_at: i64,
|
|
now: DateTime<Utc>,
|
|
) -> Result<Option<i64>, String> {
|
|
let timezone = local_timezone();
|
|
next_run_after_due_with_timezone(trigger, created_at, now, timezone)
|
|
}
|
|
|
|
pub(crate) fn normalize_schedule_dtstart_input(input: &str) -> Result<String, String> {
|
|
let timezone = local_timezone();
|
|
normalize_schedule_dtstart_input_with_timezone(input, Utc::now(), timezone)
|
|
}
|
|
|
|
fn normalize_schedule_dtstart_input_with_timezone(
|
|
input: &str,
|
|
now: DateTime<Utc>,
|
|
timezone: Tz,
|
|
) -> Result<String, String> {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("schedule dtstart cannot be empty".to_string());
|
|
}
|
|
if parse_dtstart(trimmed).is_ok() {
|
|
validate_dtstart(trimmed, timezone)?;
|
|
return Ok(trimmed.to_string());
|
|
}
|
|
|
|
let Some(time) = parse_time_only(trimmed) else {
|
|
return Err(format!(
|
|
"schedule dtstart `{trimmed}` must use format YYYY-MM-DDTHH:MM:SS or a time like HH:MM"
|
|
));
|
|
};
|
|
let local_now = now.with_timezone(&timezone).naive_local();
|
|
let mut local_dtstart = local_now.date().and_time(time);
|
|
if local_dtstart <= local_now {
|
|
local_dtstart = local_dtstart
|
|
.checked_add_signed(ChronoDuration::days(1))
|
|
.ok_or_else(|| "schedule dtstart is out of range".to_string())?;
|
|
}
|
|
let dtstart = local_dtstart.format(LOCAL_DATE_TIME_FORMAT).to_string();
|
|
validate_dtstart(&dtstart, timezone)?;
|
|
Ok(dtstart)
|
|
}
|
|
|
|
fn timing_for_new_trigger_with_timezone(
|
|
trigger: TimerTrigger,
|
|
created_at: DateTime<Utc>,
|
|
now: DateTime<Utc>,
|
|
timezone: Tz,
|
|
) -> Result<TriggerTiming, String> {
|
|
let normalized = normalize_trigger(trigger, now, timezone)?;
|
|
match &normalized {
|
|
TimerTrigger::Delay { seconds, repeat } => {
|
|
let repeat = repeat.unwrap_or(false);
|
|
if repeat && *seconds == 0 {
|
|
return Ok(timing(
|
|
normalized, /*pending_run*/ true, /*next_run_at*/ None, now,
|
|
));
|
|
}
|
|
let next_run_at = checked_add_seconds(created_at, *seconds)?;
|
|
let pending_run = next_run_at <= now;
|
|
let next_run_at = if repeat {
|
|
if pending_run {
|
|
next_delay_recurring_run_at(created_at, *seconds, now)?
|
|
} else {
|
|
Some(next_run_at.timestamp())
|
|
}
|
|
} else {
|
|
Some(next_run_at.timestamp())
|
|
};
|
|
Ok(timing(normalized, pending_run, next_run_at, now))
|
|
}
|
|
TimerTrigger::Schedule { rrule: None, .. } => {
|
|
let due_at = schedule_dtstart_utc(&normalized, timezone)?;
|
|
Ok(timing(
|
|
normalized,
|
|
due_at <= now,
|
|
Some(due_at.timestamp()),
|
|
now,
|
|
))
|
|
}
|
|
TimerTrigger::Schedule { rrule: Some(_), .. } => {
|
|
let due_or_next = next_schedule_occurrence_at_or_after(&normalized, now, timezone)?;
|
|
let Some(due_or_next) = due_or_next else {
|
|
return Ok(timing(
|
|
normalized, /*pending_run*/ false, /*next_run_at*/ None, now,
|
|
));
|
|
};
|
|
let pending_run = due_or_next <= now.timestamp();
|
|
let next_run_at = if pending_run {
|
|
next_schedule_occurrence_after(&normalized, now, timezone)?
|
|
} else {
|
|
Some(due_or_next)
|
|
};
|
|
Ok(timing(normalized, pending_run, next_run_at, now))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn timing_for_restored_trigger_with_timezone(
|
|
trigger: TimerTrigger,
|
|
created_at: i64,
|
|
persisted_pending_run: bool,
|
|
persisted_next_run_at: Option<i64>,
|
|
now: DateTime<Utc>,
|
|
timezone: Tz,
|
|
) -> Result<TriggerTiming, String> {
|
|
let normalized = normalize_trigger(trigger, now, timezone)?;
|
|
match &normalized {
|
|
TimerTrigger::Delay { seconds, repeat } => {
|
|
let repeat = repeat.unwrap_or(false);
|
|
if repeat && *seconds == 0 {
|
|
return Ok(timing(
|
|
normalized, /*pending_run*/ true, /*next_run_at*/ None, now,
|
|
));
|
|
}
|
|
let next_run_at = persisted_next_run_at
|
|
.or_else(|| next_delay_run_at(created_at, *seconds))
|
|
.ok_or_else(|| "delay next run time is out of range".to_string())?;
|
|
let due = next_run_at <= now.timestamp();
|
|
let pending_run = persisted_pending_run || due;
|
|
let next_run_at = if repeat && due {
|
|
next_delay_recurring_run_at_from_timestamp(created_at, *seconds, now)?
|
|
} else {
|
|
Some(next_run_at)
|
|
};
|
|
Ok(timing(normalized, pending_run, next_run_at, now))
|
|
}
|
|
TimerTrigger::Schedule { rrule: None, .. } => {
|
|
let next_run_at = persisted_next_run_at
|
|
.or_else(|| {
|
|
schedule_dtstart_utc(&normalized, timezone)
|
|
.ok()
|
|
.map(|dt| dt.timestamp())
|
|
})
|
|
.ok_or_else(|| "schedule next run time is out of range".to_string())?;
|
|
Ok(timing(
|
|
normalized,
|
|
persisted_pending_run || next_run_at <= now.timestamp(),
|
|
Some(next_run_at),
|
|
now,
|
|
))
|
|
}
|
|
TimerTrigger::Schedule { rrule: Some(_), .. } => {
|
|
let Some(persisted_next_run_at) = persisted_next_run_at else {
|
|
return Ok(timing(
|
|
normalized,
|
|
persisted_pending_run,
|
|
/*next_run_at*/ None,
|
|
now,
|
|
));
|
|
};
|
|
let due = persisted_next_run_at <= now.timestamp();
|
|
let next_run_at = if due {
|
|
next_schedule_occurrence_after(&normalized, now, timezone)?
|
|
} else {
|
|
Some(persisted_next_run_at)
|
|
};
|
|
Ok(timing(
|
|
normalized,
|
|
persisted_pending_run || due,
|
|
next_run_at,
|
|
now,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn next_run_after_due_with_timezone(
|
|
trigger: &TimerTrigger,
|
|
created_at: i64,
|
|
now: DateTime<Utc>,
|
|
timezone: Tz,
|
|
) -> Result<Option<i64>, String> {
|
|
match trigger {
|
|
TimerTrigger::Delay { seconds, repeat } => {
|
|
if repeat.unwrap_or(false) {
|
|
if *seconds == 0 {
|
|
return Ok(None);
|
|
}
|
|
next_delay_recurring_run_at_from_timestamp(created_at, *seconds, now)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
TimerTrigger::Schedule { rrule: None, .. } => Ok(None),
|
|
TimerTrigger::Schedule { rrule: Some(_), .. } => {
|
|
next_schedule_occurrence_after(trigger, now, timezone)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_trigger(
|
|
trigger: TimerTrigger,
|
|
now: DateTime<Utc>,
|
|
timezone: Tz,
|
|
) -> Result<TimerTrigger, String> {
|
|
match trigger {
|
|
TimerTrigger::Delay { seconds, repeat } => Ok(TimerTrigger::Delay { seconds, repeat }),
|
|
TimerTrigger::Schedule { dtstart, rrule } => {
|
|
let dtstart = normalize_optional_string(dtstart);
|
|
let rrule = normalize_optional_string(rrule);
|
|
if dtstart.is_none() && rrule.is_none() {
|
|
return Err("schedule trigger requires dtstart, rrule, or both".to_string());
|
|
}
|
|
let dtstart = match (dtstart, rrule.as_ref()) {
|
|
(Some(dtstart), _) => {
|
|
validate_dtstart(&dtstart, timezone)?;
|
|
Some(dtstart)
|
|
}
|
|
(None, Some(_)) => Some(format_local_dtstart(now, timezone)),
|
|
(None, None) => None,
|
|
};
|
|
let normalized = TimerTrigger::Schedule { dtstart, rrule };
|
|
if matches!(normalized, TimerTrigger::Schedule { rrule: Some(_), .. }) {
|
|
parse_rrule_set(&normalized, timezone)?;
|
|
}
|
|
Ok(normalized)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn timing(
|
|
trigger: TimerTrigger,
|
|
pending_run: bool,
|
|
next_run_at: Option<i64>,
|
|
now: DateTime<Utc>,
|
|
) -> TriggerTiming {
|
|
let keep_timer_for_pending = trigger.is_recurring();
|
|
let timer_delay = next_run_at
|
|
.filter(|_| !pending_run || keep_timer_for_pending)
|
|
.and_then(|next| timer_delay(next, now));
|
|
TriggerTiming {
|
|
trigger,
|
|
pending_run,
|
|
next_run_at,
|
|
timer_delay,
|
|
}
|
|
}
|
|
|
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
|
value
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn checked_add_seconds(start: DateTime<Utc>, seconds: u64) -> Result<DateTime<Utc>, String> {
|
|
let seconds = i64::try_from(seconds)
|
|
.map_err(|_| "delay seconds value is too large to schedule".to_string())?;
|
|
start
|
|
.checked_add_signed(ChronoDuration::seconds(seconds))
|
|
.ok_or_else(|| "delay next run time is out of range".to_string())
|
|
}
|
|
|
|
fn next_delay_run_at(created_at: i64, seconds: u64) -> Option<i64> {
|
|
let seconds = i64::try_from(seconds).ok()?;
|
|
created_at.checked_add(seconds)
|
|
}
|
|
|
|
fn next_delay_recurring_run_at(
|
|
created_at: DateTime<Utc>,
|
|
seconds: u64,
|
|
now: DateTime<Utc>,
|
|
) -> Result<Option<i64>, String> {
|
|
next_delay_recurring_run_at_from_timestamp(created_at.timestamp(), seconds, now)
|
|
}
|
|
|
|
fn next_delay_recurring_run_at_from_timestamp(
|
|
created_at: i64,
|
|
seconds: u64,
|
|
now: DateTime<Utc>,
|
|
) -> Result<Option<i64>, String> {
|
|
let seconds = i64::try_from(seconds)
|
|
.map_err(|_| "delay seconds value is too large to schedule".to_string())?;
|
|
if seconds <= 0 {
|
|
return Err("delay.repeat requires seconds to be greater than 0".to_string());
|
|
}
|
|
let elapsed = now.timestamp().saturating_sub(created_at);
|
|
let completed_intervals = elapsed.div_euclid(seconds) + 1;
|
|
created_at
|
|
.checked_add(
|
|
completed_intervals
|
|
.checked_mul(seconds)
|
|
.ok_or_else(|| "delay next run time is out of range".to_string())?,
|
|
)
|
|
.map(Some)
|
|
.ok_or_else(|| "delay next run time is out of range".to_string())
|
|
}
|
|
|
|
fn timer_delay(next_run_at: i64, now: DateTime<Utc>) -> Option<Duration> {
|
|
if next_run_at <= now.timestamp() {
|
|
return Some(Duration::ZERO);
|
|
}
|
|
u64::try_from(next_run_at - now.timestamp())
|
|
.ok()
|
|
.map(Duration::from_secs)
|
|
}
|
|
|
|
fn schedule_dtstart_utc(trigger: &TimerTrigger, timezone: Tz) -> Result<DateTime<Utc>, String> {
|
|
let TimerTrigger::Schedule {
|
|
dtstart: Some(dtstart),
|
|
..
|
|
} = trigger
|
|
else {
|
|
return Err("schedule trigger requires dtstart".to_string());
|
|
};
|
|
local_dtstart_to_utc(dtstart, timezone)
|
|
}
|
|
|
|
fn next_schedule_occurrence_after(
|
|
trigger: &TimerTrigger,
|
|
after: DateTime<Utc>,
|
|
timezone: Tz,
|
|
) -> Result<Option<i64>, String> {
|
|
let set = parse_rrule_set(trigger, timezone)?;
|
|
let after = after
|
|
.checked_add_signed(ChronoDuration::seconds(1))
|
|
.unwrap_or(after);
|
|
let result = set.after(after.with_timezone(&timezone)).all(1);
|
|
Ok(result
|
|
.dates
|
|
.into_iter()
|
|
.next()
|
|
.map(|next| next.with_timezone(&Utc).timestamp()))
|
|
}
|
|
|
|
fn next_schedule_occurrence_at_or_after(
|
|
trigger: &TimerTrigger,
|
|
at: DateTime<Utc>,
|
|
timezone: Tz,
|
|
) -> Result<Option<i64>, String> {
|
|
let after = at
|
|
.checked_sub_signed(ChronoDuration::seconds(1))
|
|
.unwrap_or(at);
|
|
next_schedule_occurrence_after(trigger, after, timezone)
|
|
}
|
|
|
|
fn parse_rrule_set(trigger: &TimerTrigger, timezone: Tz) -> Result<RRuleSet, String> {
|
|
let TimerTrigger::Schedule {
|
|
dtstart: Some(dtstart),
|
|
rrule: Some(rrule),
|
|
} = trigger
|
|
else {
|
|
return Err("schedule trigger requires dtstart and rrule".to_string());
|
|
};
|
|
let naive = parse_dtstart(dtstart)?;
|
|
let raw_rrule = rrule
|
|
.strip_prefix("RRULE:")
|
|
.or_else(|| rrule.strip_prefix("rrule:"))
|
|
.unwrap_or(rrule);
|
|
let formatted_dtstart = naive.format(RRULE_DATE_TIME_FORMAT);
|
|
let dtstart = if timezone.is_local() {
|
|
format!("DTSTART:{formatted_dtstart}")
|
|
} else {
|
|
let timezone_name = timezone.name();
|
|
format!("DTSTART;TZID={timezone_name}:{formatted_dtstart}")
|
|
};
|
|
let rrule_set = format!("{dtstart}\nRRULE:{raw_rrule}");
|
|
rrule_set
|
|
.parse::<RRuleSet>()
|
|
.map_err(|err| format!("invalid schedule rrule `{rrule}`: {err}"))
|
|
}
|
|
|
|
fn validate_dtstart(dtstart: &str, timezone: Tz) -> Result<(), String> {
|
|
local_dtstart_to_utc(dtstart, timezone).map(|_| ())
|
|
}
|
|
|
|
fn local_dtstart_to_utc(dtstart: &str, timezone: Tz) -> Result<DateTime<Utc>, String> {
|
|
let naive = parse_dtstart(dtstart)?;
|
|
match timezone.from_local_datetime(&naive) {
|
|
LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)),
|
|
LocalResult::Ambiguous(earliest, _) => Ok(earliest.with_timezone(&Utc)),
|
|
LocalResult::None => Err(format!(
|
|
"schedule dtstart `{dtstart}` does not exist in local timezone {timezone}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn parse_dtstart(dtstart: &str) -> Result<NaiveDateTime, String> {
|
|
NaiveDateTime::parse_from_str(dtstart, LOCAL_DATE_TIME_FORMAT)
|
|
.map_err(|_| format!("schedule dtstart `{dtstart}` must use format YYYY-MM-DDTHH:MM:SS"))
|
|
}
|
|
|
|
fn parse_time_only(input: &str) -> Option<NaiveTime> {
|
|
for format in TIME_ONLY_FORMATS {
|
|
if let Ok(time) = NaiveTime::parse_from_str(input, format) {
|
|
return Some(time);
|
|
}
|
|
}
|
|
|
|
let compact = input.to_ascii_lowercase().replace(' ', "");
|
|
let (time, is_pm) = if let Some(time) = compact.strip_suffix("am") {
|
|
(time, false)
|
|
} else if let Some(time) = compact.strip_suffix("pm") {
|
|
(time, true)
|
|
} else {
|
|
return None;
|
|
};
|
|
let parts = time.split(':').collect::<Vec<_>>();
|
|
if parts.is_empty() || parts.len() > 3 {
|
|
return None;
|
|
}
|
|
let hour = parts[0].parse::<u32>().ok()?;
|
|
if !(1..=12).contains(&hour) {
|
|
return None;
|
|
}
|
|
let minute = parts
|
|
.get(1)
|
|
.map_or(Some(0), |value| value.parse::<u32>().ok())?;
|
|
let second = parts
|
|
.get(2)
|
|
.map_or(Some(0), |value| value.parse::<u32>().ok())?;
|
|
let hour = if is_pm { (hour % 12) + 12 } else { hour % 12 };
|
|
NaiveTime::from_hms_opt(hour, minute, second)
|
|
}
|
|
|
|
fn format_local_dtstart(now: DateTime<Utc>, timezone: Tz) -> String {
|
|
now.with_timezone(&timezone)
|
|
.naive_local()
|
|
.format(LOCAL_DATE_TIME_FORMAT)
|
|
.to_string()
|
|
}
|
|
|
|
fn local_timezone() -> Tz {
|
|
timezone_from_name(iana_time_zone::get_timezone().ok())
|
|
}
|
|
|
|
fn timezone_from_name(timezone: Option<String>) -> Tz {
|
|
timezone
|
|
.as_deref()
|
|
.and_then(|timezone| timezone.parse::<chrono_tz::Tz>().ok())
|
|
.map(Tz::Tz)
|
|
.unwrap_or(Tz::LOCAL)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::TimeZone;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
const TS_1: i64 = 1;
|
|
const TS_100: i64 = 100;
|
|
const TS_135: i64 = 135;
|
|
const TS_1_700_000_000: i64 = 1_700_000_000;
|
|
|
|
fn utc(timestamp: i64) -> DateTime<Utc> {
|
|
Utc.timestamp_opt(timestamp, 0)
|
|
.single()
|
|
.expect("valid timestamp")
|
|
}
|
|
|
|
fn utc_datetime(datetime: &str) -> DateTime<Utc> {
|
|
Utc.from_utc_datetime(
|
|
&NaiveDateTime::parse_from_str(datetime, LOCAL_DATE_TIME_FORMAT)
|
|
.expect("valid datetime"),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn delay_one_shot_becomes_pending_when_due() {
|
|
let timing = timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Delay {
|
|
seconds: 0,
|
|
repeat: None,
|
|
},
|
|
utc(TS_100),
|
|
utc(TS_100),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(
|
|
timing,
|
|
TriggerTiming {
|
|
trigger: TimerTrigger::Delay {
|
|
seconds: 0,
|
|
repeat: None,
|
|
},
|
|
pending_run: true,
|
|
next_run_at: Some(100),
|
|
timer_delay: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn delay_repeat_zero_is_idle_recurring() {
|
|
let timing = timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Delay {
|
|
seconds: 0,
|
|
repeat: Some(true),
|
|
},
|
|
utc(TS_100),
|
|
utc(TS_100),
|
|
Tz::UTC,
|
|
)
|
|
.expect("zero repeat should be a valid idle-recurring trigger");
|
|
assert_eq!(
|
|
timing,
|
|
TriggerTiming {
|
|
trigger: TimerTrigger::Delay {
|
|
seconds: 0,
|
|
repeat: Some(true),
|
|
},
|
|
pending_run: true,
|
|
next_run_at: None,
|
|
timer_delay: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
next_run_after_due_with_timezone(
|
|
&TimerTrigger::Delay {
|
|
seconds: 0,
|
|
repeat: Some(true),
|
|
},
|
|
/*created_at*/ 100,
|
|
utc(TS_100),
|
|
Tz::UTC,
|
|
)
|
|
.expect("zero repeat should remain timer-free"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn delay_repeat_coalesces_overdue_runs() {
|
|
let timing = timing_for_restored_trigger_with_timezone(
|
|
TimerTrigger::Delay {
|
|
seconds: 10,
|
|
repeat: Some(true),
|
|
},
|
|
/*created_at*/ 100,
|
|
/*persisted_pending_run*/ false,
|
|
Some(110),
|
|
utc(TS_135),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(timing.pending_run, true);
|
|
assert_eq!(timing.next_run_at, Some(140));
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_rrule_only_resolves_dtstart_to_now() {
|
|
let timing = timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: None,
|
|
rrule: Some("FREQ=HOURLY;BYMINUTE=0;BYSECOND=0".to_string()),
|
|
},
|
|
utc(TS_1_700_000_000),
|
|
utc(TS_1_700_000_000),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(
|
|
timing.trigger,
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2023-11-14T22:13:20".to_string()),
|
|
rrule: Some("FREQ=HOURLY;BYMINUTE=0;BYSECOND=0".to_string()),
|
|
}
|
|
);
|
|
assert_eq!(timing.pending_run, false);
|
|
assert_eq!(timing.next_run_at, Some(1_700_002_800));
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_dtstart_only_is_one_shot() {
|
|
let timing = timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2024-01-01T09:00:00".to_string()),
|
|
rrule: None,
|
|
},
|
|
utc(TS_1),
|
|
utc(TS_1),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(timing.pending_run, false);
|
|
assert_eq!(timing.next_run_at, Some(1_704_099_600));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_schedule_dtstart_accepts_time_later_today() {
|
|
assert_eq!(
|
|
normalize_schedule_dtstart_input_with_timezone(
|
|
"15:30",
|
|
utc_datetime("2026-04-10T14:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("time should normalize"),
|
|
"2026-04-10T15:30:00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_schedule_dtstart_accepts_local_time_later_today() {
|
|
assert_eq!(
|
|
normalize_schedule_dtstart_input_with_timezone(
|
|
"8:59",
|
|
utc_datetime("2026-04-10T15:57:00"),
|
|
Tz::America__Los_Angeles,
|
|
)
|
|
.expect("time should normalize"),
|
|
"2026-04-10T08:59:00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_schedule_dtstart_rolls_past_time_to_tomorrow() {
|
|
assert_eq!(
|
|
normalize_schedule_dtstart_input_with_timezone(
|
|
"15:30",
|
|
utc_datetime("2026-04-10T16:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("time should normalize"),
|
|
"2026-04-11T15:30:00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_schedule_dtstart_accepts_ampm_time() {
|
|
assert_eq!(
|
|
normalize_schedule_dtstart_input_with_timezone(
|
|
"3:05 pm",
|
|
utc_datetime("2026-04-10T14:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("time should normalize"),
|
|
"2026-04-10T15:05:00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_schedule_dtstart_preserves_full_datetime() {
|
|
assert_eq!(
|
|
normalize_schedule_dtstart_input_with_timezone(
|
|
"2026-04-10T15:30:45",
|
|
utc_datetime("2026-04-10T14:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("datetime should normalize"),
|
|
"2026-04-10T15:30:45"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_timezone_fallback_uses_system_local_timezone() {
|
|
assert!(timezone_from_name(Some("Not/A_Real_Zone".to_string())).is_local());
|
|
}
|
|
|
|
#[test]
|
|
fn recurring_schedule_accepts_system_local_timezone() {
|
|
let timing = timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2026-04-10T09:00:00".to_string()),
|
|
rrule: Some("FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0".to_string()),
|
|
},
|
|
utc_datetime("2026-04-10T08:00:00"),
|
|
utc_datetime("2026-04-10T08:00:00"),
|
|
Tz::LOCAL,
|
|
)
|
|
.expect("local timezone should be valid for recurring schedules");
|
|
assert!(!timing.pending_run);
|
|
assert!(timing.next_run_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_recurring_historical_dtstart_waits_for_next_future_occurrence() {
|
|
let timing = timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2024-01-01T09:00:00".to_string()),
|
|
rrule: Some("FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0".to_string()),
|
|
},
|
|
utc_datetime("2024-01-02T08:00:00"),
|
|
utc_datetime("2024-01-02T08:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(timing.pending_run, false);
|
|
assert_eq!(
|
|
timing.next_run_at,
|
|
Some(utc_datetime("2024-01-02T09:00:00").timestamp())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_recurring_due_now_becomes_pending() {
|
|
let timing = timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2024-01-01T09:00:00".to_string()),
|
|
rrule: Some("FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0".to_string()),
|
|
},
|
|
utc_datetime("2024-01-02T09:00:00"),
|
|
utc_datetime("2024-01-02T09:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(timing.pending_run, true);
|
|
assert_eq!(
|
|
timing.next_run_at,
|
|
Some(utc_datetime("2024-01-03T09:00:00").timestamp())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restored_recurring_schedule_without_next_run_remains_inactive() {
|
|
let timing = timing_for_restored_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2024-01-01T09:00:00".to_string()),
|
|
rrule: Some("FREQ=DAILY;COUNT=1".to_string()),
|
|
},
|
|
utc_datetime("2024-01-01T08:00:00").timestamp(),
|
|
/*persisted_pending_run*/ false,
|
|
/*persisted_next_run_at*/ None,
|
|
utc_datetime("2024-01-02T09:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(timing.pending_run, false);
|
|
assert_eq!(timing.next_run_at, None);
|
|
}
|
|
|
|
#[test]
|
|
fn restored_pending_recurring_schedule_without_next_run_stays_pending() {
|
|
let timing = timing_for_restored_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2024-01-01T09:00:00".to_string()),
|
|
rrule: Some("FREQ=DAILY;COUNT=1".to_string()),
|
|
},
|
|
utc_datetime("2024-01-01T08:00:00").timestamp(),
|
|
/*persisted_pending_run*/ true,
|
|
/*persisted_next_run_at*/ None,
|
|
utc_datetime("2024-01-02T09:00:00"),
|
|
Tz::UTC,
|
|
)
|
|
.expect("trigger should be valid");
|
|
assert_eq!(timing.pending_run, true);
|
|
assert_eq!(timing.next_run_at, None);
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_rejects_neither_dtstart_nor_rrule() {
|
|
assert_eq!(
|
|
timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: None,
|
|
rrule: None,
|
|
},
|
|
utc(TS_1),
|
|
utc(TS_1),
|
|
Tz::UTC,
|
|
)
|
|
.expect_err("empty schedule should be invalid"),
|
|
"schedule trigger requires dtstart, rrule, or both"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_rejects_invalid_dtstart() {
|
|
assert_eq!(
|
|
timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2024-01-01 09:00:00".to_string()),
|
|
rrule: None,
|
|
},
|
|
utc(TS_1),
|
|
utc(TS_1),
|
|
Tz::UTC,
|
|
)
|
|
.expect_err("bad dtstart should be invalid"),
|
|
"schedule dtstart `2024-01-01 09:00:00` must use format YYYY-MM-DDTHH:MM:SS"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_rejects_invalid_rrule() {
|
|
assert!(
|
|
timing_for_new_trigger_with_timezone(
|
|
TimerTrigger::Schedule {
|
|
dtstart: Some("2024-01-01T09:00:00".to_string()),
|
|
rrule: Some("FREQ=NEVER".to_string()),
|
|
},
|
|
utc(TS_1),
|
|
utc(TS_1),
|
|
Tz::UTC,
|
|
)
|
|
.expect_err("bad rrule should be invalid")
|
|
.contains("invalid schedule rrule")
|
|
);
|
|
}
|
|
}
|