Files
hamlab/internal/cron/schedule.go
T

164 lines
4.0 KiB
Go

package cron
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// schedule is a parsed five-field cron expression as sets.
type schedule struct {
min, hour, dom, mon, dow [64]bool
domStar, dowStar bool // "*" in dom/dow changes the match rule
reboot bool
}
var monthNames = map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}
var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
var aliases = map[string]string{
"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0",
"@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *",
}
func parseSchedule(expr string) (*schedule, error) {
expr = strings.TrimSpace(expr)
if expr == "@reboot" {
return &schedule{reboot: true}, nil
}
if a, ok := aliases[strings.ToLower(expr)]; ok {
expr = a
}
f := strings.Fields(expr)
if len(f) != 5 {
return nil, errors.New("expected 5 fields")
}
s := &schedule{}
var err error
if s.min, err = parseField(f[0], 0, 59, nil); err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
if s.hour, err = parseField(f[1], 0, 23, nil); err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
if s.dom, err = parseField(f[2], 1, 31, nil); err != nil {
return nil, fmt.Errorf("day of month: %w", err)
}
if s.mon, err = parseField(f[3], 1, 12, monthNames); err != nil {
return nil, fmt.Errorf("month: %w", err)
}
if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil {
return nil, fmt.Errorf("day of week: %w", err)
}
if s.dow[7] { // 7 == Sunday
s.dow[0] = true
}
s.domStar = f[2] == "*"
s.dowStar = f[4] == "*"
return s, nil
}
func parseField(field string, lo, hi int, names map[string]int) ([64]bool, error) {
var set [64]bool
atom := func(s string) (int, error) {
if names != nil {
if v, ok := names[strings.ToLower(s)]; ok {
return v, nil
}
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("bad value %q", s)
}
return v, nil
}
for _, part := range strings.Split(field, ",") {
step := 1
if i := strings.Index(part, "/"); i >= 0 {
v, err := strconv.Atoi(part[i+1:])
if err != nil || v <= 0 {
return set, fmt.Errorf("bad step in %q", part)
}
step, part = v, part[:i]
}
a, b := lo, hi
switch {
case part == "*":
case strings.Contains(part, "-"):
x, y, _ := strings.Cut(part, "-")
var err error
if a, err = atom(x); err != nil {
return set, err
}
if b, err = atom(y); err != nil {
return set, err
}
default:
v, err := atom(part)
if err != nil {
return set, err
}
a = v
if step == 1 {
b = v
}
}
if a < lo || b > hi || a > b {
return set, fmt.Errorf("out of range: %q", part)
}
for v := a; v <= b; v += step {
set[v] = true
}
}
return set, nil
}
func (s *schedule) dayMatches(t time.Time) bool {
if !s.mon[int(t.Month())] {
return false
}
dom, dow := s.dom[t.Day()], s.dow[int(t.Weekday())]
switch {
case s.domStar && s.dowStar:
return true
case s.domStar:
return dow
case s.dowStar:
return dom
default: // both restricted: cron runs when either matches
return dom || dow
}
}
// nextRun finds the first time strictly after `from` that the schedule fires.
func nextRun(expr string, from time.Time) (time.Time, bool) {
s, err := parseSchedule(expr)
if err != nil || s.reboot {
return time.Time{}, false
}
t := from.Truncate(time.Minute).Add(time.Minute)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
for i := 0; i < 366*2; i++ {
if s.dayMatches(day) {
for h := 0; h < 24; h++ {
if !s.hour[h] {
continue
}
for mi := 0; mi < 60; mi++ {
if !s.min[mi] {
continue
}
c := time.Date(day.Year(), day.Month(), day.Day(), h, mi, 0, 0, day.Location())
if !c.Before(t) {
return c, true
}
}
}
}
day = day.AddDate(0, 0, 1)
}
return time.Time{}, false
}