use anyhow::{bail, Result};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDateTime, TimeZone, Timelike, Weekday};
pub const TICK_MS: u64 = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CivilTime {
pub minute: u32,
pub hour: u32,
pub day: u32,
pub month: u32,
pub dow: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TickTime {
pub decisecond: u32,
pub second: u32,
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()),
}
}
}
impl TickTime {
pub fn now_local() -> Self {
Self::from_chrono(Local::now())
}
pub fn from_chrono<T: Datelike + Timelike>(dt: T) -> Self {
let nanos = dt.nanosecond();
Self {
decisecond: nanos / 100_000_000,
second: dt.second(),
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))
}
pub fn parse_at_tick(s: &str) -> Result<TickTime> {
let s = s.trim();
if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
{
return Ok(TickTime::from_chrono(naive));
}
if let Some((base, ds)) = s.rsplit_once('.') {
let naive = NaiveDateTime::parse_from_str(base.trim(), "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(base.trim(), "%Y-%m-%dT%H:%M:%S"))
.map_err(|e| {
anyhow::anyhow!("invalid --at `{s}` (expected YYYY-MM-DD HH:MM:SS[.d]): {e}")
})?;
let ds = ds.trim();
if ds.len() != 1 || !ds.chars().all(|c| c.is_ascii_digit()) {
bail!("invalid decisecond in --at `{s}` (expected single digit 0-9)");
}
let decisecond: u32 = ds.parse().unwrap();
if decisecond > 9 {
bail!("decisecond in --at `{s}` must be 0-9");
}
let mut t = TickTime::from_chrono(naive);
t.decisecond = decisecond;
return Ok(t);
}
bail!("invalid --at `{s}` (expected YYYY-MM-DD HH:MM[:SS[.d]])");
}
#[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, Copy, PartialEq, Eq)]
pub enum CronGranularity {
Minute,
Second,
Decisecond,
}
#[derive(Debug, Clone)]
pub struct CronExpr {
granularity: CronGranularity,
decisecond: Option<Field>,
second: Option<Field>,
minute: Field,
hour: Field,
day: Field,
month: Field,
dow: Field,
}
const NEXT_SEARCH_TICKS: i64 = 366 * 24 * 60 * 60 * 10;
const NEXT_SEARCH_SECONDS: i64 = 366 * 24 * 60 * 60;
const NEXT_SEARCH_MINUTES: i64 = 366 * 24 * 60;
impl CronExpr {
fn step_and_limit(&self) -> i64 {
match self.granularity {
CronGranularity::Minute => NEXT_SEARCH_MINUTES,
CronGranularity::Second => NEXT_SEARCH_SECONDS,
CronGranularity::Decisecond => NEXT_SEARCH_TICKS,
}
}
fn align_for_search(&self, from: DateTime<Local>) -> DateTime<Local> {
match self.granularity {
CronGranularity::Minute => from
.with_second(0)
.and_then(|d| d.with_nanosecond(0))
.unwrap_or(from),
CronGranularity::Second => from.with_nanosecond(0).unwrap_or(from),
CronGranularity::Decisecond => align_tick_datetime(from),
}
}
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 * * * *",
"@every_second" => "* * * * * *",
"@every_100ms" => "* * * * * * *",
other => other,
};
let parts: Vec<&str> = expanded.split_whitespace().collect();
match parts.len() {
5 => {
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)?;
normalize_dow(&mut dow);
Ok(Self {
granularity: CronGranularity::Minute,
decisecond: None,
second: None,
minute,
hour,
day,
month,
dow,
})
}
6 => {
let second = parse_field(parts[0], 0, 59)?;
let minute = parse_field(parts[1], 0, 59)?;
let hour = parse_field(parts[2], 0, 23)?;
let day = parse_field(parts[3], 1, 31)?;
let month = parse_field(parts[4], 1, 12)?;
let mut dow = parse_field(parts[5], 0, 7)?;
normalize_dow(&mut dow);
Ok(Self {
granularity: CronGranularity::Second,
decisecond: None,
second: Some(second),
minute,
hour,
day,
month,
dow,
})
}
7 => {
let decisecond = parse_field(parts[0], 0, 9)?;
let second = parse_field(parts[1], 0, 59)?;
let minute = parse_field(parts[2], 0, 59)?;
let hour = parse_field(parts[3], 0, 23)?;
let day = parse_field(parts[4], 1, 31)?;
let month = parse_field(parts[5], 1, 12)?;
let mut dow = parse_field(parts[6], 0, 7)?;
normalize_dow(&mut dow);
Ok(Self {
granularity: CronGranularity::Decisecond,
decisecond: Some(decisecond),
second: Some(second),
minute,
hour,
day,
month,
dow,
})
}
n => bail!(
"cron expression `{expr}` must have 5, 6, or 7 fields \
(minute|second|decisecond granularity), got {n}"
),
}
}
pub fn granularity(&self) -> CronGranularity {
self.granularity
}
pub fn matches_tick(&self, t: &TickTime) -> bool {
match self.granularity {
CronGranularity::Minute => {
if t.decisecond != 0 || t.second != 0 {
return false;
}
}
CronGranularity::Second => {
if t.decisecond != 0 {
return false;
}
if let Some(second) = &self.second {
if !second.matches(t.second) {
return false;
}
}
}
CronGranularity::Decisecond => {
if let Some(decisecond) = &self.decisecond {
if !decisecond.matches(t.decisecond) {
return false;
}
}
if let Some(second) = &self.second {
if !second.matches(t.second) {
return false;
}
}
}
}
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 limit = self.step_and_limit();
let start = self.align_for_search(from);
for offset in 0..limit {
let candidate = match self.granularity {
CronGranularity::Minute => start + Duration::minutes(offset),
CronGranularity::Second => start + Duration::seconds(offset),
CronGranularity::Decisecond => {
start + Duration::milliseconds(offset * TICK_MS as i64)
}
};
if self.matches_tick(&TickTime::from_chrono(candidate)) {
return Some(candidate);
}
}
None
}
pub fn next_after_tick(&self, from: DateTime<Local>) -> Option<TickTime> {
self.next_after(from).map(TickTime::from_chrono)
}
}
fn normalize_dow(dow: &mut Field) {
if dow.values.contains(&7) && !dow.values.contains(&0) {
dow.values.push(0);
dow.values.sort_unstable();
}
}
fn align_tick_datetime(from: DateTime<Local>) -> DateTime<Local> {
let nanos = from.nanosecond();
let aligned_nanos = (nanos / 100_000_000) * 100_000_000;
from.with_nanosecond(aligned_nanos).unwrap_or(from)
}
pub fn any_match(exprs: &[String], t: &CivilTime) -> Result<bool> {
any_match_tick(exprs, &t.to_tick())
}
fn trait_to_tick(c: &CivilTime) -> TickTime {
TickTime {
decisecond: 0,
second: 0,
minute: c.minute,
hour: c.hour,
day: c.day,
month: c.month,
dow: c.dow,
}
}
impl CivilTime {
fn to_tick(&self) -> TickTime {
trait_to_tick(self)
}
}
pub fn any_match_tick(exprs: &[String], t: &TickTime) -> Result<bool> {
for e in exprs {
let parsed = CronExpr::parse(e)?;
if parsed.matches_tick(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 next_any_tick(exprs: &[String], from: DateTime<Local>) -> Result<Option<TickTime>> {
let mut best: Option<TickTime> = None;
for e in exprs {
let parsed = CronExpr::parse(e)?;
if let Some(n) = parsed.next_after_tick(from) {
best = Some(match best {
Some(b) if tick_le(&b, &n) => b,
_ => n,
});
}
}
Ok(best)
}
fn tick_le(a: &TickTime, b: &TickTime) -> bool {
(a.month, a.day, a.hour, a.minute, a.second, a.decisecond)
<= (b.month, b.day, b.hour, b.minute, b.second, b.decisecond)
}
pub fn format_absolute(when: DateTime<Local>) -> String {
when.format("%Y-%m-%d %H:%M").to_string()
}
pub fn format_absolute_tick(t: &TickTime, from: DateTime<Local>) -> String {
if t.decisecond == 0 && t.second == 0 {
format!(
"{:04}-{:02}-{:02} {:02}:{:02}",
from.year(),
t.month,
t.day,
t.hour,
t.minute
)
} else if t.decisecond == 0 {
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
from.year(),
t.month,
t.day,
t.hour,
t.minute,
t.second
)
} else {
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{}",
from.year(),
t.month,
t.day,
t.hour,
t.minute,
t.second,
t.decisecond
)
}
}
pub fn format_relative(from: DateTime<Local>, when: DateTime<Local>) -> String {
let delta = when.signed_duration_since(from);
let millis = delta.num_milliseconds();
if millis <= 0 {
return "now".to_string();
}
if millis < 1000 {
return format!("in {}ms", millis);
}
let secs = delta.num_seconds();
if secs <= 0 {
return "now".to_string();
}
let mins_total = (secs + 59) / 60;
if mins_total < 1 {
return format!("in {secs}s");
}
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(" "))
}
pub fn format_relative_tick(from: DateTime<Local>, when: &TickTime) -> String {
let when_dt = Local
.with_ymd_and_hms(
from.year(),
when.month,
when.day,
when.hour,
when.minute,
when.second,
)
.single()
.unwrap_or(from)
.with_nanosecond(when.decisecond * 100_000_000)
.unwrap_or(from);
format_relative(from, when_dt)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn t(minute: u32, hour: u32, day: u32, month: u32, dow: u32) -> TickTime {
CivilTime {
minute,
hour,
day,
month,
dow,
}
.to_tick()
}
fn tick(ds: u32, sec: u32, min: u32, hr: u32, day: u32, mon: u32, dow: u32) -> TickTime {
TickTime {
decisecond: ds,
second: sec,
minute: min,
hour: hr,
day,
month: mon,
dow,
}
}
#[test]
fn every_minute() {
let c = CronExpr::parse("* * * * *").unwrap();
assert!(c.matches_tick(&t(0, 0, 1, 1, 0)));
assert!(c.matches_tick(&tick(0, 0, 0, 0, 1, 1, 0)));
assert!(!c.matches_tick(&tick(0, 1, 0, 0, 1, 1, 0)));
}
#[test]
fn every_second() {
let c = CronExpr::parse("* * * * * *").unwrap();
assert_eq!(c.granularity(), CronGranularity::Second);
assert!(c.matches_tick(&tick(0, 0, 0, 0, 1, 1, 0)));
assert!(c.matches_tick(&tick(0, 59, 0, 0, 1, 1, 0)));
assert!(!c.matches_tick(&tick(1, 0, 0, 0, 1, 1, 0)));
}
#[test]
fn every_100ms() {
let c = CronExpr::parse("* * * * * * *").unwrap();
assert_eq!(c.granularity(), CronGranularity::Decisecond);
assert!(c.matches_tick(&tick(0, 0, 0, 0, 1, 1, 0)));
assert!(c.matches_tick(&tick(9, 0, 0, 0, 1, 1, 0)));
}
#[test]
fn specific_time() {
let c = CronExpr::parse("30 10 * * *").unwrap();
assert!(c.matches_tick(&t(30, 10, 5, 3, 2)));
assert!(!c.matches_tick(&t(31, 10, 5, 3, 2)));
assert!(!c.matches_tick(&t(30, 11, 5, 3, 2)));
}
#[test]
fn step_hours() {
let c = CronExpr::parse("0 */6 * * *").unwrap();
assert!(c.matches_tick(&t(0, 0, 1, 1, 0)));
assert!(c.matches_tick(&t(0, 6, 1, 1, 0)));
assert!(c.matches_tick(&t(0, 18, 1, 1, 0)));
assert!(!c.matches_tick(&t(0, 7, 1, 1, 0)));
}
#[test]
fn dow_sunday_aliases() {
let c = CronExpr::parse("0 0 * * 7").unwrap();
assert!(c.matches_tick(&t(0, 0, 1, 1, 0)));
assert!(!c.matches_tick(&t(0, 0, 1, 1, 1)));
}
#[test]
fn nickname_hourly() {
let c = CronExpr::parse("@hourly").unwrap();
assert!(c.matches_tick(&t(0, 15, 1, 1, 0)));
assert!(!c.matches_tick(&t(1, 15, 1, 1, 0)));
}
#[test]
fn nickname_every_second() {
let c = CronExpr::parse("@every_second").unwrap();
assert!(c.matches_tick(&tick(0, 42, 10, 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 parse_at_tick_string() {
let tt = parse_at_tick("2026-08-05 10:30:45.3").unwrap();
assert_eq!(tt.hour, 10);
assert_eq!(tt.minute, 30);
assert_eq!(tt.second, 45);
assert_eq!(tt.decisecond, 3);
}
#[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");
}
}