162 lines
4.2 KiB
Go
162 lines
4.2 KiB
Go
// 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
|
|
}
|