use anyhow::{bail, Result};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDateTime, Timelike, Weekday};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CivilTime {
pub minute: u32,
pub hour: u32,
pub day: u32,
pub month: u32,
pub dow: u32,
}
impl CivilTime {
pub fn now_local() -> Self {
Self::from_chrono(Local::now())
}
pub fn from_chrono<T: Datelike + Timelike>(dt: T) -> Self {
Self {
minute: dt.minute(),
hour: dt.hour(),
day: dt.day(),
month: dt.month(),
dow: weekday_to_cron(dt.weekday()),
}
}
}
fn weekday_to_cron(wd: Weekday) -> u32 {
match wd {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
}
}
pub fn parse_at(s: &str) -> Result<CivilTime> {
let s = s.trim();
let naive = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
.map_err(|e| anyhow::anyhow!("invalid --at `{s}` (expected YYYY-MM-DD HH:MM): {e}"))?;
Ok(CivilTime::from_chrono(naive))
}
#[derive(Debug, Clone)]
struct Field {
values: Vec<u32>,
}
impl Field {
fn matches(&self, value: u32) -> bool {
self.values.contains(&value)
}
}
fn parse_field(raw: &str, min: u32, max: u32) -> Result<Field> {
let raw = raw.trim();
if raw.is_empty() {
bail!("empty cron field");
}
let mut values = Vec::new();
for part in raw.split(',') {
let part = part.trim();
if part.is_empty() {
bail!("empty cron field list entry");
}
let (range_part, step) = match part.split_once('/') {
Some((r, s)) => {
let step: u32 = s
.parse()
.map_err(|_| anyhow::anyhow!("invalid cron step `{s}`"))?;
if step == 0 {
bail!("cron step must be > 0");
}
(r, step)
}
None => (part, 1u32),
};
let (start, end) = if range_part == "*" {
(min, max)
} else if let Some((a, b)) = range_part.split_once('-') {
let start: u32 = a
.parse()
.map_err(|_| anyhow::anyhow!("invalid cron range start `{a}`"))?;
let end: u32 = b
.parse()
.map_err(|_| anyhow::anyhow!("invalid cron range end `{b}`"))?;
(start, end)
} else {
let n: u32 = range_part
.parse()
.map_err(|_| anyhow::anyhow!("invalid cron value `{range_part}`"))?;
(n, n)
};
if start > end || start < min || end > max {
bail!("cron field `{part}` out of range {min}-{max}");
}
let mut v = start;
while v <= end {
values.push(v);
v = v.saturating_add(step);
if step == 0 {
break;
}
}
}
values.sort_unstable();
values.dedup();
Ok(Field { values })
}
#[derive(Debug, Clone)]
pub struct CronExpr {
minute: Field,
hour: Field,
day: Field,
month: Field,
dow: Field,
}
const NEXT_SEARCH_MINUTES: i64 = 366 * 24 * 60;
impl CronExpr {
pub fn parse(expr: &str) -> Result<Self> {
let expr = expr.trim();
if expr.is_empty() {
bail!("empty cron expression");
}
let expanded = match expr {
"@yearly" | "@annually" => "0 0 1 1 *",
"@monthly" => "0 0 1 * *",
"@weekly" => "0 0 * * 0",
"@daily" | "@midnight" => "0 0 * * *",
"@hourly" => "0 * * * *",
other => other,
};
let parts: Vec<&str> = expanded.split_whitespace().collect();
if parts.len() != 5 {
bail!(
"cron expression `{expr}` must have 5 fields (minute hour dom month dow), got {}",
parts.len()
);
}
let minute = parse_field(parts[0], 0, 59)?;
let hour = parse_field(parts[1], 0, 23)?;
let day = parse_field(parts[2], 1, 31)?;
let month = parse_field(parts[3], 1, 12)?;
let mut dow = parse_field(parts[4], 0, 7)?;
if dow.values.contains(&7) && !dow.values.contains(&0) {
dow.values.push(0);
dow.values.sort_unstable();
}
Ok(Self {
minute,
hour,
day,
month,
dow,
})
}
pub fn matches(&self, t: &CivilTime) -> bool {
self.minute.matches(t.minute)
&& self.hour.matches(t.hour)
&& self.day.matches(t.day)
&& self.month.matches(t.month)
&& self.dow.matches(t.dow)
}
pub fn next_after(&self, from: DateTime<Local>) -> Option<DateTime<Local>> {
let start = from
.with_second(0)
.and_then(|d| d.with_nanosecond(0))
.unwrap_or(from);
for offset in 0..NEXT_SEARCH_MINUTES {
let candidate = start + Duration::minutes(offset);
if self.matches(&CivilTime::from_chrono(candidate)) {
return Some(candidate);
}
}
None
}
}
pub fn any_match(exprs: &[String], t: &CivilTime) -> Result<bool> {
for e in exprs {
let parsed = CronExpr::parse(e)?;
if parsed.matches(t) {
return Ok(true);
}
}
Ok(false)
}
pub fn next_any(exprs: &[String], from: DateTime<Local>) -> Result<Option<DateTime<Local>>> {
let mut best: Option<DateTime<Local>> = None;
for e in exprs {
let parsed = CronExpr::parse(e)?;
if let Some(n) = parsed.next_after(from) {
best = Some(match best {
Some(b) if b <= n => b,
_ => n,
});
}
}
Ok(best)
}
pub fn format_absolute(when: DateTime<Local>) -> String {
when.format("%Y-%m-%d %H:%M").to_string()
}
pub fn format_relative(from: DateTime<Local>, when: DateTime<Local>) -> String {
let delta = when.signed_duration_since(from);
let secs = delta.num_seconds();
if secs <= 0 {
return "now".to_string();
}
let mins_total = (secs + 59) / 60; if mins_total < 1 {
return "now".to_string();
}
let days = mins_total / (24 * 60);
let hours = (mins_total % (24 * 60)) / 60;
let mins = mins_total % 60;
let mut parts = Vec::new();
if days > 0 {
parts.push(format!("{days}d"));
}
if hours > 0 {
parts.push(format!("{hours}h"));
}
if mins > 0 && days == 0 {
parts.push(format!("{mins}m"));
} else if mins > 0 && hours > 0 {
parts.push(format!("{mins}m"));
} else if parts.is_empty() {
parts.push(format!("{mins}m"));
}
format!("in {}", parts.join(" "))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn t(minute: u32, hour: u32, day: u32, month: u32, dow: u32) -> CivilTime {
CivilTime {
minute,
hour,
day,
month,
dow,
}
}
#[test]
fn every_minute() {
let c = CronExpr::parse("* * * * *").unwrap();
assert!(c.matches(&t(0, 0, 1, 1, 0)));
assert!(c.matches(&t(59, 23, 31, 12, 6)));
}
#[test]
fn specific_time() {
let c = CronExpr::parse("30 10 * * *").unwrap();
assert!(c.matches(&t(30, 10, 5, 3, 2)));
assert!(!c.matches(&t(31, 10, 5, 3, 2)));
assert!(!c.matches(&t(30, 11, 5, 3, 2)));
}
#[test]
fn step_hours() {
let c = CronExpr::parse("0 */6 * * *").unwrap();
assert!(c.matches(&t(0, 0, 1, 1, 0)));
assert!(c.matches(&t(0, 6, 1, 1, 0)));
assert!(c.matches(&t(0, 18, 1, 1, 0)));
assert!(!c.matches(&t(0, 7, 1, 1, 0)));
}
#[test]
fn dow_sunday_aliases() {
let c = CronExpr::parse("0 0 * * 7").unwrap();
assert!(c.matches(&t(0, 0, 1, 1, 0))); assert!(!c.matches(&t(0, 0, 1, 1, 1)));
}
#[test]
fn nickname_hourly() {
let c = CronExpr::parse("@hourly").unwrap();
assert!(c.matches(&t(0, 15, 1, 1, 0)));
assert!(!c.matches(&t(1, 15, 1, 1, 0)));
}
#[test]
fn parse_at_string() {
let ct = parse_at("2026-08-05 10:30").unwrap();
assert_eq!(ct.hour, 10);
assert_eq!(ct.minute, 30);
assert_eq!(ct.day, 5);
assert_eq!(ct.month, 8);
}
#[test]
fn next_after_same_day() {
let c = CronExpr::parse("30 10 * * *").unwrap();
let from = Local.with_ymd_and_hms(2026, 8, 5, 9, 0, 0).unwrap();
let next = c.next_after(from).unwrap();
assert_eq!(format_absolute(next), "2026-08-05 10:30");
}
#[test]
fn next_after_rolls_to_tomorrow() {
let c = CronExpr::parse("30 10 * * *").unwrap();
let from = Local.with_ymd_and_hms(2026, 8, 5, 11, 0, 0).unwrap();
let next = c.next_after(from).unwrap();
assert_eq!(format_absolute(next), "2026-08-06 10:30");
}
#[test]
fn format_relative_parts() {
let from = Local.with_ymd_and_hms(2026, 8, 5, 10, 0, 0).unwrap();
let when = Local.with_ymd_and_hms(2026, 8, 5, 10, 0, 0).unwrap();
assert_eq!(format_relative(from, when), "now");
let when = Local.with_ymd_and_hms(2026, 8, 5, 12, 15, 0).unwrap();
assert_eq!(format_relative(from, when), "in 2h 15m");
}
}