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

194 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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<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.**
```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, 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.