meerkat-schedule 0.7.11

Realm-scoped schedule domain for Meerkat
Documentation
use crate::error::ScheduleDomainError;
use crate::types::{CalendarFieldSpec, CalendarTriggerSpec, IntervalTriggerSpec, TriggerSpec};
use chrono::{DateTime, Datelike, Duration, LocalResult, TimeZone, Timelike, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;

#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CronAuthoringSpec {
    pub expression: String,
    pub timezone: String,
}

impl CronAuthoringSpec {
    pub fn into_calendar(self) -> Result<CalendarTriggerSpec, ScheduleDomainError> {
        let fields: Vec<&str> = self.expression.split_whitespace().collect();
        if fields.len() != 5 {
            return Err(ScheduleDomainError::InvalidCron(
                "cron expressions must have 5 fields: minute hour day_of_month month day_of_week"
                    .to_string(),
            ));
        }

        Ok(CalendarTriggerSpec {
            timezone: normalize_timezone(&self.timezone)?.name().to_string(),
            minute: parse_field(fields[0], 0, 59, ValueDomain::Minute)?,
            hour: parse_field(fields[1], 0, 23, ValueDomain::Hour)?,
            day_of_month: parse_field(fields[2], 1, 31, ValueDomain::DayOfMonth)?,
            month: parse_field(fields[3], 1, 12, ValueDomain::Month)?,
            day_of_week: parse_field(fields[4], 0, 6, ValueDomain::DayOfWeek)?,
            year: None,
        })
    }

    pub fn into_trigger(self) -> Result<TriggerSpec, ScheduleDomainError> {
        Ok(TriggerSpec::Calendar(self.into_calendar()?))
    }
}

pub fn next_due_after(
    trigger: &TriggerSpec,
    after_utc: Option<DateTime<Utc>>,
) -> Result<Option<DateTime<Utc>>, ScheduleDomainError> {
    match trigger {
        TriggerSpec::Once { due_at_utc } => {
            if after_utc.is_none_or(|after| *due_at_utc > after) {
                Ok(Some(*due_at_utc))
            } else {
                Ok(None)
            }
        }
        TriggerSpec::Interval(spec) => next_interval_due_after(spec, after_utc),
        TriggerSpec::Calendar(spec) => next_calendar_due_after(spec, after_utc),
    }
}

pub fn occurrences_for_horizon(
    trigger: &TriggerSpec,
    cursor_utc: Option<DateTime<Utc>>,
    horizon_end_utc: DateTime<Utc>,
    max_occurrences: usize,
) -> Result<Vec<DateTime<Utc>>, ScheduleDomainError> {
    if max_occurrences == 0 {
        return Ok(Vec::new());
    }

    if let TriggerSpec::Calendar(spec) = trigger {
        let tz = normalize_timezone(&spec.timezone)?;
        let search_start =
            next_minute_boundary(cursor_utc.unwrap_or_else(|| Utc::now() - Duration::minutes(1)));
        return scan_calendar(spec, tz, search_start, horizon_end_utc, max_occurrences);
    }

    let mut occurrences = Vec::new();
    let mut cursor = cursor_utc;

    while occurrences.len() < max_occurrences {
        let Some(next_due) = next_due_after(trigger, cursor)? else {
            break;
        };
        if next_due > horizon_end_utc {
            break;
        }
        occurrences.push(next_due);
        cursor = Some(next_due);
    }

    Ok(occurrences)
}

fn next_interval_due_after(
    spec: &IntervalTriggerSpec,
    after_utc: Option<DateTime<Utc>>,
) -> Result<Option<DateTime<Utc>>, ScheduleDomainError> {
    if spec.every_seconds == 0 {
        return Err(ScheduleDomainError::InvalidTrigger(
            "interval trigger requires every_seconds >= 1".to_string(),
        ));
    }

    let step_seconds = i64::try_from(spec.every_seconds).map_err(|_| {
        ScheduleDomainError::InvalidTrigger("interval every_seconds exceeds i64 range".to_string())
    })?;

    let candidate = match after_utc {
        None => spec.start_at_utc,
        Some(after) if after < spec.start_at_utc => spec.start_at_utc,
        Some(after) => {
            let elapsed = after.signed_duration_since(spec.start_at_utc);
            let mut intervals = elapsed.num_seconds().div_euclid(step_seconds) + 1;
            let mut candidate = spec.start_at_utc + Duration::seconds(intervals * step_seconds);
            while candidate <= after {
                intervals += 1;
                candidate = spec.start_at_utc + Duration::seconds(intervals * step_seconds);
            }
            candidate
        }
    };

    if spec.end_at_utc.is_some_and(|end_at| candidate > end_at) {
        Ok(None)
    } else {
        Ok(Some(candidate))
    }
}

fn next_calendar_due_after(
    spec: &CalendarTriggerSpec,
    after_utc: Option<DateTime<Utc>>,
) -> Result<Option<DateTime<Utc>>, ScheduleDomainError> {
    let tz = normalize_timezone(&spec.timezone)?;
    let search_start = next_minute_boundary(after_utc.unwrap_or_else(Utc::now));
    let search_end = search_start + Duration::days(366 * 5);

    scan_calendar(spec, tz, search_start, search_end, 1).map(|mut matches| matches.pop())
}

fn scan_calendar(
    spec: &CalendarTriggerSpec,
    tz: Tz,
    start_utc: DateTime<Utc>,
    end_utc: DateTime<Utc>,
    max_matches: usize,
) -> Result<Vec<DateTime<Utc>>, ScheduleDomainError> {
    if end_utc < start_utc {
        return Ok(Vec::new());
    }

    let mut matches = Vec::new();
    let mut cursor = truncate_to_minute(start_utc);

    while cursor <= end_utc && matches.len() < max_matches {
        let local = cursor.with_timezone(&tz);
        if matches_calendar(spec, &local) {
            matches.push(cursor);
        }
        cursor += Duration::minutes(1);
    }

    Ok(matches)
}

fn matches_calendar(spec: &CalendarTriggerSpec, local: &chrono::DateTime<Tz>) -> bool {
    spec.minute.contains(local.minute())
        && spec.hour.contains(local.hour())
        && spec.day_of_month.contains(local.day())
        && spec.month.contains(local.month())
        && spec
            .day_of_week
            .contains(local.weekday().num_days_from_sunday())
        && spec
            .year
            .as_ref()
            .is_none_or(|years| years.contains(local.year_ce().1))
}

fn truncate_to_minute(input: DateTime<Utc>) -> DateTime<Utc> {
    match Utc.with_ymd_and_hms(
        input.year(),
        input.month(),
        input.day(),
        input.hour(),
        input.minute(),
        0,
    ) {
        LocalResult::Single(value) => value,
        _ => input,
    }
}

fn next_minute_boundary(after: DateTime<Utc>) -> DateTime<Utc> {
    let truncated = truncate_to_minute(after);
    if truncated <= after {
        truncated + Duration::minutes(1)
    } else {
        truncated
    }
}

fn normalize_timezone(value: &str) -> Result<Tz, ScheduleDomainError> {
    value
        .parse::<Tz>()
        .map_err(|_| ScheduleDomainError::InvalidTrigger(format!("invalid IANA timezone: {value}")))
}

#[derive(Clone, Copy)]
enum ValueDomain {
    Minute,
    Hour,
    DayOfMonth,
    Month,
    DayOfWeek,
}

fn parse_field(
    raw: &str,
    min: u32,
    max: u32,
    domain: ValueDomain,
) -> Result<CalendarFieldSpec, ScheduleDomainError> {
    if raw == "*" {
        return Ok(CalendarFieldSpec::Any);
    }

    let mut values = BTreeSet::new();
    for part in raw.split(',') {
        parse_part(part.trim(), min, max, domain, &mut values)?;
    }

    if values.is_empty() {
        return Err(ScheduleDomainError::InvalidCron(format!(
            "cron field `{raw}` expands to no values"
        )));
    }

    Ok(CalendarFieldSpec::Values(values.into_iter().collect()))
}

fn parse_part(
    raw: &str,
    min: u32,
    max: u32,
    domain: ValueDomain,
    out: &mut BTreeSet<u32>,
) -> Result<(), ScheduleDomainError> {
    let (base, step) = match raw.split_once('/') {
        Some((base, step)) => (base, Some(parse_u32(step, domain)?)),
        None => (raw, None),
    };

    if step == Some(0) {
        return Err(ScheduleDomainError::InvalidCron(format!(
            "cron step must be >= 1 in `{raw}`"
        )));
    }

    let (range_start, range_end) = if base == "*" {
        (min, max)
    } else if let Some((start, end)) = base.split_once('-') {
        (
            normalize_field_value(start, domain)?,
            normalize_field_value(end, domain)?,
        )
    } else {
        let value = normalize_field_value(base, domain)?;
        (value, value)
    };

    if range_start < min || range_end > max || range_start > range_end {
        return Err(ScheduleDomainError::InvalidCron(format!(
            "cron field `{raw}` is outside the allowed range {min}..={max}"
        )));
    }

    let step = step.unwrap_or(1);
    let mut value = range_start;
    while value <= range_end {
        out.insert(value);
        match value.checked_add(step) {
            Some(next) => value = next,
            None => break,
        }
    }
    Ok(())
}

fn normalize_field_value(raw: &str, domain: ValueDomain) -> Result<u32, ScheduleDomainError> {
    let upper = raw.trim().to_ascii_uppercase();
    let parsed = match domain {
        ValueDomain::Month => month_name(&upper).or_else(|| upper.parse::<u32>().ok()),
        ValueDomain::DayOfWeek => day_of_week_name(&upper).or_else(|| upper.parse::<u32>().ok()),
        _ => upper.parse::<u32>().ok(),
    }
    .ok_or_else(|| ScheduleDomainError::InvalidCron(format!("invalid cron token `{raw}`")))?;

    if matches!(domain, ValueDomain::DayOfWeek) && parsed == 7 {
        Ok(0)
    } else {
        Ok(parsed)
    }
}

fn parse_u32(raw: &str, domain: ValueDomain) -> Result<u32, ScheduleDomainError> {
    normalize_field_value(raw, domain)
}

fn month_name(value: &str) -> Option<u32> {
    match value {
        "JAN" => Some(1),
        "FEB" => Some(2),
        "MAR" => Some(3),
        "APR" => Some(4),
        "MAY" => Some(5),
        "JUN" => Some(6),
        "JUL" => Some(7),
        "AUG" => Some(8),
        "SEP" => Some(9),
        "OCT" => Some(10),
        "NOV" => Some(11),
        "DEC" => Some(12),
        _ => None,
    }
}

fn day_of_week_name(value: &str) -> Option<u32> {
    match value {
        "SUN" => Some(0),
        "MON" => Some(1),
        "TUE" | "TUES" => Some(2),
        "WED" => Some(3),
        "THU" | "THUR" | "THURS" => Some(4),
        "FRI" => Some(5),
        "SAT" => Some(6),
        _ => None,
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn cron_codec_normalizes_calendar_trigger() {
        let spec = CronAuthoringSpec {
            expression: "*/15 9-17 * * MON-FRI".to_string(),
            timezone: "Europe/Stockholm".to_string(),
        };

        let calendar = spec.into_calendar().expect("cron should parse");
        assert!(matches!(
            calendar.minute,
            CalendarFieldSpec::Values(ref values)
                if values == &[0, 15, 30, 45]
        ));
        assert!(matches!(
            calendar.hour,
            CalendarFieldSpec::Values(ref values)
                if values == &[9, 10, 11, 12, 13, 14, 15, 16, 17]
        ));
    }

    #[test]
    fn interval_trigger_uses_strictly_future_occurrences() {
        let spec = IntervalTriggerSpec {
            start_at_utc: Utc.with_ymd_and_hms(2026, 4, 2, 9, 0, 0).unwrap(),
            every_seconds: 300,
            end_at_utc: None,
        };

        let due = next_interval_due_after(
            &spec,
            Some(Utc.with_ymd_and_hms(2026, 4, 2, 9, 10, 0).unwrap()),
        )
        .expect("interval should evaluate");

        assert_eq!(
            due,
            Some(Utc.with_ymd_and_hms(2026, 4, 2, 9, 15, 0).unwrap())
        );
    }

    #[test]
    fn calendar_trigger_handles_dst_by_scanning_utc_minutes() {
        let trigger = TriggerSpec::Calendar(CalendarTriggerSpec {
            timezone: "Europe/Stockholm".to_string(),
            minute: CalendarFieldSpec::Values(vec![30]),
            hour: CalendarFieldSpec::Values(vec![2]),
            day_of_month: CalendarFieldSpec::Any,
            month: CalendarFieldSpec::Any,
            day_of_week: CalendarFieldSpec::Any,
            year: None,
        });

        let after = Utc.with_ymd_and_hms(2026, 3, 28, 23, 0, 0).unwrap();
        let due = next_due_after(&trigger, Some(after)).expect("calendar scan should succeed");

        assert!(due.is_some());
    }
}