Files
boxmaker/docs/plans/M5d2/04-loopd-cron.md
T
kyleandGrok 4.6 2a48b7f36d docs: M5d-2: after gate: ok, do not sum test results
Task 01 told Ornith to add up every `test result:` N passed. A greedy
sed kept the last digit (1512 became 1022) and it stopped with the
correct T1 and given test uncommitted. T50/T59: the last line of
make gate is the count; summing is not a stop.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 12:56:14 -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). After gate: ok, do not add up test result: lines.

  • 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's last line is not gate: ok; 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.