use time::{Date, Month};
use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId};
use crate::cycle_schedule::identity::{
GeneratedCycle, GeneratedCycleId, NextCycleKey, ScheduleIndex, cadence_rule,
};
use crate::error::render_diagnostics;
use crate::recurrence::rule::{DateKey, RecurrenceRule, RuleContext, RuleDiagnostic};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScheduleWarningKind {
Ended,
Due,
LookAhead,
Failure,
}
impl ScheduleWarningKind {
pub const ALL: [Self; 4] = [Self::Ended, Self::Due, Self::LookAhead, Self::Failure];
pub const fn as_str(self) -> &'static str {
match self {
Self::Ended => "ended",
Self::Due => "due",
Self::LookAhead => "look-ahead",
Self::Failure => "failure",
}
}
pub const fn offers_actions(self) -> bool {
!matches!(self, Self::Failure)
}
const fn rank(self) -> u8 {
match self {
Self::Ended => 0,
Self::Due => 1,
Self::LookAhead => 2,
Self::Failure => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WarningAction {
Close,
Extend,
Skip,
Ignore,
Generate,
}
impl WarningAction {
pub const ALL: [Self; 5] = [
Self::Close,
Self::Extend,
Self::Skip,
Self::Ignore,
Self::Generate,
];
pub const INSTANCE: [Self; 3] = [Self::Close, Self::Extend, Self::Ignore];
pub const CANDIDATE: [Self; 2] = [Self::Generate, Self::Ignore];
pub const fn as_str(self) -> &'static str {
match self {
Self::Close => "close",
Self::Extend => "extend",
Self::Skip => "skip",
Self::Ignore => "ignore",
Self::Generate => "generate",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveDates {
pub start: String,
pub due: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScheduleDiagnosticKind {
MissingCadence,
InvalidCadence,
UnresolvableDate,
}
impl ScheduleDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::MissingCadence => "missing_cadence",
Self::InvalidCadence => "invalid_cadence",
Self::UnresolvableDate => "unresolvable_date",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleDiagnostic {
pub kind: ScheduleDiagnosticKind,
pub field: String,
pub message: String,
}
impl ScheduleDiagnostic {
fn missing_cadence(schedule: &CycleScheduleId) -> Self {
Self {
kind: ScheduleDiagnosticKind::MissingCadence,
field: format!("cycle_schedules.{schedule}.cadence.kind"),
message: format!(
"cadence.kind: schedule `{schedule}` names no cadence family, so no cadence \
can be resolved"
),
}
}
fn invalid_cadence(schedule: &CycleScheduleId, diagnostic: &RuleDiagnostic) -> Self {
let field = match diagnostic.field {
"anchor" => "start_date".to_string(),
"timezone" => "timezone".to_string(),
other => format!("cadence.{other}"),
};
Self {
kind: ScheduleDiagnosticKind::InvalidCadence,
field: format!("cycle_schedules.{schedule}.{field}"),
message: diagnostic.message.clone(),
}
}
fn unreadable_date(cycle: &GeneratedCycle, field: &str, value: &str) -> Self {
Self {
kind: ScheduleDiagnosticKind::UnresolvableDate,
field: format!("cycles.{}.{field}", cycle.name),
message: format!(
"{field}: cycle `{}` has `{value}`, which is not a `YYYY-MM-DD` calendar date, \
so its ended/due state cannot be decided",
cycle.name
),
}
}
#[must_use]
pub fn render(&self) -> String {
render_diagnostics(
"E-tix-cycle-schedule",
std::iter::once((self.kind.as_str(), self.message.as_str())),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleWarning {
pub schedule_id: CycleScheduleId,
pub cycle_id: Option<GeneratedCycleId>,
pub kind: ScheduleWarningKind,
pub effective_dates: Option<EffectiveDates>,
pub next_generation: Option<NextCycleKey>,
pub available_actions: Vec<WarningAction>,
pub diagnostic: Option<ScheduleDiagnostic>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CycleEvaluation {
warnings: Vec<ScheduleWarning>,
schedules_examined: usize,
}
impl CycleEvaluation {
#[must_use]
pub fn warnings(&self) -> &[ScheduleWarning] {
&self.warnings
}
#[must_use]
pub fn schedules_examined(&self) -> usize {
self.schedules_examined
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.warnings.is_empty()
}
pub fn warnings_of(
&self,
schedule: &CycleScheduleId,
) -> impl Iterator<Item = &ScheduleWarning> {
self.warnings
.iter()
.filter(move |warning| &warning.schedule_id == schedule)
}
pub fn warnings_with_kind(
&self,
kind: ScheduleWarningKind,
) -> impl Iterator<Item = &ScheduleWarning> {
self.warnings
.iter()
.filter(move |warning| warning.kind == kind)
}
#[must_use]
pub fn has_failures(&self) -> bool {
self.warnings
.iter()
.any(|warning| warning.kind == ScheduleWarningKind::Failure)
}
}
#[must_use]
pub fn evaluate(index: &ScheduleIndex, context: &RuleContext) -> CycleEvaluation {
let today = DateKey::from_date(context.today);
let mut warnings = Vec::new();
for (schedule, record) in index.schedules() {
let mut ordered = evaluate_schedule(index, schedule, record, &today);
ordered.sort_by_key(|warning| (warning.sequence.unwrap_or(u32::MAX), warning.kind.rank()));
warnings.extend(ordered.into_iter().map(|ordered| ordered.warning));
}
CycleEvaluation {
warnings,
schedules_examined: index.schedule_count(),
}
}
struct Ordered {
sequence: Option<u32>,
kind: ScheduleWarningKind,
warning: ScheduleWarning,
}
fn evaluate_schedule(
index: &ScheduleIndex,
schedule: &CycleScheduleId,
record: &CycleScheduleConfiguration,
today: &DateKey,
) -> Vec<Ordered> {
let Some(rule) = cadence_rule(record) else {
return vec![failure(
schedule,
None,
None,
ScheduleDiagnostic::missing_cadence(schedule),
)];
};
if let Err(err) = rule.validate() {
return err
.diagnostics()
.iter()
.map(|diagnostic| {
failure(
schedule,
None,
None,
ScheduleDiagnostic::invalid_cadence(schedule, diagnostic),
)
})
.collect();
}
let mut warnings = instance_warnings(index, schedule, today);
warnings.extend(look_ahead_warnings(index, schedule, record, &rule));
warnings
}
fn instance_warnings(
index: &ScheduleIndex,
schedule: &CycleScheduleId,
today: &DateKey,
) -> Vec<Ordered> {
let mut warnings = Vec::new();
for cycle in index.cycles_of(schedule) {
if !cycle.is_active() {
continue;
}
if let Some(diagnostic) = unreadable_date(cycle) {
warnings.push(failure(schedule, Some(cycle.id.clone()), None, diagnostic));
continue;
}
let kind = match cycle.due_date.as_str().cmp(today.as_str()) {
std::cmp::Ordering::Less => ScheduleWarningKind::Ended,
std::cmp::Ordering::Equal => ScheduleWarningKind::Due,
std::cmp::Ordering::Greater => continue,
};
warnings.push(Ordered {
sequence: Some(cycle.sequence),
kind,
warning: ScheduleWarning {
schedule_id: schedule.clone(),
cycle_id: Some(cycle.id.clone()),
kind,
effective_dates: Some(EffectiveDates {
start: cycle.start_date.clone(),
due: cycle.due_date.clone(),
}),
next_generation: None,
available_actions: WarningAction::INSTANCE.to_vec(),
diagnostic: None,
},
});
}
warnings
}
fn look_ahead_warnings(
index: &ScheduleIndex,
schedule: &CycleScheduleId,
record: &CycleScheduleConfiguration,
rule: &RecurrenceRule,
) -> Vec<Ordered> {
if record.look_ahead == 0 {
return Vec::new();
}
let Some(first) = index.next_key(schedule) else {
return Vec::new();
};
let available = GeneratedCycleId::SEQUENCE_LIMIT.saturating_sub(first.sequence);
let count = record.look_ahead.min(available);
let mut warnings = Vec::with_capacity(count as usize);
let mut date = first.start_date.clone();
for sequence in (first.sequence..).take(count as usize) {
let Some(start) = date.clone() else {
if last_instance_start_flagged(index, schedule) {
return Vec::new();
}
return vec![failure(
schedule,
None,
Some(candidate(schedule, sequence, None)),
unresolved_candidate(index, schedule),
)];
};
warnings.push(Ordered {
sequence: Some(sequence),
kind: ScheduleWarningKind::LookAhead,
warning: ScheduleWarning {
schedule_id: schedule.clone(),
cycle_id: None,
kind: ScheduleWarningKind::LookAhead,
effective_dates: None,
next_generation: Some(candidate(schedule, sequence, Some(start.clone()))),
available_actions: WarningAction::CANDIDATE.to_vec(),
diagnostic: None,
},
});
date = next_start(rule, &start);
}
warnings
}
fn next_start(rule: &RecurrenceRule, from: &str) -> Option<String> {
let date = as_date(from)?;
rule.next_occurrence(&RuleContext::on(date))
.ok()
.map(|key| key.as_str().to_string())
}
fn candidate(
schedule: &CycleScheduleId,
sequence: u32,
start_date: Option<String>,
) -> NextCycleKey {
NextCycleKey {
schedule_id: schedule.clone(),
sequence,
id: GeneratedCycleId::new(schedule, sequence),
start_date,
}
}
fn unresolved_candidate(index: &ScheduleIndex, schedule: &CycleScheduleId) -> ScheduleDiagnostic {
if let Some(cycle) = index.cycles_of(schedule).last()
&& !crate::model::validate_date(&cycle.start_date)
{
return ScheduleDiagnostic {
kind: ScheduleDiagnosticKind::UnresolvableDate,
field: format!("cycles.{}.start_date", cycle.name),
message: format!(
"start_date: cycle `{}` has `{}`, which is not a `YYYY-MM-DD` calendar date, so \
the next candidate date cannot be derived from it",
cycle.name, cycle.start_date
),
};
}
ScheduleDiagnostic {
kind: ScheduleDiagnosticKind::UnresolvableDate,
field: format!("cycle_schedules.{schedule}.cadence"),
message: format!("cadence: schedule `{schedule}` produced no next candidate date"),
}
}
fn last_instance_start_flagged(index: &ScheduleIndex, schedule: &CycleScheduleId) -> bool {
index
.cycles_of(schedule)
.last()
.is_some_and(|cycle| cycle.is_active() && !crate::model::validate_date(&cycle.start_date))
}
fn unreadable_date(cycle: &GeneratedCycle) -> Option<ScheduleDiagnostic> {
for (field, value) in [
("start_date", cycle.start_date.as_str()),
("due_date", cycle.due_date.as_str()),
] {
if !crate::model::validate_date(value) {
return Some(ScheduleDiagnostic::unreadable_date(cycle, field, value));
}
}
None
}
fn failure(
schedule: &CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
next_generation: Option<NextCycleKey>,
diagnostic: ScheduleDiagnostic,
) -> Ordered {
Ordered {
sequence: cycle_id
.as_ref()
.map(GeneratedCycleId::sequence)
.or_else(|| next_generation.as_ref().map(|key| key.sequence)),
kind: ScheduleWarningKind::Failure,
warning: ScheduleWarning {
schedule_id: schedule.clone(),
cycle_id,
kind: ScheduleWarningKind::Failure,
effective_dates: None,
next_generation,
available_actions: Vec::new(),
diagnostic: Some(diagnostic),
},
}
}
fn as_date(value: &str) -> Option<Date> {
if !crate::model::validate_date(value) {
return None;
}
let year: i32 = value.get(0..4)?.parse().ok()?;
let month: u8 = value.get(5..7)?.parse().ok()?;
let day: u8 = value.get(8..10)?.parse().ok()?;
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn every_warning_kind_has_a_stable_spelling() {
let spellings: Vec<&str> = ScheduleWarningKind::ALL
.iter()
.map(|kind| kind.as_str())
.collect();
assert_eq!(
spellings,
vec!["ended", "due", "look-ahead", "failure"],
"the wire vocabulary, with the data model's one kebab spelling"
);
}
#[test]
fn every_action_has_a_stable_spelling_and_a_fixed_offer_order() {
let spellings: Vec<&str> = WarningAction::ALL
.iter()
.map(|action| action.as_str())
.collect();
assert_eq!(
spellings,
vec!["close", "extend", "skip", "ignore", "generate"]
);
assert_eq!(
WarningAction::INSTANCE,
[
WarningAction::Close,
WarningAction::Extend,
WarningAction::Ignore
]
);
assert_eq!(
WarningAction::CANDIDATE,
[WarningAction::Generate, WarningAction::Ignore]
);
}
#[test]
fn a_failure_is_the_one_kind_that_offers_no_action() {
assert!(ScheduleWarningKind::Ended.offers_actions());
assert!(ScheduleWarningKind::Due.offers_actions());
assert!(ScheduleWarningKind::LookAhead.offers_actions());
assert!(!ScheduleWarningKind::Failure.offers_actions());
}
#[test]
fn the_sequence_to_date_conversion_is_the_canonical_one() {
assert!(as_date("2026-02-30").is_none(), "not a real calendar date");
assert!(as_date("2026-2-3").is_none(), "not canonical");
assert!(as_date("").is_none());
let date = as_date("2026-01-05").expect("a real date");
assert_eq!(date.to_string(), "2026-01-05");
}
#[test]
fn a_rule_field_is_translated_to_the_configuration_field_it_came_from() {
let schedule = CycleScheduleId::new("cs-1").unwrap();
let anchor = RuleDiagnostic {
kind: crate::recurrence::rule::RuleDiagnosticKind::InvalidAnchor,
field: "anchor",
value: "2026-02-30".into(),
message: "anchor: `2026-02-30` is not a valid `YYYY-MM-DD` calendar date".into(),
};
let diagnostic = ScheduleDiagnostic::invalid_cadence(&schedule, &anchor);
assert_eq!(diagnostic.field, "cycle_schedules.cs-1.start_date");
assert_eq!(diagnostic.kind.as_str(), "invalid_cadence");
let timezone = RuleDiagnostic {
kind: crate::recurrence::rule::RuleDiagnosticKind::UnsupportedTimezone,
field: "timezone",
value: "Europe/Berlin".into(),
message: "timezone: rule `Europe/Berlin` is a named zone".into(),
};
assert_eq!(
ScheduleDiagnostic::invalid_cadence(&schedule, &timezone).field,
"cycle_schedules.cs-1.timezone"
);
assert_eq!(
ScheduleDiagnostic::missing_cadence(&schedule).field,
"cycle_schedules.cs-1.cadence.kind"
);
}
}