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>
7.2 KiB
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 testcrates/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_rfc3339isYYYY-MM-DDTHH:MM:SS.mmmZ. Minute =rfc[14..16], hour =rfc[11..13], day =rfc[8..10], month =rfc[5..7], parsed asu32. If a slice is missing,CronError::Fieldsnaming the schedule. - Weekday.
0is Sunday.(unix_millis() / 1000 / 86400 + 4) % 7asu32(1970-01-01was Thursday, so+ 4).unix_millisispubonTimestamp. - Ranges. Inclusive.
*/nmatches whenvalue % n == 0(multiples from 0).n == 0in a step isFields. - 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 areFields. Display. Names the schedule, e.g. containsx x x x xwhen 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 orFields. - rustfmt decides
modorder inlib.rs. Afterpub mod compact;alphabeticallycroncomes afterconfigand beforehttp. Insert where rustfmt will put it; runcargo fmtbefore the gate. - The items the edits use. This table is complete.
Item What it does Timestamp::to_rfc3339civil fields Timestamp::unix_millisweekday 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 targetcrondoes not compile (E0432unresolved importloopd::cron). Every other target compiles. -
3. The module. Write
cron.rsfrom the skeleton. Addpub mod cron;tolib.rswhere rustfmt will put it.cargo check -p loopdprints no error and no warning. -
4. The tests.
cargo test -p loopd --test cron: every test passes, 11 tests. -
5. Check.
git diff --statlistscrates/loopd/src/cron.rsandcrates/loopd/src/lib.rs. -
6. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1537 (1526 before, plus 11). -
7. Log and commit. Task cell
M5d2/04-loopd-cron. Stage withgit add crates/loopd docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1537 tests.git diff HEAD~1 --statlists onlycrates/loopd/src/cron.rs,crates/loopd/src/lib.rs,crates/loopd/tests/cron.rsand the log.
Stop and report if
- The branch check fails; step 2 differs;
cargo checkwarns; a test fails; the gate count is not 1537; you add a dependency; you treat DOM and DOW as OR; you accept@dailyor a sixth field;cron.rsexceeds 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.