use std::time::Duration;
use tracing::Instrument;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CronParseError(pub String);
impl std::fmt::Display for CronParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid cron expression: {}", self.0)
}
}
impl std::error::Error for CronParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronError {
Parse(CronParseError),
}
impl std::fmt::Display for CronError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(err) => write!(f, "cron error: {err}"),
}
}
}
impl std::error::Error for CronError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct CronTime {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub second: u32,
}
#[derive(Debug, Clone)]
pub struct CronSchedule {
minutes: Vec<u8>,
hours: Vec<u8>,
days_of_month: Vec<u8>,
months: Vec<u8>,
days_of_week: Vec<u8>,
}
#[derive(Debug)]
pub struct CronJob {
handle: tokio::task::JoinHandle<()>,
}
impl CronJob {
pub fn cancel(self) {
self.handle.abort();
}
}
impl CronSchedule {
pub fn parse(expr: &str) -> Result<Self, CronParseError> {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err(CronParseError(format!(
"expected exactly 5 fields, got {}",
fields.len()
)));
}
let minutes = parse_field(fields[0], 0, 59, false)?;
let hours = parse_field(fields[1], 0, 23, false)?;
let days_of_month = parse_field(fields[2], 1, 31, false)?;
let months = parse_field(fields[3], 1, 12, false)?;
let days_of_week = parse_field(fields[4], 0, 6, true)?;
Ok(Self {
minutes,
hours,
days_of_month,
months,
days_of_week,
})
}
pub fn next_after(&self, from: CronTime) -> Option<CronTime> {
let mut act = from.advance_minute();
let horizon = 5 * 366 * 24 * 60; for _ in 0..horizon {
if self.matches(act) {
return Some(act);
}
act = act.advance_minute();
}
None
}
fn matches(&self, t: CronTime) -> bool {
let month_ok = self.months.contains(&(t.month as u8));
let dom_ok = self.days_of_month.contains(&(t.day as u8));
let dow_ok = self.days_of_week.contains(&(weekday(t) as u8));
let dom_restricted = self.days_of_month.len() < 31;
let dow_restricted = self.days_of_week.len() < 7;
let day_ok = match (dom_restricted, dow_restricted) {
(true, true) => dom_ok || dow_ok,
(true, false) => dom_ok,
(false, true) => dow_ok,
(false, false) => true,
};
month_ok
&& day_ok
&& self.hours.contains(&(t.hour as u8))
&& self.minutes.contains(&(t.minute as u8))
}
pub fn next_five(&self, from: CronTime) -> Vec<CronTime> {
let mut out = Vec::new();
let mut cur = from;
for _ in 0..5 {
match self.next_after(cur) {
Some(next) => {
out.push(next);
cur = next;
}
None => break,
}
}
out
}
}
pub fn schedule<F, Fut>(expr: &str, mut job: F) -> Result<CronJob, CronError>
where
F: FnMut() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let schedule = CronSchedule::parse(expr).map_err(CronError::Parse)?;
let expr_owned = expr.to_string();
let handle = tokio::spawn(async move {
loop {
let now = CronTime::now();
let next = schedule.next_after(now);
let next = match next {
Some(next) => next,
None => {
tokio::time::sleep(Duration::from_secs(60)).await;
continue;
}
};
let now_ts = CronTime::now().to_timestamp();
let until = tokio::time::Instant::now()
+ Duration::from_secs(next.to_timestamp().saturating_sub(now_ts).max(1) as u64);
tokio::time::sleep_until(until).await;
let span = tracing::info_span!(
"mytheclipse_cron_task",
expr = %expr_owned
);
job().instrument(span).await;
}
});
Ok(CronJob { handle })
}
impl CronTime {
pub fn now() -> Self {
now_utc()
}
fn to_timestamp(self) -> i64 {
days_from_civil(self.year, self.month, self.day) * 86_400
+ (self.hour as i64) * 3_600
+ (self.minute as i64) * 60
+ self.second as i64
}
fn advance_minute(self) -> Self {
let mut t = self.add_seconds(60);
t.second = 0;
t
}
fn add_seconds(self, secs: i64) -> Self {
let ts = self.to_timestamp() + secs;
from_timestamp(ts)
}
}
fn weekday(t: CronTime) -> u32 {
let days = days_from_civil(t.year, t.month, t.day);
((days % 7 + 11) % 7) as u32
}
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = y as i64 - (m as i64 <= 2) as i64;
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400; let mp = (m as i64 + 9) % 12; let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn from_timestamp(ts: i64) -> CronTime {
let days = ts.div_euclid(86_400);
let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y } as i32;
let (h, mi, s) = split_seconds(ts);
CronTime {
year: y,
month: m,
day: d as u32,
hour: h,
minute: mi,
second: s,
}
}
fn split_seconds(ts: i64) -> (u32, u32, u32) {
let rem = ts.rem_euclid(86_400);
(
(rem / 3_600) as u32,
((rem % 3_600) / 60) as u32,
(rem % 60) as u32,
)
}
fn now_utc() -> CronTime {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
from_timestamp(now.as_secs() as i64)
}
fn parse_field(
field: &str,
min: u8,
max: u8,
allow_names: bool,
) -> Result<Vec<u8>, CronParseError> {
let mut out = Vec::new();
for part in field.split(',') {
let part = part.trim();
if part.is_empty() {
return Err(CronParseError(format!("empty field element in `{field}`")));
}
let (range, step) = match part.split_once('/') {
Some((r, s)) => (r, parse_num(s, "step")?),
None => (part, 1),
};
match range {
"*" => {
let mut v = min;
while v <= max {
out.push(v);
v += step;
}
}
_ if range.contains('-') => {
let (a, b) = range
.split_once('-')
.ok_or_else(|| CronParseError(format!("bad range `{range}`")))?;
let a = parse_value(a, min, max, allow_names)?;
let b = parse_value(b, min, max, allow_names)?;
if a > b {
return Err(CronParseError(format!("range start {a} > end {b}")));
}
let mut v = a;
while v <= b {
out.push(v);
v += step;
}
}
_ => {
let v = parse_value(range, min, max, allow_names)?;
out.push(v);
}
}
}
out.sort_unstable();
out.dedup();
Ok(out)
}
fn parse_num(s: &str, what: &str) -> Result<u8, CronParseError> {
s.parse::<u8>()
.map_err(|_| CronParseError(format!("invalid {what} `{s}`")))
}
fn parse_value(s: &str, min: u8, max: u8, allow_names: bool) -> Result<u8, CronParseError> {
let upper = s.to_uppercase();
if allow_names {
let name = match upper.as_str() {
"SUN" => Some(0),
"MON" => Some(1),
"TUE" => Some(2),
"WED" => Some(3),
"THU" => Some(4),
"FRI" => Some(5),
"SAT" => Some(6),
_ => None,
};
if let Some(v) = name {
return Ok(v);
}
}
let v = parse_num(s, "value")?;
if v < min || v > max {
return Err(CronParseError(format!(
"value {v} out of range {min}..{max}"
)));
}
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
fn t(y: i32, m: u32, d: u32, h: u32, mi: u32) -> CronTime {
CronTime {
year: y,
month: m,
day: d,
hour: h,
minute: mi,
second: 0,
}
}
#[test]
fn parses_star() {
let s = CronSchedule::parse("* * * * *").unwrap();
assert_eq!(s.minutes.len(), 60);
}
#[test]
fn parses_list_and_range() {
let s = CronSchedule::parse("1,15 9-17 * * 1-5").unwrap();
assert_eq!(s.minutes, vec![1, 15]);
assert_eq!(s.hours, (9..=17).collect::<Vec<_>>());
assert_eq!(s.days_of_week, vec![1, 2, 3, 4, 5]);
}
#[test]
fn parses_step_and_names() {
let s = CronSchedule::parse("*/15 * * * MON,WED").unwrap();
assert_eq!(s.minutes, vec![0, 15, 30, 45]);
assert_eq!(s.days_of_week, vec![1, 3]);
}
#[test]
fn rejects_too_few_fields() {
assert!(CronSchedule::parse("* * * *").is_err());
}
#[test]
fn rejects_out_of_range() {
assert!(CronSchedule::parse("60 * * * *").is_err());
}
#[test]
fn next_after_every_minute() {
let s = CronSchedule::parse("* * * * *").unwrap();
let next = s.next_after(t(2026, 8, 28, 10, 30)).unwrap();
assert_eq!(next, t(2026, 8, 28, 10, 31));
}
#[test]
fn next_after_midnight() {
let s = CronSchedule::parse("0 0 * * *").unwrap();
let next = s.next_after(t(2026, 8, 28, 23, 59)).unwrap();
assert_eq!(next, t(2026, 8, 29, 0, 0));
}
#[test]
fn next_after_weekday_only() {
let s = CronSchedule::parse("0 9 * * MON-FRI").unwrap();
let next = s.next_after(t(2026, 8, 28, 23, 0)).unwrap();
assert_eq!(next, t(2026, 8, 31, 9, 0));
}
#[test]
fn next_after_leap_year_feb() {
let s = CronSchedule::parse("0 12 29 2 *").unwrap();
let next = s.next_after(t(2026, 1, 1, 0, 0)).unwrap();
assert_eq!(next, t(2028, 2, 29, 12, 0));
}
#[test]
fn impossible_expression_returns_none() {
let s = CronSchedule::parse("0 0 30 2 *").unwrap(); assert!(s.next_after(t(2026, 1, 1, 0, 0)).is_none());
}
#[test]
fn next_five_every_hour() {
let s = CronSchedule::parse("0 * * * *").unwrap();
let five = s.next_five(t(2026, 8, 28, 9, 45));
assert_eq!(five.len(), 5);
assert_eq!(five[0], t(2026, 8, 28, 10, 0));
assert_eq!(five[1], t(2026, 8, 28, 11, 0));
assert_eq!(five[4], t(2026, 8, 28, 14, 0));
}
}