package declaration // A minimal five-field cron, enough to say when a scheduled step is due (novox/hq ADR 0053). // // **Written rather than pulled in.** The host depends on nothing it does not have to // (novox/hq ADR 0005), and a scheduled step needs exactly two questions answered — *is this minute // a match* and *when is the next one* — over the ordinary five fields (minute, hour, day-of-month, // month, day-of-week) with `*`, lists (`,`), ranges (`-`) and steps (`/`). That is small enough to // keep in the vocabulary the host already owns, and a cron library would be a dependency carried // for the parts of it nobody here uses. // // **The host both validates and evaluates.** The control plane refuses a malformed cron near its // author (novox/hq ADR 0053), and the host refuses it again on arrival for the same near-versus-far // reason every other field is checked here: a declaration the host does not fully understand is // refused whole rather than half-applied. The evaluation half — `Next` — is what the scheduler // fires on. import ( "fmt" "strconv" "strings" "time" ) // Cron is a parsed five-field expression, each field held as a bitset of the values it permits. // // The two day fields carry a flag for whether they were written as a bare `*`, because standard // cron gives them a special rule: when day-of-month and day-of-week are *both* restricted, a day // matches if *either* does; when one is `*`, only the other is consulted. Getting that wrong is the // classic cron surprise, so the flag is kept rather than rediscovered. type Cron struct { minute uint64 hour uint64 dom uint64 month uint64 dow uint64 domStar bool dowStar bool } // ParseCron reads a five-field cron expression, or says why it is not one. func ParseCron(expr string) (*Cron, error) { fields := strings.Fields(expr) if len(fields) != 5 { return nil, fmt.Errorf( "a schedule is a five-field cron expression (minute hour day-of-month month "+ "day-of-week), and %q has %d field(s)", expr, len(fields)) } minute, _, err := parseCronField(fields[0], 0, 59) if err != nil { return nil, fmt.Errorf("schedule minute field: %w", err) } hour, _, err := parseCronField(fields[1], 0, 23) if err != nil { return nil, fmt.Errorf("schedule hour field: %w", err) } dom, domStar, err := parseCronField(fields[2], 1, 31) if err != nil { return nil, fmt.Errorf("schedule day-of-month field: %w", err) } month, _, err := parseCronField(fields[3], 1, 12) if err != nil { return nil, fmt.Errorf("schedule month field: %w", err) } // Day-of-week is 0-6 with Sunday at 0, and 7 is also accepted for Sunday — the convention every // cron keeps, so a crontab copied from elsewhere is not refused for saying 7. dow, dowStar, err := parseCronField(fields[4], 0, 7) if err != nil { return nil, fmt.Errorf("schedule day-of-week field: %w", err) } if dow&(1<<7) != 0 { dow |= 1 << 0 dow &^= 1 << 7 } return &Cron{ minute: minute, hour: hour, dom: dom, month: month, dow: dow, domStar: domStar, dowStar: dowStar, }, nil } // parseCronField turns one field into the bitset of values it permits, and reports whether it was a // bare `*` (which the day fields treat specially). func parseCronField(spec string, min, max int) (uint64, bool, error) { if spec == "" { return 0, false, fmt.Errorf("is empty") } star := spec == "*" var bits uint64 for _, part := range strings.Split(spec, ",") { if part == "" { return 0, false, fmt.Errorf("%q has an empty element between commas", spec) } // A step may follow either `*` or a range: `*/15`, `0-30/5`. step := 1 rangePart := part if slash := strings.IndexByte(part, '/'); slash >= 0 { rangePart = part[:slash] n, err := strconv.Atoi(part[slash+1:]) if err != nil || n < 1 { return 0, false, fmt.Errorf("step in %q is not a positive number", part) } step = n } lo, hi := min, max switch { case rangePart == "*": // Full range, already set. case strings.IndexByte(rangePart, '-') >= 0: dash := strings.IndexByte(rangePart, '-') a, errA := strconv.Atoi(rangePart[:dash]) b, errB := strconv.Atoi(rangePart[dash+1:]) if errA != nil || errB != nil { return 0, false, fmt.Errorf("range %q is not two numbers", rangePart) } lo, hi = a, b default: n, err := strconv.Atoi(rangePart) if err != nil { return 0, false, fmt.Errorf("%q is not a number", rangePart) } lo, hi = n, n } if lo < min || hi > max || lo > hi { return 0, false, fmt.Errorf( "%q is outside the allowed range %d-%d", part, min, max) } for v := lo; v <= hi; v += step { bits |= 1 << uint(v) } } return bits, star, nil } // Matches reports whether a scheduled step is due at this minute. func (c *Cron) Matches(t time.Time) bool { if c.minute&(1<