use crate::utils::ChainError;
use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveTime, TimeZone, Utc, Weekday};
use chrono_tz::{GapInfo, Tz};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroUsize;
pub(crate) const MAX_SCHEDULE_RULES: usize = 16;
pub(crate) const MAX_TARGET_COUNT: usize = 256;
pub(crate) const MAX_EXPIRATIONS_PER_SNAPSHOT: usize = 512;
pub(crate) const DEFAULT_YEARLY_MONTH: u32 = 12;
pub(crate) const MAX_RULE_ID_LEN: usize = 64;
const DAY_SCAN_SLACK: usize = 32;
const PERIOD_SCAN_SLACK: usize = 2;
const SECONDS_PER_DAY: Decimal = Decimal::from_parts(86_400, 0, 0, false, 0);
#[must_use]
pub(crate) fn tzdb_version() -> &'static str {
chrono_tz::IANA_TZDB_VERSION
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CalendarVersion {
#[serde(rename = "weekdays_v1")]
WeekdaysV1,
}
impl CalendarVersion {
#[must_use]
pub fn eligible_date(self, date: NaiveDate) -> Option<NaiveDate> {
match self {
CalendarVersion::WeekdaysV1 => match date.weekday() {
Weekday::Sat | Weekday::Sun => None,
_ => Some(date),
},
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
CalendarVersion::WeekdaysV1 => "weekdays_v1",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExpiryRuleKind {
Daily,
Weekly {
weekdays: Vec<Weekday>,
},
Monthly {
weekday: Weekday,
},
Yearly {
weekday: Weekday,
month: u32,
},
}
impl ExpiryRuleKind {
#[must_use]
pub fn weekly(weekdays: impl IntoIterator<Item = Weekday>) -> Self {
let ordered: BTreeSet<u8> = weekdays
.into_iter()
.filter_map(|day| u8::try_from(day.num_days_from_monday()).ok())
.collect();
let weekdays = ordered
.into_iter()
.filter_map(|index| Weekday::try_from(index).ok())
.collect();
ExpiryRuleKind::Weekly { weekdays }
}
#[must_use]
pub fn yearly(weekday: Weekday) -> Self {
ExpiryRuleKind::Yearly {
weekday,
month: DEFAULT_YEARLY_MONTH,
}
}
#[must_use]
fn kind_name(&self) -> &'static str {
match self {
ExpiryRuleKind::Daily => "daily",
ExpiryRuleKind::Weekly { .. } => "weekly",
ExpiryRuleKind::Monthly { .. } => "monthly",
ExpiryRuleKind::Yearly { .. } => "yearly",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(into = "ExpiryRuleWire", try_from = "ExpiryRuleWire")]
pub struct ExpiryRule {
rule_id: String,
kind: ExpiryRuleKind,
target_count: NonZeroUsize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ExpiryRuleWire {
rule_id: String,
kind: String,
target_count: NonZeroUsize,
#[serde(default, skip_serializing_if = "Option::is_none")]
weekdays: Option<Vec<Weekday>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
weekday: Option<Weekday>,
#[serde(default, skip_serializing_if = "Option::is_none")]
month: Option<u32>,
}
impl From<ExpiryRule> for ExpiryRuleWire {
fn from(rule: ExpiryRule) -> Self {
let kind = rule.kind.kind_name().to_string();
let (weekdays, weekday, month) = match rule.kind {
ExpiryRuleKind::Daily => (None, None, None),
ExpiryRuleKind::Weekly { weekdays } => (Some(weekdays), None, None),
ExpiryRuleKind::Monthly { weekday } => (None, Some(weekday), None),
ExpiryRuleKind::Yearly { weekday, month } => (None, Some(weekday), Some(month)),
};
Self {
rule_id: rule.rule_id,
kind,
target_count: rule.target_count,
weekdays,
weekday,
month,
}
}
}
impl TryFrom<ExpiryRuleWire> for ExpiryRule {
type Error = ChainError;
fn try_from(wire: ExpiryRuleWire) -> Result<Self, Self::Error> {
validate_rule_id(&wire.rule_id)?;
let field = |name: &str| format!("schedules.{}.{name}", wire.rule_id);
let reject = |present: bool, name: &str| -> Result<(), ChainError> {
if present {
return Err(ChainError::Validation {
field: field(name),
reason: format!("is not valid for a {} rule", wire.kind),
});
}
Ok(())
};
let kind = match wire.kind.as_str() {
"daily" => {
reject(wire.weekdays.is_some(), "weekdays")?;
reject(wire.weekday.is_some(), "weekday")?;
reject(wire.month.is_some(), "month")?;
ExpiryRuleKind::Daily
}
"weekly" => {
reject(wire.weekday.is_some(), "weekday")?;
reject(wire.month.is_some(), "month")?;
let weekdays = wire.weekdays.ok_or_else(|| ChainError::Validation {
field: field("weekdays"),
reason: "is required for a weekly rule".to_string(),
})?;
ExpiryRuleKind::weekly(weekdays)
}
"monthly" => {
reject(wire.weekdays.is_some(), "weekdays")?;
reject(wire.month.is_some(), "month")?;
let weekday = wire.weekday.ok_or_else(|| ChainError::Validation {
field: field("weekday"),
reason: "is required for a monthly rule".to_string(),
})?;
ExpiryRuleKind::Monthly { weekday }
}
"yearly" => {
reject(wire.weekdays.is_some(), "weekdays")?;
let weekday = wire.weekday.ok_or_else(|| ChainError::Validation {
field: field("weekday"),
reason: "is required for a yearly rule".to_string(),
})?;
let month = wire.month.unwrap_or(DEFAULT_YEARLY_MONTH);
ExpiryRuleKind::Yearly { weekday, month }
}
other => {
return Err(ChainError::Validation {
field: field("kind"),
reason: format!("must be one of daily, weekly, monthly, yearly; got {other:?}"),
});
}
};
ExpiryRule::from_parts(wire.rule_id, kind, wire.target_count)
}
}
impl ExpiryRule {
pub fn new(
rule_id: impl Into<String>,
kind: ExpiryRuleKind,
target_count: usize,
) -> Result<Self, ChainError> {
let rule_id = rule_id.into();
let target_count =
NonZeroUsize::new(target_count).ok_or_else(|| ChainError::Validation {
field: format!("schedules.{rule_id}.target_count"),
reason: "must be at least 1".to_string(),
})?;
Self::from_parts(rule_id, kind, target_count)
}
fn from_parts(
rule_id: String,
kind: ExpiryRuleKind,
target_count: NonZeroUsize,
) -> Result<Self, ChainError> {
let kind = match kind {
ExpiryRuleKind::Weekly { weekdays } => ExpiryRuleKind::weekly(weekdays),
other => other,
};
validate_rule_id(&rule_id)?;
if target_count.get() > MAX_TARGET_COUNT {
return Err(ChainError::Validation {
field: format!("schedules.{rule_id}.target_count"),
reason: format!(
"must not exceed {MAX_TARGET_COUNT}, got {}",
target_count.get()
),
});
}
validate_rule_kind(&rule_id, &kind)?;
let kind = match kind {
ExpiryRuleKind::Weekly { weekdays } => ExpiryRuleKind::weekly(weekdays),
other => other,
};
Ok(Self {
rule_id,
kind,
target_count,
})
}
#[must_use]
pub fn rule_id(&self) -> &str {
&self.rule_id
}
#[must_use]
pub fn kind(&self) -> &ExpiryRuleKind {
&self.kind
}
#[must_use]
pub fn target_count(&self) -> NonZeroUsize {
self.target_count
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ExpirationScheduleWire")]
pub struct ExpirationSchedule {
calendar: CalendarVersion,
timezone: Tz,
expiration_time: NaiveTime,
rules: Vec<ExpiryRule>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ExpirationScheduleWire {
calendar: CalendarVersion,
timezone: Tz,
expiration_time: NaiveTime,
rules: Vec<ExpiryRule>,
}
impl TryFrom<ExpirationScheduleWire> for ExpirationSchedule {
type Error = ChainError;
fn try_from(wire: ExpirationScheduleWire) -> Result<Self, Self::Error> {
ExpirationSchedule::new(
wire.calendar,
wire.timezone,
wire.expiration_time,
wire.rules,
)
}
}
impl ExpirationSchedule {
pub fn new(
calendar: CalendarVersion,
timezone: Tz,
expiration_time: NaiveTime,
rules: Vec<ExpiryRule>,
) -> Result<Self, ChainError> {
let mut schedule = Self {
calendar,
timezone,
expiration_time,
rules,
};
schedule.validate()?;
schedule.rules.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
Ok(schedule)
}
#[must_use]
pub fn calendar(&self) -> CalendarVersion {
self.calendar
}
#[must_use]
pub fn timezone(&self) -> Tz {
self.timezone
}
#[must_use]
pub fn expiration_time(&self) -> NaiveTime {
self.expiration_time
}
#[must_use]
pub fn rules(&self) -> &[ExpiryRule] {
&self.rules
}
pub fn validate(&self) -> Result<(), ChainError> {
if self.rules.is_empty() {
return Err(ChainError::Validation {
field: "schedules".to_string(),
reason: "must declare at least one expiration rule".to_string(),
});
}
if self.rules.len() > MAX_SCHEDULE_RULES {
return Err(ChainError::Validation {
field: "schedules".to_string(),
reason: format!(
"must not exceed {MAX_SCHEDULE_RULES} rules, got {}",
self.rules.len()
),
});
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut projected: usize = 0;
for rule in &self.rules {
if !seen.insert(rule.rule_id.as_str()) {
return Err(ChainError::Validation {
field: format!("schedules.{}.rule_id", rule.rule_id),
reason: "must be unique within the schedule".to_string(),
});
}
projected = projected
.checked_add(rule.target_count.get())
.ok_or_else(|| ChainError::Validation {
field: "schedules".to_string(),
reason: "total expiration count overflows".to_string(),
})?;
}
if projected > MAX_EXPIRATIONS_PER_SNAPSHOT {
return Err(ChainError::Validation {
field: "schedules".to_string(),
reason: format!(
"total expiration count must not exceed {MAX_EXPIRATIONS_PER_SNAPSHOT}, got {projected}"
),
});
}
Ok(())
}
}
fn validate_rule_id(rule_id: &str) -> Result<(), ChainError> {
if rule_id.is_empty() {
return Err(ChainError::Validation {
field: "schedules.rule_id".to_string(),
reason: "must not be empty".to_string(),
});
}
if rule_id.len() > MAX_RULE_ID_LEN {
return Err(ChainError::Validation {
field: "schedules.rule_id".to_string(),
reason: format!(
"must not exceed {MAX_RULE_ID_LEN} characters, got {}",
rule_id.len()
),
});
}
if let Some(bad) = rule_id
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '_' && *c != '-')
{
return Err(ChainError::Validation {
field: "schedules.rule_id".to_string(),
reason: format!("must contain only [A-Za-z0-9_-], found {bad:?}"),
});
}
Ok(())
}
fn validate_rule_kind(rule_id: &str, kind: &ExpiryRuleKind) -> Result<(), ChainError> {
let field = format!("schedules.{rule_id}.{}", kind.kind_name());
match kind {
ExpiryRuleKind::Daily => Ok(()),
ExpiryRuleKind::Weekly { weekdays } => {
if weekdays.is_empty() {
return Err(ChainError::Validation {
field: format!("{field}.weekdays"),
reason: "must name at least one weekday".to_string(),
});
}
for weekday in weekdays {
reject_weekend(&format!("{field}.weekdays"), *weekday)?;
}
Ok(())
}
ExpiryRuleKind::Monthly { weekday } => {
reject_weekend(&format!("{field}.weekday"), *weekday)
}
ExpiryRuleKind::Yearly { weekday, month } => {
reject_weekend(&format!("{field}.weekday"), *weekday)?;
if !(1..=12).contains(month) {
return Err(ChainError::Validation {
field: format!("{field}.month"),
reason: format!("must be between 1 and 12, got {month}"),
});
}
Ok(())
}
}
}
#[cold]
fn reject_weekend(field: &str, weekday: Weekday) -> Result<(), ChainError> {
match weekday {
Weekday::Sat | Weekday::Sun => Err(ChainError::Validation {
field: field.to_string(),
reason: format!("{weekday} is never an eligible expiration day under weekdays_v1"),
}),
_ => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ActiveExpiry {
pub(crate) expires_at: DateTime<Utc>,
pub(crate) labels: Vec<String>,
}
impl ActiveExpiry {
pub(crate) fn days_to_expiration(
&self,
simulated_at: DateTime<Utc>,
) -> Result<Decimal, ChainError> {
let seconds = self
.expires_at
.signed_duration_since(simulated_at)
.num_seconds();
if seconds <= 0 {
return Err(ChainError::Internal(format!(
"expiration {} is not after the simulated instant {simulated_at}",
self.expires_at
)));
}
Decimal::from(seconds)
.checked_div(SECONDS_PER_DAY)
.ok_or_else(|| {
ChainError::Internal(format!(
"days to expiration for {} does not fit in a decimal",
self.expires_at
))
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RollingPlanner<'a> {
schedule: &'a ExpirationSchedule,
}
impl<'a> RollingPlanner<'a> {
#[must_use]
pub(crate) fn new(schedule: &'a ExpirationSchedule) -> Self {
Self { schedule }
}
pub(crate) fn active_at(
&self,
simulated_at: DateTime<Utc>,
) -> Result<Vec<ActiveExpiry>, ChainError> {
let mut merged: BTreeMap<DateTime<Utc>, BTreeSet<String>> = BTreeMap::new();
for rule in &self.schedule.rules {
for expires_at in self.project_rule(rule, simulated_at)? {
merged
.entry(expires_at)
.or_default()
.insert(rule.rule_id.clone());
}
}
Ok(merged
.into_iter()
.map(|(expires_at, labels)| ActiveExpiry {
expires_at,
labels: labels.into_iter().collect(),
})
.collect())
}
fn project_rule(
&self,
rule: &ExpiryRule,
simulated_at: DateTime<Utc>,
) -> Result<BTreeSet<DateTime<Utc>>, ChainError> {
let wanted = rule.target_count.get();
let start = simulated_at
.with_timezone(&self.schedule.timezone)
.date_naive();
let mut found: BTreeSet<DateTime<Utc>> = BTreeSet::new();
match &rule.kind {
ExpiryRuleKind::Daily => {
self.scan_days(rule, start, simulated_at, wanted, &mut found, |_| true)?;
}
ExpiryRuleKind::Weekly { weekdays } => {
self.scan_days(rule, start, simulated_at, wanted, &mut found, |date| {
weekdays.contains(&date.weekday())
})?;
}
ExpiryRuleKind::Monthly { weekday } => {
self.scan_periods(rule, simulated_at, wanted, &mut found, |index| {
let (year, month) = add_months(start.year(), start.month(), index)
.map_err(|reason| projection_error(rule, reason))?;
last_weekday_of_month(rule, year, month, *weekday).map(Some)
})?;
}
ExpiryRuleKind::Yearly { weekday, month } => {
self.scan_periods(rule, simulated_at, wanted, &mut found, |index| {
let year = add_years(start.year(), index)
.map_err(|reason| projection_error(rule, reason))?;
last_weekday_of_month(rule, year, *month, *weekday).map(Some)
})?;
}
}
if found.len() < wanted {
return Err(projection_error(
rule,
format!("could only project {} of {wanted} expirations", found.len()),
));
}
Ok(found)
}
fn scan_days<F>(
&self,
rule: &ExpiryRule,
start: NaiveDate,
simulated_at: DateTime<Utc>,
wanted: usize,
found: &mut BTreeSet<DateTime<Utc>>,
accepts: F,
) -> Result<(), ChainError>
where
F: Fn(NaiveDate) -> bool,
{
let budget = wanted
.checked_mul(8)
.and_then(|days| days.checked_add(DAY_SCAN_SLACK))
.ok_or_else(|| projection_error(rule, "day scan budget overflows".to_string()))?;
let mut date = start;
for _ in 0..budget {
if accepts(date)
&& let Some(eligible) = self.schedule.calendar.eligible_date(date)
{
let expires_at = self.instant_for(rule, eligible)?;
if expires_at > simulated_at {
found.insert(expires_at);
}
}
if found.len() >= wanted {
return Ok(());
}
date = date
.checked_add_days(Days::new(1))
.ok_or_else(|| projection_error(rule, "date arithmetic overflows".to_string()))?;
}
Ok(())
}
fn scan_periods<F>(
&self,
rule: &ExpiryRule,
simulated_at: DateTime<Utc>,
wanted: usize,
found: &mut BTreeSet<DateTime<Utc>>,
date_for: F,
) -> Result<(), ChainError>
where
F: Fn(u32) -> Result<Option<NaiveDate>, ChainError>,
{
let budget = wanted
.checked_add(PERIOD_SCAN_SLACK)
.ok_or_else(|| projection_error(rule, "period scan budget overflows".to_string()))?;
let budget = u32::try_from(budget)
.map_err(|_| projection_error(rule, "period scan budget overflows".to_string()))?;
for index in 0..budget {
if found.len() >= wanted {
return Ok(());
}
let Some(date) = date_for(index)? else {
continue;
};
let Some(eligible) = self.schedule.calendar.eligible_date(date) else {
continue;
};
let expires_at = self.instant_for(rule, eligible)?;
if expires_at > simulated_at {
found.insert(expires_at);
}
}
Ok(())
}
fn instant_for(&self, rule: &ExpiryRule, date: NaiveDate) -> Result<DateTime<Utc>, ChainError> {
let local = date.and_time(self.schedule.expiration_time);
if let Some(resolved) = self
.schedule
.timezone
.from_local_datetime(&local)
.earliest()
{
return Ok(resolved.with_timezone(&Utc));
}
GapInfo::new(&local, &self.schedule.timezone)
.and_then(|gap| gap.end)
.map(|end| end.with_timezone(&Utc))
.ok_or_else(|| {
projection_error(
rule,
format!(
"local time {local} does not exist in {} and no end of the transition gap is known",
self.schedule.timezone.name()
),
)
})
}
}
#[cold]
fn projection_error(rule: &ExpiryRule, reason: String) -> ChainError {
ChainError::Validation {
field: format!("schedules.{}", rule.rule_id),
reason,
}
}
fn add_months(year: i32, month: u32, offset: u32) -> Result<(i32, u32), String> {
let zero_based = month.checked_sub(1).ok_or("month underflows")?;
let total = zero_based
.checked_add(offset)
.ok_or("month arithmetic overflows")?;
let years = i32::try_from(total / 12).map_err(|_| "year arithmetic overflows")?;
let year = year.checked_add(years).ok_or("year arithmetic overflows")?;
let month = (total % 12)
.checked_add(1)
.ok_or("month arithmetic overflows")?;
Ok((year, month))
}
fn add_years(year: i32, offset: u32) -> Result<i32, String> {
let offset = i32::try_from(offset).map_err(|_| "year arithmetic overflows")?;
year.checked_add(offset)
.ok_or_else(|| "year arithmetic overflows".to_string())
}
fn last_weekday_of_month(
rule: &ExpiryRule,
year: i32,
month: u32,
weekday: Weekday,
) -> Result<NaiveDate, ChainError> {
let (next_year, next_month) =
add_months(year, month, 1).map_err(|reason| projection_error(rule, reason))?;
let first_of_next = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or_else(|| {
projection_error(
rule,
format!("{next_year}-{next_month:02} is outside the representable date range"),
)
})?;
let last_of_month = first_of_next
.checked_sub_days(Days::new(1))
.ok_or_else(|| projection_error(rule, "date arithmetic underflows".to_string()))?;
let back =
(last_of_month.weekday().num_days_from_monday() + 7 - weekday.num_days_from_monday()) % 7;
last_of_month
.checked_sub_days(Days::new(u64::from(back)))
.ok_or_else(|| projection_error(rule, "date arithmetic underflows".to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono_tz::Africa::Cairo;
use chrono_tz::America::New_York;
use chrono_tz::Australia::Lord_Howe;
use chrono_tz::Europe::Madrid;
use rust_decimal_macros::dec;
fn at_1700() -> NaiveTime {
match NaiveTime::from_hms_opt(17, 0, 0) {
Some(time) => time,
None => panic!("17:00:00 must be a valid time"),
}
}
fn utc(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> DateTime<Utc> {
match Utc
.with_ymd_and_hms(year, month, day, hour, minute, 0)
.single()
{
Some(instant) => instant,
None => panic!(
"{year}-{month:02}-{day:02} {hour:02}:{minute:02} must be a valid UTC instant"
),
}
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
match NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => date,
None => panic!("{year}-{month:02}-{day:02} must be a valid date"),
}
}
fn rule(id: &str, kind: ExpiryRuleKind, count: usize) -> ExpiryRule {
match ExpiryRule::new(id, kind, count) {
Ok(rule) => rule,
Err(error) => panic!("test rule must be valid: {error}"),
}
}
fn ny_schedule(rules: Vec<ExpiryRule>) -> ExpirationSchedule {
match ExpirationSchedule::new(CalendarVersion::WeekdaysV1, New_York, at_1700(), rules) {
Ok(schedule) => schedule,
Err(error) => panic!("test schedule must be valid: {error}"),
}
}
fn reference_schedule() -> ExpirationSchedule {
ny_schedule(vec![
rule("zero_dte", ExpiryRuleKind::Daily, 1),
rule(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
3,
),
rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
12,
),
])
}
fn active(schedule: &ExpirationSchedule, at: DateTime<Utc>) -> Vec<ActiveExpiry> {
match RollingPlanner::new(schedule).active_at(at) {
Ok(expiries) => expiries,
Err(error) => panic!("planner must project: {error}"),
}
}
fn instant_of(schedule: &ExpirationSchedule, day: NaiveDate) -> DateTime<Utc> {
let planner = RollingPlanner::new(schedule);
let probe = rule("probe", ExpiryRuleKind::Daily, 1);
match planner.instant_for(&probe, day) {
Ok(instant) => instant,
Err(error) => panic!("local time must resolve: {error}"),
}
}
fn schedule_in(zone: Tz, time: NaiveTime) -> ExpirationSchedule {
match ExpirationSchedule::new(
CalendarVersion::WeekdaysV1,
zone,
time,
vec![rule("probe", ExpiryRuleKind::Daily, 1)],
) {
Ok(schedule) => schedule,
Err(error) => panic!("test schedule must be valid: {error}"),
}
}
#[test]
fn test_daily_before_cutoff_keeps_same_day_expiry() {
let schedule = ny_schedule(vec![rule("zero_dte", ExpiryRuleKind::Daily, 1)]);
let expiries = active(&schedule, utc(2026, 1, 5, 21, 59));
assert_eq!(expiries.len(), 1);
assert_eq!(expiries[0].expires_at, utc(2026, 1, 5, 22, 0));
assert_eq!(expiries[0].labels, vec!["zero_dte".to_string()]);
}
#[test]
fn test_daily_at_cutoff_rolls_to_next_eligible_day() {
let schedule = ny_schedule(vec![rule("zero_dte", ExpiryRuleKind::Daily, 1)]);
let expiries = active(&schedule, utc(2026, 1, 5, 22, 0));
assert_eq!(expiries.len(), 1);
assert_eq!(expiries[0].expires_at, utc(2026, 1, 6, 22, 0));
}
#[test]
fn test_daily_after_cutoff_matches_cutoff_behaviour() {
let schedule = ny_schedule(vec![rule("zero_dte", ExpiryRuleKind::Daily, 1)]);
let at_cutoff = active(&schedule, utc(2026, 1, 5, 22, 0));
let after_cutoff = active(
&schedule,
utc(2026, 1, 5, 22, 0) + chrono::Duration::seconds(1),
);
assert_eq!(at_cutoff, after_cutoff);
}
#[test]
fn test_daily_rolls_over_the_weekend_to_monday() {
let schedule = ny_schedule(vec![rule("zero_dte", ExpiryRuleKind::Daily, 1)]);
let expiries = active(&schedule, utc(2026, 1, 9, 22, 0));
assert_eq!(expiries.len(), 1);
assert_eq!(expiries[0].expires_at, utc(2026, 1, 12, 22, 0));
}
#[test]
fn test_daily_never_emits_a_weekend_expiry() {
let schedule = ny_schedule(vec![rule("zero_dte", ExpiryRuleKind::Daily, 5)]);
let expiries = active(&schedule, utc(2026, 1, 8, 12, 0));
assert_eq!(expiries.len(), 5);
for expiry in &expiries {
let weekday = expiry.expires_at.with_timezone(&New_York).weekday();
assert!(
!matches!(weekday, Weekday::Sat | Weekday::Sun),
"unexpected weekend expiry {}",
expiry.expires_at
);
}
}
#[test]
fn test_weekly_returns_the_next_three_rule_expirations() {
let schedule = ny_schedule(vec![rule(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
3,
)]);
let expiries = active(&schedule, utc(2026, 1, 5, 14, 30));
let dates: Vec<DateTime<Utc>> = expiries.iter().map(|e| e.expires_at).collect();
assert_eq!(
dates,
vec![
utc(2026, 1, 5, 22, 0), utc(2026, 1, 7, 22, 0), utc(2026, 1, 9, 22, 0), ]
);
}
#[test]
fn test_weekly_replenishes_in_the_same_result() {
let schedule = ny_schedule(vec![rule(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
3,
)]);
let expiries = active(&schedule, utc(2026, 1, 5, 22, 0));
let dates: Vec<DateTime<Utc>> = expiries.iter().map(|e| e.expires_at).collect();
assert_eq!(
dates,
vec![
utc(2026, 1, 7, 22, 0), utc(2026, 1, 9, 22, 0), utc(2026, 1, 12, 22, 0), ]
);
}
#[test]
fn test_weekly_single_weekday_reaches_a_large_count() {
let schedule = ny_schedule(vec![rule(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Fri]),
52,
)]);
let expiries = active(&schedule, utc(2026, 1, 5, 14, 30));
assert_eq!(expiries.len(), 52);
for expiry in &expiries {
assert_eq!(
expiry.expires_at.with_timezone(&New_York).weekday(),
Weekday::Fri
);
}
}
#[test]
fn test_weekly_weekday_set_is_normalised() {
let kind = ExpiryRuleKind::weekly([Weekday::Fri, Weekday::Mon, Weekday::Fri]);
match kind {
ExpiryRuleKind::Weekly { weekdays } => {
assert_eq!(weekdays, vec![Weekday::Mon, Weekday::Fri]);
}
other => panic!("expected a weekly rule, got {other:?}"),
}
}
#[test]
fn test_monthly_last_friday_projects_twelve() {
let schedule = ny_schedule(vec![rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
12,
)]);
let expiries = active(&schedule, utc(2026, 1, 5, 14, 30));
assert_eq!(expiries.len(), 12);
assert_eq!(expiries[0].expires_at, utc(2026, 1, 30, 22, 0));
assert_eq!(expiries[1].expires_at, utc(2026, 2, 27, 22, 0));
assert_eq!(expiries[2].expires_at, utc(2026, 3, 27, 21, 0));
assert_eq!(expiries[11].expires_at, utc(2026, 12, 25, 22, 0));
}
#[test]
fn test_monthly_count_survives_month_and_year_boundaries() {
let schedule = ny_schedule(vec![rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
12,
)]);
let expiries = active(&schedule, utc(2026, 1, 30, 22, 0));
assert_eq!(expiries.len(), 12);
assert_eq!(expiries[0].expires_at, utc(2026, 2, 27, 22, 0));
assert_eq!(expiries[11].expires_at, utc(2027, 1, 29, 22, 0));
}
#[test]
fn test_monthly_handles_a_leap_year_february() {
let schedule = ny_schedule(vec![rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
1,
)]);
let expiries = active(&schedule, utc(2028, 2, 1, 14, 30));
assert_eq!(expiries.len(), 1);
assert_eq!(expiries[0].expires_at, utc(2028, 2, 25, 22, 0));
}
#[test]
fn test_monthly_count_is_stable_across_a_whole_year() {
let schedule = ny_schedule(vec![rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
12,
)]);
for month in 1..=12 {
let expiries = active(&schedule, utc(2026, month, 15, 12, 0));
assert_eq!(expiries.len(), 12, "month {month} lost inventory");
}
}
#[test]
fn test_yearly_reaches_beyond_one_year() {
let schedule = ny_schedule(vec![rule(
"leaps",
ExpiryRuleKind::Yearly {
weekday: Weekday::Fri,
month: 12,
},
2,
)]);
let simulated_at = utc(2026, 1, 5, 14, 30);
let expiries = active(&schedule, simulated_at);
assert_eq!(expiries.len(), 2);
assert_eq!(expiries[0].expires_at, utc(2026, 12, 25, 22, 0));
assert_eq!(expiries[1].expires_at, utc(2027, 12, 31, 22, 0));
assert!(expiries[1].expires_at - simulated_at > chrono::Duration::days(365));
}
#[test]
fn test_yearly_skips_a_month_already_past() {
let schedule = ny_schedule(vec![rule(
"leaps",
ExpiryRuleKind::Yearly {
weekday: Weekday::Fri,
month: 1,
},
1,
)]);
let expiries = active(&schedule, utc(2026, 6, 1, 12, 0));
assert_eq!(expiries.len(), 1);
assert_eq!(expiries[0].expires_at, utc(2027, 1, 29, 22, 0));
}
#[test]
fn test_coincident_expiry_is_deduplicated_and_carries_both_labels() {
let schedule = reference_schedule();
let expiries = active(&schedule, utc(2026, 1, 5, 14, 30));
assert_eq!(expiries.len(), 15);
let first = &expiries[0];
assert_eq!(first.expires_at, utc(2026, 1, 5, 22, 0));
assert_eq!(
first.labels,
vec!["weeklies".to_string(), "zero_dte".to_string()]
);
}
#[test]
fn test_every_rule_keeps_its_full_count_after_deduplication() {
let schedule = reference_schedule();
let expiries = active(&schedule, utc(2026, 1, 5, 14, 30));
for (rule_id, expected) in [("zero_dte", 1), ("weeklies", 3), ("monthlies", 12)] {
let count = expiries
.iter()
.filter(|expiry| expiry.labels.iter().any(|label| label == rule_id))
.count();
assert_eq!(count, expected, "rule {rule_id} lost inventory to dedup");
}
}
#[test]
fn test_results_are_chronological_and_unique() {
let schedule = reference_schedule();
let expiries = active(&schedule, utc(2026, 1, 5, 14, 30));
assert!(!expiries.is_empty());
for pair in expiries.windows(2) {
assert!(
pair[0].expires_at < pair[1].expires_at,
"results must be strictly increasing"
);
}
}
#[test]
fn test_days_to_expiration_is_fractional_and_exact() {
let schedule = ny_schedule(vec![rule("zero_dte", ExpiryRuleKind::Daily, 1)]);
let simulated_at = utc(2026, 1, 6, 14, 30);
let expiries = active(&schedule, simulated_at);
assert_eq!(expiries.len(), 1);
match expiries[0].days_to_expiration(simulated_at) {
Ok(days) => assert_eq!(days, dec!(0.3125)),
Err(error) => panic!("must compute days to expiration: {error}"),
}
}
#[test]
fn test_days_to_expiration_is_positive_for_every_active_expiry() {
let schedule = reference_schedule();
let simulated_at = utc(2026, 1, 5, 14, 30);
for expiry in active(&schedule, simulated_at) {
match expiry.days_to_expiration(simulated_at) {
Ok(days) => assert!(days > Decimal::ZERO, "non-positive DTE for {expiry:?}"),
Err(error) => panic!("must compute days to expiration: {error}"),
}
}
}
#[test]
fn test_days_to_expiration_rejects_a_non_future_instant() {
let expiry = ActiveExpiry {
expires_at: utc(2026, 1, 5, 22, 0),
labels: vec!["zero_dte".to_string()],
};
match expiry.days_to_expiration(utc(2026, 1, 5, 22, 0)) {
Err(ChainError::Internal(reason)) => assert!(reason.contains("not after")),
other => panic!("expected an internal error, got {other:?}"),
}
}
#[test]
fn test_dst_gap_resolves_to_the_first_instant_after_the_gap() {
let time = match NaiveTime::from_hms_opt(2, 30, 0) {
Some(time) => time,
None => panic!("02:30:00 must be a valid time"),
};
let schedule = schedule_in(Madrid, time);
assert_eq!(
instant_of(&schedule, date(2026, 3, 29)),
utc(2026, 3, 29, 1, 0)
);
}
#[test]
fn test_dst_fold_resolves_to_the_earlier_instant() {
let time = match NaiveTime::from_hms_opt(2, 30, 0) {
Some(time) => time,
None => panic!("02:30:00 must be a valid time"),
};
let schedule = schedule_in(Madrid, time);
assert_eq!(
instant_of(&schedule, date(2026, 10, 25)),
utc(2026, 10, 25, 0, 30)
);
}
#[test]
fn test_dst_gap_handles_a_thirty_minute_transition() {
let time = match NaiveTime::from_hms_opt(2, 15, 0) {
Some(time) => time,
None => panic!("02:15:00 must be a valid time"),
};
let schedule = schedule_in(Lord_Howe, time);
assert_eq!(
instant_of(&schedule, date(2026, 10, 4)),
utc(2026, 10, 3, 15, 30)
);
}
#[test]
fn test_dst_gap_is_resolved_through_the_public_projection_path() {
let time = match NaiveTime::from_hms_opt(0, 30, 0) {
Some(time) => time,
None => panic!("00:30:00 must be a valid time"),
};
let schedule = schedule_in(Cairo, time);
let expiries = active(&schedule, utc(2026, 4, 23, 12, 0));
assert_eq!(expiries.len(), 1);
assert_eq!(expiries[0].expires_at, utc(2026, 4, 23, 22, 0));
assert_eq!(
expiries[0].expires_at.with_timezone(&Cairo).weekday(),
Weekday::Fri
);
}
#[test]
fn test_local_expiration_time_is_stable_across_a_dst_boundary() {
let schedule = ny_schedule(vec![rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
4,
)]);
let expiries = active(&schedule, utc(2026, 1, 5, 14, 30));
assert_eq!(expiries.len(), 4);
for expiry in &expiries {
let local = expiry.expires_at.with_timezone(&New_York);
assert_eq!(local.time(), at_1700(), "local expiration time drifted");
}
}
#[test]
fn test_projection_is_stable_across_repeated_calls() {
let schedule = reference_schedule();
let at = utc(2026, 1, 5, 14, 30);
assert_eq!(active(&schedule, at), active(&schedule, at));
}
#[test]
fn test_rule_order_does_not_change_the_result() {
let forwards = reference_schedule();
let backwards = ny_schedule(vec![
rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
12,
),
rule(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Fri, Weekday::Wed, Weekday::Mon]),
3,
),
rule("zero_dte", ExpiryRuleKind::Daily, 1),
]);
let at = utc(2026, 1, 5, 14, 30);
assert_eq!(active(&forwards, at), active(&backwards, at));
}
#[test]
fn test_result_depends_only_on_the_absolute_instant() {
let schedule = reference_schedule();
let as_utc = utc(2026, 1, 5, 14, 30);
let same_instant_elsewhere = as_utc.with_timezone(&Madrid).with_timezone(&Utc);
assert_eq!(
active(&schedule, as_utc),
active(&schedule, same_instant_elsewhere)
);
}
#[test]
fn test_zero_target_count_is_rejected() {
match ExpiryRule::new("zero_dte", ExpiryRuleKind::Daily, 0) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.zero_dte.target_count");
assert!(reason.contains("at least 1"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_excessive_target_count_is_rejected() {
match ExpiryRule::new("zero_dte", ExpiryRuleKind::Daily, MAX_TARGET_COUNT + 1) {
Err(ChainError::Validation { field, .. }) => {
assert_eq!(field, "schedules.zero_dte.target_count");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_duplicate_rule_id_is_rejected() {
match ExpirationSchedule::new(
CalendarVersion::WeekdaysV1,
New_York,
at_1700(),
vec![
rule("same", ExpiryRuleKind::Daily, 1),
rule("same", ExpiryRuleKind::weekly([Weekday::Mon]), 1),
],
) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.same.rule_id");
assert!(reason.contains("unique"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_empty_rule_id_is_rejected() {
match ExpiryRule::new("", ExpiryRuleKind::Daily, 1) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.rule_id");
assert!(reason.contains("empty"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_overlong_rule_id_is_rejected() {
let long = "a".repeat(MAX_RULE_ID_LEN + 1);
match ExpiryRule::new(long, ExpiryRuleKind::Daily, 1) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.rule_id");
assert!(reason.contains("characters"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_rule_id_with_a_label_separator_is_rejected() {
match ExpiryRule::new("weekly|monthly", ExpiryRuleKind::Daily, 1) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.rule_id");
assert!(reason.contains("[A-Za-z0-9_-]"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_empty_schedule_is_rejected() {
match ExpirationSchedule::new(CalendarVersion::WeekdaysV1, New_York, at_1700(), vec![]) {
Err(ChainError::Validation { field, .. }) => assert_eq!(field, "schedules"),
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_too_many_rules_is_rejected() {
let rules = (0..=MAX_SCHEDULE_RULES)
.map(|index| rule(&format!("rule_{index}"), ExpiryRuleKind::Daily, 1))
.collect();
match ExpirationSchedule::new(CalendarVersion::WeekdaysV1, New_York, at_1700(), rules) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules");
assert!(reason.contains("rules"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_excessive_total_inventory_is_rejected() {
let rules = (0..4)
.map(|index| {
rule(
&format!("rule_{index}"),
ExpiryRuleKind::Daily,
MAX_TARGET_COUNT,
)
})
.collect();
match ExpirationSchedule::new(CalendarVersion::WeekdaysV1, New_York, at_1700(), rules) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules");
assert!(reason.contains("total expiration count"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_weekly_without_weekdays_is_rejected() {
match ExpiryRule::new("weeklies", ExpiryRuleKind::weekly([]), 1) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.weeklies.weekly.weekdays");
assert!(reason.contains("at least one weekday"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_weekly_naming_a_weekend_day_is_rejected() {
match ExpiryRule::new(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Sat]),
1,
) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.weeklies.weekly.weekdays");
assert!(reason.contains("Sat"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_monthly_naming_a_weekend_day_is_rejected() {
match ExpiryRule::new(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Sun,
},
1,
) {
Err(ChainError::Validation { field, .. }) => {
assert_eq!(field, "schedules.monthlies.monthly.weekday");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_yearly_naming_a_weekend_day_is_rejected() {
match ExpiryRule::new(
"leaps",
ExpiryRuleKind::Yearly {
weekday: Weekday::Sat,
month: 12,
},
1,
) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.leaps.yearly.weekday");
assert!(reason.contains("Sat"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_yearly_with_an_invalid_month_is_rejected() {
match ExpiryRule::new(
"leaps",
ExpiryRuleKind::Yearly {
weekday: Weekday::Fri,
month: 13,
},
1,
) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "schedules.leaps.yearly.month");
assert!(reason.contains("between 1 and 12"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_month_arithmetic_overflow_is_a_typed_error() {
match add_months(i32::MAX, 12, 12) {
Err(reason) => assert!(reason.contains("overflow")),
Ok(result) => panic!("expected an overflow error, got {result:?}"),
}
}
#[test]
fn test_year_arithmetic_overflow_is_a_typed_error() {
match add_years(i32::MAX, 1) {
Err(reason) => assert!(reason.contains("overflow")),
Ok(result) => panic!("expected an overflow error, got {result:?}"),
}
}
#[test]
fn test_month_arithmetic_rolls_into_the_next_year() {
match add_months(2026, 12, 1) {
Ok((year, month)) => {
assert_eq!(year, 2027);
assert_eq!(month, 1);
}
Err(reason) => panic!("must not fail: {reason}"),
}
}
#[test]
fn test_unreachable_projection_reports_the_rule() {
let schedule = ny_schedule(vec![rule(
"leaps",
ExpiryRuleKind::Yearly {
weekday: Weekday::Fri,
month: 12,
},
200,
)]);
let far_future = match Utc.with_ymd_and_hms(262_000, 1, 1, 0, 0, 0).single() {
Some(instant) => instant,
None => panic!("262000-01-01 must be representable"),
};
match RollingPlanner::new(&schedule).active_at(far_future) {
Err(ChainError::Validation { field, .. }) => {
assert_eq!(field, "schedules.leaps");
}
other => panic!("expected a projection error, got {other:?}"),
}
}
#[test]
fn test_weekdays_v1_eligibility_rejects_weekends_only() {
assert_eq!(
CalendarVersion::WeekdaysV1.eligible_date(date(2026, 1, 10)),
None
);
assert_eq!(
CalendarVersion::WeekdaysV1.eligible_date(date(2026, 1, 11)),
None
);
assert_eq!(
CalendarVersion::WeekdaysV1.eligible_date(date(2026, 1, 12)),
Some(date(2026, 1, 12))
);
}
#[test]
fn test_tzdb_version_is_exposed_and_non_empty() {
let version = tzdb_version();
assert!(!version.is_empty());
assert!(
version.starts_with(|c: char| c.is_ascii_digit()),
"expected an IANA release such as 2025b, got {version:?}"
);
}
#[test]
fn test_calendar_version_serde_round_trip() {
let json = match serde_json::to_string(&CalendarVersion::WeekdaysV1) {
Ok(json) => json,
Err(error) => panic!("must serialize: {error}"),
};
assert_eq!(json, "\"weekdays_v1\"");
assert_eq!(CalendarVersion::WeekdaysV1.as_str(), "weekdays_v1");
match serde_json::from_str::<CalendarVersion>(&json) {
Ok(parsed) => assert_eq!(parsed, CalendarVersion::WeekdaysV1),
Err(error) => panic!("must deserialize: {error}"),
}
}
#[test]
fn test_stored_rule_shape_is_flat_and_tagged_by_kind() {
let monthly = rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
12,
);
let daily = rule("zero_dte", ExpiryRuleKind::Daily, 1);
match serde_json::to_value(&monthly) {
Ok(value) => assert_eq!(
value,
serde_json::json!({
"rule_id": "monthlies",
"kind": "monthly",
"weekday": "Fri",
"target_count": 12
})
),
Err(error) => panic!("must serialize: {error}"),
}
match serde_json::to_value(&daily) {
Ok(value) => assert_eq!(
value,
serde_json::json!({
"rule_id": "zero_dte",
"kind": "daily",
"target_count": 1
})
),
Err(error) => panic!("must serialize: {error}"),
}
}
#[test]
fn test_schedule_serde_round_trip() {
let schedule = reference_schedule();
let json = match serde_json::to_string(&schedule) {
Ok(json) => json,
Err(error) => panic!("must serialize: {error}"),
};
match serde_json::from_str::<ExpirationSchedule>(&json) {
Ok(parsed) => assert_eq!(parsed, schedule),
Err(error) => panic!("must deserialize: {error}"),
}
}
#[test]
fn test_deserialization_rejects_an_out_of_range_target_count() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"rules": [
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 100000000000 }
]
}"#;
assert!(serde_json::from_str::<ExpirationSchedule>(json).is_err());
}
#[test]
fn test_deserialization_rejects_a_weekend_weekday() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"rules": [
{ "rule_id": "monthlies", "kind": "monthly", "weekday": "Sat", "target_count": 1 }
]
}"#;
assert!(serde_json::from_str::<ExpirationSchedule>(json).is_err());
}
#[test]
fn test_deserialization_rejects_an_empty_schedule() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"rules": []
}"#;
assert!(serde_json::from_str::<ExpirationSchedule>(json).is_err());
}
#[test]
fn test_deserialization_normalises_rule_order() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"rules": [
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 1 },
{ "rule_id": "monthlies", "kind": "monthly", "weekday": "Fri", "target_count": 1 }
]
}"#;
match serde_json::from_str::<ExpirationSchedule>(json) {
Ok(schedule) => {
let ids: Vec<&str> = schedule.rules().iter().map(ExpiryRule::rule_id).collect();
assert_eq!(ids, vec!["monthlies", "zero_dte"]);
}
Err(error) => panic!("must deserialize: {error}"),
}
}
#[test]
fn test_deserialization_normalises_weekly_weekdays() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"rules": [
{ "rule_id": "weeklies", "kind": "weekly", "target_count": 1,
"weekdays": ["Fri", "Mon", "Fri", "Wed"] }
]
}"#;
match serde_json::from_str::<ExpirationSchedule>(json) {
Ok(schedule) => match schedule.rules().first().map(ExpiryRule::kind) {
Some(ExpiryRuleKind::Weekly { weekdays }) => assert_eq!(
*weekdays,
vec![Weekday::Mon, Weekday::Wed, Weekday::Fri],
"the stored set must be deduplicated and Monday-first"
),
other => panic!("must load a weekly rule, got {other:?}"),
},
Err(error) => panic!("must deserialize: {error}"),
}
}
#[test]
fn test_deserialization_rejects_an_unknown_rule_field() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"rules": [
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 1,
"weekday": "Fri" }
]
}"#;
match serde_json::from_str::<ExpirationSchedule>(json) {
Ok(schedule) => panic!("must reject the stray field, got {schedule:?}"),
Err(error) => assert!(
error.to_string().contains("weekday"),
"the error must name the offending field, got {error}"
),
}
}
#[test]
fn test_deserialization_rejects_an_unknown_schedule_field() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"not_a_schedule_field": true,
"rules": [
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 1 }
]
}"#;
assert!(serde_json::from_str::<ExpirationSchedule>(json).is_err());
}
#[test]
fn test_deserialization_defaults_the_yearly_month() {
let json = r#"{
"calendar": "weekdays_v1",
"timezone": "America/New_York",
"expiration_time": "17:00:00",
"rules": [
{ "rule_id": "leaps", "kind": "yearly", "target_count": 1, "weekday": "Fri" }
]
}"#;
match serde_json::from_str::<ExpirationSchedule>(json) {
Ok(schedule) => assert_eq!(
schedule.rules().first().map(ExpiryRule::kind),
Some(&ExpiryRuleKind::yearly(Weekday::Fri)),
"an omitted month must default to December on both paths"
),
Err(error) => panic!("must deserialize: {error}"),
}
}
#[test]
fn test_every_rule_kind_survives_a_round_trip() {
let kinds = vec![
ExpiryRuleKind::Daily,
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
ExpiryRuleKind::yearly(Weekday::Fri),
];
for kind in kinds {
let schedule = ny_schedule(vec![rule("only", kind.clone(), 1)]);
let json = match serde_json::to_string(&schedule) {
Ok(json) => json,
Err(error) => panic!("must serialize {kind:?}: {error}"),
};
match serde_json::from_str::<ExpirationSchedule>(&json) {
Ok(loaded) => assert_eq!(loaded, schedule, "{kind:?} must round-trip"),
Err(error) => panic!("must deserialize {kind:?} from {json}: {error}"),
}
}
}
#[test]
fn test_schedule_rules_are_ordered_by_id() {
let schedule = ny_schedule(vec![
rule("zero_dte", ExpiryRuleKind::Daily, 1),
rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
1,
),
]);
let ids: Vec<&str> = schedule.rules().iter().map(ExpiryRule::rule_id).collect();
assert_eq!(ids, vec!["monthlies", "zero_dte"]);
}
#[test]
fn test_schedule_accessors_expose_the_constructed_values() {
let schedule = reference_schedule();
assert_eq!(schedule.calendar(), CalendarVersion::WeekdaysV1);
assert_eq!(schedule.timezone(), New_York);
assert_eq!(schedule.expiration_time(), at_1700());
assert_eq!(schedule.rules().len(), 3);
let first = match schedule.rules().first() {
Some(rule) => rule,
None => panic!("the reference schedule has three rules"),
};
assert_eq!(first.rule_id(), "monthlies");
assert_eq!(first.target_count().get(), 12);
assert_eq!(
first.kind(),
&ExpiryRuleKind::Monthly {
weekday: Weekday::Fri
}
);
}
}