jan-cli 0.18.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
//! Five-field cron expression matching for `jan cron`.
//!
//! Supports `*`, `N`, `A,B`, `A-B`, `*/S`, `A-B/S`. Day-of-week uses the crontab
//! convention: 0 or 7 = Sunday … 6 = Saturday.

use anyhow::{bail, Result};
use chrono::{Datelike, 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,
    /// Crontab day-of-week: 0/7 = Sunday … 6 = Saturday.
    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,
    }
}

/// Parse `YYYY-MM-DD HH:MM` (24h, local civil time) into [`CivilTime`].
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 {
    /// Allowed values in the field's domain.
    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,
}

impl CronExpr {
    pub fn parse(expr: &str) -> Result<Self> {
        let expr = expr.trim();
        if expr.is_empty() {
            bail!("empty cron expression");
        }
        // Optional nicknames
        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)?;
        // Accept 0-7 where 7 means Sunday as well.
        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)
    }
}

/// True if any expression in `exprs` matches `t`.
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)
}

#[cfg(test)]
mod tests {
    use super::*;

    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))); // Sunday
        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);
    }
}