// Minimal 5-field cron parser for control-logic cron triggers. // // Fields, in order: minute hour day-of-month month day-of-week. // Each field supports: // // * any value // */n every n (step over the whole range) // a-b inclusive range // a-b/n range with step // a,b,c comma-separated list of the above // N a single value // // Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both // day-of-month and day-of-week are restricted (neither is "*"), the schedule // matches when EITHER matches, following Vixie cron semantics. package controllogic import ( "fmt" "strconv" "strings" "time" ) // Schedule is a parsed 5-field cron specification. type Schedule struct { minute uint64 // bitmask 0..59 hour uint64 // bitmask 0..23 dom uint64 // bitmask 1..31 month uint64 // bitmask 1..12 dow uint64 // bitmask 0..6 domStar bool // day-of-month field was "*" dowStar bool // day-of-week field was "*" } // ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated. func ParseSchedule(spec string) (*Schedule, error) { fields := strings.Fields(spec) if len(fields) != 5 { return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields)) } s := &Schedule{} var err error if s.minute, err = parseField(fields[0], 0, 59); err != nil { return nil, fmt.Errorf("minute: %w", err) } if s.hour, err = parseField(fields[1], 0, 23); err != nil { return nil, fmt.Errorf("hour: %w", err) } if s.dom, err = parseField(fields[2], 1, 31); err != nil { return nil, fmt.Errorf("day-of-month: %w", err) } if s.month, err = parseField(fields[3], 1, 12); err != nil { return nil, fmt.Errorf("month: %w", err) } if s.dow, err = parseDOW(fields[4]); err != nil { return nil, fmt.Errorf("day-of-week: %w", err) } s.domStar = strings.TrimSpace(fields[2]) == "*" s.dowStar = strings.TrimSpace(fields[4]) == "*" return s, nil } // Match reports whether t (truncated to the minute) satisfies the schedule. func (s *Schedule) Match(t time.Time) bool { if s.minute&bit(t.Minute()) == 0 { return false } if s.hour&bit(t.Hour()) == 0 { return false } if s.month&bit(int(t.Month())) == 0 { return false } domMatch := s.dom&bit(t.Day()) != 0 dowMatch := s.dow&bit(int(t.Weekday())) != 0 // Vixie semantics: when both day fields are restricted, match either. switch { case s.domStar && s.dowStar: return true case s.domStar: return dowMatch case s.dowStar: return domMatch default: return domMatch || dowMatch } } func bit(n int) uint64 { return uint64(1) << uint(n) } func parseDOW(field string) (uint64, error) { // Normalise 7 → 0 (both mean Sunday) by parsing then folding. mask, err := parseField(field, 0, 7) if err != nil { return 0, err } if mask&bit(7) != 0 { mask = (mask &^ bit(7)) | bit(0) } return mask, nil } func parseField(field string, lo, hi int) (uint64, error) { var mask uint64 for _, part := range strings.Split(field, ",") { m, err := parsePart(strings.TrimSpace(part), lo, hi) if err != nil { return 0, err } mask |= m } if mask == 0 { return 0, fmt.Errorf("empty field %q", field) } return mask, nil } func parsePart(part string, lo, hi int) (uint64, error) { if part == "" { return 0, fmt.Errorf("empty term") } step := 1 rangePart := part if idx := strings.IndexByte(part, '/'); idx >= 0 { rangePart = part[:idx] st, err := strconv.Atoi(part[idx+1:]) if err != nil || st < 1 { return 0, fmt.Errorf("bad step %q", part[idx+1:]) } step = st } start, end := lo, hi if rangePart != "*" { if idx := strings.IndexByte(rangePart, '-'); idx >= 0 { a, err1 := strconv.Atoi(rangePart[:idx]) b, err2 := strconv.Atoi(rangePart[idx+1:]) if err1 != nil || err2 != nil { return 0, fmt.Errorf("bad range %q", rangePart) } start, end = a, b } else { v, err := strconv.Atoi(rangePart) if err != nil { return 0, fmt.Errorf("bad value %q", rangePart) } start, end = v, v } } if start < lo || end > hi || start > end { return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part) } var mask uint64 for v := start; v <= end; v += step { mask |= bit(v) } return mask, nil }