use std::str::FromStr;
use chrono::{DateTime, Local, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronExpr {
expr: String,
#[serde(skip)]
parsed: Option<cron::Schedule>,
}
impl CronExpr {
pub fn parse(input: &str) -> Result<Self, String> {
let trimmed = input.trim();
let fields: Vec<&str> = trimmed.split_whitespace().collect();
if fields.len() != 5 {
return Err(format!(
"cron: expected 5 fields (min hour dom month dow), got {}",
fields.len()
));
}
for f in &fields {
for ch in f.chars() {
let ok = ch.is_ascii_digit()
|| matches!(ch, '*' | '/' | ',' | '-');
if !ok {
return Err(format!(
"cron: unsupported character '{ch}' in field '{f}' \
(extended syntax like L/W/?/MON/JAN is not supported)"
));
}
}
}
let six = format!("0 {trimmed}");
let parsed = cron::Schedule::from_str(&six)
.map_err(|e| format!("cron: parse error: {e}"))?;
Ok(Self {
expr: trimmed.to_string(),
parsed: Some(parsed),
})
}
pub fn as_str(&self) -> &str {
&self.expr
}
pub fn next_after(&mut self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
self.ensure_parsed();
let local_after: DateTime<Local> = after.with_timezone(&Local);
self.parsed
.as_ref()
.and_then(|s| s.after(&local_after).next())
.map(|t| t.with_timezone(&Utc))
}
pub fn approx_interval_seconds(&mut self) -> i64 {
let now: DateTime<Local> = Local::now();
self.ensure_parsed();
let Some(sched) = self.parsed.as_ref() else {
return 3600;
};
let mut iter = sched.after(&now);
let Some(a) = iter.next() else { return 3600 };
let Some(b) = iter.next() else { return 3600 };
(b - a).num_seconds().max(60)
}
fn ensure_parsed(&mut self) {
if self.parsed.is_none() {
let six = format!("0 {}", self.expr);
self.parsed = cron::Schedule::from_str(&six).ok();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_every_5_min() {
let e = CronExpr::parse("*/5 * * * *").unwrap();
assert_eq!(e.as_str(), "*/5 * * * *");
}
#[test]
fn parses_weekday_morning() {
CronExpr::parse("0 9 * * 1-5").unwrap();
}
#[test]
fn rejects_wrong_field_count() {
let err = CronExpr::parse("0 9 * *").unwrap_err();
assert!(err.contains("5 fields"), "got: {err}");
}
#[test]
fn rejects_l_syntax() {
let err = CronExpr::parse("0 9 L * *").unwrap_err();
assert!(err.contains("unsupported character"), "got: {err}");
}
#[test]
fn rejects_name_alias() {
let err = CronExpr::parse("0 9 * * MON").unwrap_err();
assert!(err.contains("unsupported character"), "got: {err}");
}
#[test]
fn next_after_advances() {
let mut e = CronExpr::parse("*/5 * * * *").unwrap();
let now = Utc::now();
let nxt = e.next_after(now).unwrap();
assert!(nxt > now);
assert!((nxt - now).num_minutes() <= 5);
}
#[test]
fn next_after_uses_local_timezone() {
use chrono::{Local, Timelike};
let mut e = CronExpr::parse("0 9 * * *").unwrap();
let now = Utc::now();
let nxt_utc = e.next_after(now).expect("should have a next fire");
let nxt_local: DateTime<Local> = nxt_utc.with_timezone(&Local);
assert_eq!(
nxt_local.hour(),
9,
"next fire should be at 9 AM local; got {nxt_local} (UTC {nxt_utc})"
);
assert_eq!(nxt_local.minute(), 0);
}
}