Files
boxmaker/docs/plans/M5d2/04-loopd-cron.md
T
kyleandGrok 4.6 df987c3058 docs: M5d-2 checklist: given-test lifetimes and cron AND
session_slot and the scheduler tests held a temporary JSON Value across
statements (E0716). Cron now pins DOM and DOW as AND, which the given tests
had not. Gate counts from task 04 on move up by one.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 01:31:07 -07:00

7.2 KiB
Raw Blame History

M5d-2 task 04: five-field UTC cron matcher

Branch: m5d2. Check it: git branch --show-current prints m5d2 and git status --short is empty; otherwise stop. Never switch branch. Commit subject: loopd: five-field UTC cron matcher Log row: the Task cell is M5d2/04-loopd-cron.

Goal

loopd::cron::due(schedule, Timestamp) -> Result<bool, CronError> (spec 4.2). Five fields: minute hour day-of-month month day-of-week. Each field is *, a single number, n-m, or */n. Day-of-week 0 is Sunday. No cron crate. DOM and DOW are AND (README known limits).

Nothing loads job files yet.

Every place that changes

Place What happens there
crates/loopd/src/cron.rs new: due, CronError
crates/loopd/src/lib.rs pub mod cron;
crates/loopd/tests/cron.rs new, given
everything else no change

Files

  • Copy, from docs/plans/M5d2/files/ (the same path under it): the new test crates/loopd/tests/cron.rs.
  • Create: crates/loopd/src/cron.rs (skeleton below).
  • Modify: crates/loopd/src/lib.rs, docs/implementer-log.md.
  • Do not touch: any other file.

Facts you need

  • Civil fields from RFC 3339. Timestamp::to_rfc3339 is YYYY-MM-DDTHH:MM:SS.mmmZ. Minute = rfc[14..16], hour = rfc[11..13], day = rfc[8..10], month = rfc[5..7], parsed as u32. If a slice is missing, CronError::Fields naming the schedule.
  • Weekday. 0 is Sunday. (unix_millis() / 1000 / 86400 + 4) % 7 as u32 (1970-01-01 was Thursday, so + 4). unix_millis is pub on Timestamp.
  • Ranges. Inclusive. */n matches when value % n == 0 (multiples from 0). n == 0 in a step is Fields.
  • Bounds. Minute 0–59, hour 0–23, DOM 1–31, month 1–12, DOW 0–6. A number outside its field's range is Fields. Four or six fields, @daily, and any other token are Fields.
  • Display. Names the schedule, e.g. contains x x x x x when that was the input. Do not add a runbook pointer; this error is used by job loading in task 05.
  • Split. split_whitespace. Five pieces or Fields.
  • rustfmt decides mod order in lib.rs. After pub mod compact; alphabetically cron comes after config and before http. Insert where rustfmt will put it; run cargo fmt before the gate.
  • The items the edits use. This table is complete.
    Item What it does
    Timestamp::to_rfc3339 civil fields
    Timestamp::unix_millis weekday
    str::parse::<u32> numbers

Skeleton

crates/loopd/src/cron.rs:

//! Five-field UTC crontab matcher (M5d spec, section 4.2). No cron crate.

use bxcore::Timestamp;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronError {
    Fields(String),
}

impl std::fmt::Display for CronError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CronError::Fields(schedule) => {
                write!(f, "cron schedule {schedule:?} is not five UTC fields")
            }
        }
    }
}

impl std::error::Error for CronError {}

/// Whether `schedule` matches `now` in UTC.
pub fn due(schedule: &str, now: Timestamp) -> Result<bool, CronError> {
    let fields: Vec<&str> = schedule.split_whitespace().collect();
    if fields.len() != 5 {
        return Err(CronError::Fields(schedule.to_string()));
    }
    let rfc = now.to_rfc3339();
    let minute = number(&rfc, 14, 16, schedule)?;
    let hour = number(&rfc, 11, 13, schedule)?;
    let day = number(&rfc, 8, 10, schedule)?;
    let month = number(&rfc, 5, 7, schedule)?;
    let dow = {
        let days = now.unix_millis() / 1000 / 86_400;
        ((days + 4) % 7) as u32
    };
    Ok(match_field(fields[0], minute, 0, 59, schedule)?
        && match_field(fields[1], hour, 0, 23, schedule)?
        && match_field(fields[2], day, 1, 31, schedule)?
        && match_field(fields[3], month, 1, 12, schedule)?
        && match_field(fields[4], dow, 0, 6, schedule)?)
}

fn number(rfc: &str, start: usize, end: usize, schedule: &str) -> Result<u32, CronError> {
    let piece = rfc
        .get(start..end)
        .ok_or_else(|| CronError::Fields(schedule.to_string()))?;
    piece
        .parse()
        .map_err(|_| CronError::Fields(schedule.to_string()))
}

fn match_field(
    field: &str,
    value: u32,
    min: u32,
    max: u32,
    schedule: &str,
) -> Result<bool, CronError> {
    let err = || CronError::Fields(schedule.to_string());
    if field == "*" {
        return Ok(true);
    }
    if let Some(step) = field.strip_prefix("*/") {
        let n: u32 = step.parse().map_err(|_| err())?;
        if n == 0 {
            return Err(err());
        }
        return Ok(value.is_multiple_of(n));
    }
    if let Some((a, b)) = field.split_once('-') {
        let lo: u32 = a.parse().map_err(|_| err())?;
        let hi: u32 = b.parse().map_err(|_| err())?;
        if lo < min || hi > max || lo > hi {
            return Err(err());
        }
        return Ok(value >= lo && value <= hi);
    }
    let n: u32 = field.parse().map_err(|_| err())?;
    if n < min || n > max {
        return Err(err());
    }
    Ok(n == value)
}

u32::is_multiple_of is stable in 1.98. Do not use % with a comment that n was checked; the skeleton already returns Fields for n == 0.

If is_multiple_of is not in scope on u32 in this toolchain, use value % n == 0 after the n == 0 check, and stop only if both fail to compile.

Steps

  • 1. Copy the given test.

    cp docs/plans/M5d2/files/crates/loopd/tests/cron.rs crates/loopd/tests/
    
  • 2. See it fail. cargo build --tests --keep-going -p loopd, timeout 600000 ms: test target cron does not compile (E0432 unresolved import loopd::cron). Every other target compiles.

  • 3. The module. Write cron.rs from the skeleton. Add pub mod cron; to lib.rs where rustfmt will put it. cargo check -p loopd prints no error and no warning.

  • 4. The tests. cargo test -p loopd --test cron: every test passes, 11 tests.

  • 5. Check. git diff --stat lists crates/loopd/src/cron.rs and crates/loopd/src/lib.rs.

  • 6. Run the gate. cargo fmt --all, then make gate, timeout 600000 ms. Last line gate: ok. Test count 1537 (1526 before, plus 11).

  • 7. Log and commit. Task cell M5d2/04-loopd-cron. Stage with git add crates/loopd docs/implementer-log.md Cargo.lock, then commit as AGENTS.md says.

Done when

  • make gate prints gate: ok with 1537 tests.
  • git diff HEAD~1 --stat lists only crates/loopd/src/cron.rs, crates/loopd/src/lib.rs, crates/loopd/tests/cron.rs and the log.

Stop and report if

  • The branch check fails; step 2 differs; cargo check warns; a test fails; the gate count is not 1537; you add a dependency; you treat DOM and DOW as OR; you accept @daily or a sixth field; cron.rs exceeds 500 lines.

What the given tests pin

0 7 * * * matches that minute only; * * * * * matches; */5 is multiples from 0; 0-10 is inclusive; Sunday is 0 on 2026-10-04; the 1st of the month; DOM and DOW are AND (0 7 1 * 0 matches 2026-11-01, not 2026-10-04 or 2026-10-01); four or six fields error; minute 60, hour 24, DOW 7 error; @daily errors; Display names the schedule.