internal/ci/cron.go

af7479c3da05687175f211b836c3e428233a65ab
gitbay/internal/ci/cron.go history · blame · raw

118 lines · 3011 bytes

  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}