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>
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). Aftergate: ok, do not add uptest result:lines. -
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's last line is notgate: ok; 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.