use crate::config::ScheduleError;
use crate::schedule::Schedule;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
const MAX_OCCURRENCES: usize = 100_000;
pub fn enumerate_next_occurrences(
schedule: &Schedule,
after: DateTime<Utc>,
count: usize,
) -> Result<Vec<DateTime<Utc>>, ScheduleError> {
if count == 0 {
return Ok(Vec::new());
}
let cap = count.min(MAX_OCCURRENCES);
if let Schedule::OneTime { run_at } = schedule {
if *run_at > after {
return Ok(vec![*run_at]);
}
return Ok(Vec::new());
}
let mut out = Vec::with_capacity(cap);
let mut cursor = after;
for _ in 0..cap {
match schedule.next_run(Some(cursor)) {
Ok(next) => {
if next <= cursor {
break;
}
out.push(next);
cursor = next;
}
Err(ScheduleError::Invalid(_)) => {
break;
}
Err(other) => return Err(other),
}
}
Ok(out)
}
pub fn enumerate_occurrences_in_window(
schedule: &Schedule,
after: DateTime<Utc>,
window_seconds: i64,
) -> Result<Vec<DateTime<Utc>>, ScheduleError> {
if window_seconds <= 0 {
return Ok(Vec::new());
}
let end = after + Duration::seconds(window_seconds);
if let Schedule::OneTime { run_at } = schedule {
if *run_at > after && *run_at <= end {
return Ok(vec![*run_at]);
}
return Ok(Vec::new());
}
let mut out = Vec::new();
let mut cursor = after;
for _ in 0..MAX_OCCURRENCES {
match schedule.next_run(Some(cursor)) {
Ok(next) => {
if next <= cursor || next > end {
break;
}
out.push(next);
cursor = next;
}
Err(ScheduleError::Invalid(_)) => break,
Err(other) => return Err(other),
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OccurrenceConflict {
pub first_index: usize,
pub second_index: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub second_name: Option<String>,
pub instant: DateTime<Utc>,
}
impl OccurrenceConflict {
pub fn new(first_index: usize, second_index: usize, instant: DateTime<Utc>) -> Self {
Self {
first_index,
second_index,
first_name: None,
second_name: None,
instant,
}
}
pub fn with_names(
mut self,
first_name: impl Into<String>,
second_name: impl Into<String>,
) -> Self {
self.first_name = Some(first_name.into());
self.second_name = Some(second_name.into());
self
}
}
impl std::fmt::Display for OccurrenceConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let first = self
.first_name
.clone()
.unwrap_or_else(|| format!("#{}", self.first_index));
let second = self
.second_name
.clone()
.unwrap_or_else(|| format!("#{}", self.second_index));
write!(
f,
"Conflict: {} <-> {} both fire at {}",
first,
second,
self.instant.format("%Y-%m-%d %H:%M:%S UTC")
)
}
}
pub fn detect_schedule_conflicts(
schedules: &[Schedule],
after: DateTime<Utc>,
window_seconds: i64,
) -> Result<Vec<OccurrenceConflict>, ScheduleError> {
detect_conflicts_inner(schedules, &[], after, window_seconds)
}
pub fn detect_named_schedule_conflicts(
schedules: &[Schedule],
names: &[String],
after: DateTime<Utc>,
window_seconds: i64,
) -> Result<Vec<OccurrenceConflict>, ScheduleError> {
if names.len() != schedules.len() {
return Err(ScheduleError::Invalid(format!(
"names length ({}) must match schedules length ({})",
names.len(),
schedules.len()
)));
}
detect_conflicts_inner(schedules, names, after, window_seconds)
}
fn detect_conflicts_inner(
schedules: &[Schedule],
names: &[String],
after: DateTime<Utc>,
window_seconds: i64,
) -> Result<Vec<OccurrenceConflict>, ScheduleError> {
use std::collections::BTreeSet;
if schedules.len() < 2 || window_seconds <= 0 {
return Ok(Vec::new());
}
let mut occurrence_sets: Vec<BTreeSet<DateTime<Utc>>> = Vec::with_capacity(schedules.len());
for schedule in schedules {
let occ = enumerate_occurrences_in_window(schedule, after, window_seconds)?;
occurrence_sets.push(occ.into_iter().collect());
}
let mut conflicts = Vec::new();
for i in 0..schedules.len() {
for j in (i + 1)..schedules.len() {
for instant in occurrence_sets[i].intersection(&occurrence_sets[j]) {
let mut conflict = OccurrenceConflict::new(i, j, *instant);
if !names.is_empty() {
conflict.first_name = Some(names[i].clone());
conflict.second_name = Some(names[j].clone());
}
conflicts.push(conflict);
}
}
}
conflicts.sort_by(|a, b| {
a.instant
.cmp(&b.instant)
.then_with(|| a.first_index.cmp(&b.first_index))
.then_with(|| a.second_index.cmp(&b.second_index))
});
Ok(conflicts)
}
pub fn has_schedule_conflicts(
schedules: &[Schedule],
after: DateTime<Utc>,
window_seconds: i64,
) -> Result<bool, ScheduleError> {
Ok(!detect_schedule_conflicts(schedules, after, window_seconds)?.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn ts(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, mo, d, h, mi, s)
.single()
.expect("valid timestamp")
}
#[test]
fn enumerate_interval_basic() {
let after = ts(2026, 6, 13, 0, 0, 0);
let sched = Schedule::interval(60);
let occ = enumerate_next_occurrences(&sched, after, 3).expect("enumerate");
assert_eq!(occ.len(), 3);
assert_eq!(occ[0], ts(2026, 6, 13, 0, 1, 0));
assert_eq!(occ[1], ts(2026, 6, 13, 0, 2, 0));
assert_eq!(occ[2], ts(2026, 6, 13, 0, 3, 0));
}
#[test]
fn enumerate_zero_count_is_empty() {
let after = ts(2026, 6, 13, 0, 0, 0);
let sched = Schedule::interval(60);
assert!(enumerate_next_occurrences(&sched, after, 0)
.expect("enumerate")
.is_empty());
}
#[test]
fn enumerate_onetime_future_yields_one() {
let after = ts(2026, 6, 13, 0, 0, 0);
let run_at = ts(2026, 6, 13, 1, 0, 0);
let sched = Schedule::onetime(run_at);
let occ = enumerate_next_occurrences(&sched, after, 5).expect("enumerate");
assert_eq!(occ, vec![run_at]);
}
#[test]
fn enumerate_onetime_past_yields_none() {
let after = ts(2026, 6, 13, 2, 0, 0);
let run_at = ts(2026, 6, 13, 1, 0, 0);
let sched = Schedule::onetime(run_at);
let occ = enumerate_next_occurrences(&sched, after, 5).expect("enumerate");
assert!(occ.is_empty());
}
#[test]
fn enumerate_window_interval() {
let after = ts(2026, 6, 13, 0, 0, 0);
let sched = Schedule::interval(60);
let occ = enumerate_occurrences_in_window(&sched, after, 200).expect("window");
assert_eq!(occ.len(), 3);
assert_eq!(*occ.last().unwrap(), ts(2026, 6, 13, 0, 3, 0));
}
#[test]
fn conflict_interval_pair() {
let after = ts(2026, 6, 13, 0, 0, 0);
let a = Schedule::interval(60);
let b = Schedule::interval(90);
let conflicts = detect_schedule_conflicts(&[a, b], after, 600).expect("detect");
assert_eq!(conflicts.len(), 3);
assert_eq!(conflicts[0].instant, ts(2026, 6, 13, 0, 3, 0));
assert_eq!(conflicts[1].instant, ts(2026, 6, 13, 0, 6, 0));
assert_eq!(conflicts[2].instant, ts(2026, 6, 13, 0, 9, 0));
assert_eq!(conflicts[0].first_index, 0);
assert_eq!(conflicts[0].second_index, 1);
}
#[test]
fn no_conflict_when_coprime_misaligned() {
let after = ts(2026, 6, 13, 0, 0, 0);
let a = Schedule::interval(60);
let b = Schedule::interval(60);
let conflicts = detect_schedule_conflicts(&[a, b], after, 180).expect("detect");
assert_eq!(conflicts.len(), 3);
}
#[test]
fn three_schedules_pairwise() {
let after = ts(2026, 6, 13, 0, 0, 0);
let scheds = vec![
Schedule::interval(60),
Schedule::interval(120),
Schedule::interval(180),
];
let conflicts = detect_schedule_conflicts(&scheds, after, 360).expect("detect");
assert!(!conflicts.is_empty());
for w in conflicts.windows(2) {
assert!(w[0].instant <= w[1].instant);
}
for c in &conflicts {
assert!(c.first_index < c.second_index);
}
}
#[test]
fn named_conflicts_carry_names() {
let after = ts(2026, 6, 13, 0, 0, 0);
let scheds = vec![Schedule::interval(60), Schedule::interval(60)];
let names = vec!["alpha".to_string(), "beta".to_string()];
let conflicts =
detect_named_schedule_conflicts(&scheds, &names, after, 60).expect("detect");
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].first_name.as_deref(), Some("alpha"));
assert_eq!(conflicts[0].second_name.as_deref(), Some("beta"));
let rendered = conflicts[0].to_string();
assert!(rendered.contains("alpha"));
assert!(rendered.contains("beta"));
}
#[test]
fn named_conflicts_length_mismatch_errors() {
let after = ts(2026, 6, 13, 0, 0, 0);
let scheds = vec![Schedule::interval(60), Schedule::interval(60)];
let names = vec!["only-one".to_string()];
let err = detect_named_schedule_conflicts(&scheds, &names, after, 60).unwrap_err();
assert!(err.is_invalid());
}
#[test]
fn fewer_than_two_schedules_no_conflict() {
let after = ts(2026, 6, 13, 0, 0, 0);
let scheds = vec![Schedule::interval(60)];
assert!(detect_schedule_conflicts(&scheds, after, 600)
.expect("detect")
.is_empty());
}
#[test]
fn zero_window_no_conflict() {
let after = ts(2026, 6, 13, 0, 0, 0);
let scheds = vec![Schedule::interval(60), Schedule::interval(60)];
assert!(detect_schedule_conflicts(&scheds, after, 0)
.expect("detect")
.is_empty());
}
#[test]
fn has_conflicts_helper() {
let after = ts(2026, 6, 13, 0, 0, 0);
let colliding = vec![Schedule::interval(60), Schedule::interval(60)];
assert!(has_schedule_conflicts(&colliding, after, 60).expect("has"));
let onetime_a = vec![
Schedule::onetime(ts(2026, 6, 13, 0, 5, 0)),
Schedule::onetime(ts(2026, 6, 13, 0, 6, 0)),
];
assert!(!has_schedule_conflicts(&onetime_a, after, 600).expect("has"));
}
#[test]
fn onetime_conflict_same_instant() {
let after = ts(2026, 6, 13, 0, 0, 0);
let instant = ts(2026, 6, 13, 0, 5, 0);
let scheds = vec![Schedule::onetime(instant), Schedule::onetime(instant)];
let conflicts = detect_schedule_conflicts(&scheds, after, 600).expect("detect");
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].instant, instant);
}
#[cfg(feature = "cron")]
#[test]
fn enumerate_crontab_hourly() {
let sched = Schedule::crontab("0", "*", "*", "*", "*");
let after = ts(2026, 6, 13, 0, 30, 0);
let occ = enumerate_next_occurrences(&sched, after, 3).expect("enumerate");
assert_eq!(occ[0], ts(2026, 6, 13, 1, 0, 0));
assert_eq!(occ[1], ts(2026, 6, 13, 2, 0, 0));
assert_eq!(occ[2], ts(2026, 6, 13, 3, 0, 0));
}
#[cfg(feature = "cron")]
#[test]
fn conflict_crontab_vs_interval() {
let cron = Schedule::crontab("0", "*", "*", "*", "*");
let interval = Schedule::interval(3600);
let after = ts(2026, 6, 13, 0, 0, 0);
let conflicts =
detect_schedule_conflicts(&[cron, interval], after, 3 * 3600).expect("detect");
assert_eq!(conflicts.len(), 3);
assert_eq!(conflicts[0].instant, ts(2026, 6, 13, 1, 0, 0));
}
}