internal/ci/cron.go
118 lines · 3011 bytes
5 symbols in this file
1package ci
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7 "time"
8)
9
10// Cron is a parsed five-field expression: minute, hour, day-of-month,
11// month, day-of-week. Supported per field: "*", N, A-B, */N, A-B/N, and
12// comma lists. Day-of-month and day-of-week combine with OR when both are
13// restricted, per traditional cron.
14type Cron struct {
15 min, hour, dom, mon, dow map[int]bool
16 domAny, dowAny bool
17}
18
19// ParseCron validates and compiles an expression like "17 11,23 * * *".
20func ParseCron(expr string) (Cron, error) {
21 fields := strings.Fields(expr)
22 if len(fields) != 5 {
23 return Cron{}, fmt.Errorf("cron %q: want 5 fields (min hour dom mon dow), got %d", expr, len(fields))
24 }
25 specs := []struct {
26 lo, hi int
27 }{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
28 var sets [5]map[int]bool
29 for i, f := range fields {
30 set, err := parseField(f, specs[i].lo, specs[i].hi)
31 if err != nil {
32 return Cron{}, fmt.Errorf("cron %q field %d: %w", expr, i+1, err)
33 }
34 sets[i] = set
35 }
36 // dow 7 is Sunday, same as 0.
37 if sets[4][7] {
38 sets[4][0] = true
39 }
40 return Cron{
41 min: sets[0], hour: sets[1], dom: sets[2], mon: sets[3], dow: sets[4],
42 domAny: fields[2] == "*", dowAny: fields[4] == "*",
43 }, nil
44}
45
46func parseField(f string, lo, hi int) (map[int]bool, error) {
47 set := map[int]bool{}
48 for _, part := range strings.Split(f, ",") {
49 rangePart, stepPart, hasStep := strings.Cut(part, "/")
50 step := 1
51 if hasStep {
52 s, err := strconv.Atoi(stepPart)
53 if err != nil || s < 1 {
54 return nil, fmt.Errorf("bad step %q", stepPart)
55 }
56 step = s
57 }
58 a, b := lo, hi
59 if rangePart != "*" {
60 loStr, hiStr, isRange := strings.Cut(rangePart, "-")
61 n, err := strconv.Atoi(loStr)
62 if err != nil {
63 return nil, fmt.Errorf("bad value %q", loStr)
64 }
65 a = n
66 if isRange {
67 m, err := strconv.Atoi(hiStr)
68 if err != nil {
69 return nil, fmt.Errorf("bad value %q", hiStr)
70 }
71 b = m
72 } else if hasStep {
73 b = hi // "N/step" means N..hi by step
74 } else {
75 b = n
76 }
77 }
78 if a < lo || b > hi || a > b {
79 return nil, fmt.Errorf("%q out of range %d-%d", part, lo, hi)
80 }
81 for v := a; v <= b; v += step {
82 set[v] = true
83 }
84 }
85 return set, nil
86}
87
88// Matches reports whether the expression fires at t (minute precision).
89func (c Cron) Matches(t time.Time) bool {
90 if !c.min[t.Minute()] || !c.hour[t.Hour()] || !c.mon[int(t.Month())] {
91 return false
92 }
93 domOK := c.dom[t.Day()]
94 dowOK := c.dow[int(t.Weekday())]
95 switch {
96 case c.domAny && c.dowAny:
97 return true
98 case c.domAny:
99 return dowOK
100 case c.dowAny:
101 return domOK
102 default:
103 return domOK || dowOK // both restricted: traditional OR
104 }
105}
106
107// Next returns the first firing time strictly after t, or the zero time if
108// none exists within a year (an impossible date like Feb 30).
109func (c Cron) Next(t time.Time) time.Time {
110 t = t.Truncate(time.Minute).Add(time.Minute)
111 limit := t.AddDate(1, 0, 1)
112 for ; t.Before(limit); t = t.Add(time.Minute) {
113 if c.Matches(t) {
114 return t
115 }
116 }
117 return time.Time{}
118}