# 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` (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::` | numbers | ## Skeleton `crates/loopd/src/cron.rs`: ```rust //! 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 { 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 { 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 { 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.** ```sh 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, 10 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 **1535** (1525 before, plus 10). - [ ] **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 1535 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 1535; 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; four or six fields error; minute 60, hour 24, DOW 7 error; `@daily` errors; `Display` names the schedule.