281 lines
7.1 KiB
Go
Raw Permalink Normal View History

2026-03-12 20:51:38 -07:00
package scheduler
import (
"fmt"
"strconv"
"strings"
"time"
)
// CronExpr represents a parsed 5-field cron expression.
// Each field is expanded into a sorted slice of valid integer values.
type CronExpr struct {
Minutes []int // 0-59
Hours []int // 0-23
DaysOfMonth []int // 1-31
Months []int // 1-12
DaysOfWeek []int // 0-6 (0 = Sunday)
}
// fieldBounds defines the min/max for each cron field.
var fieldBounds = [5][2]int{
{0, 59}, // minute
{0, 23}, // hour
{1, 31}, // day of month
{1, 12}, // month
{0, 6}, // day of week
}
// ParseCron parses a standard 5-field cron expression into a CronExpr.
//
// Supported syntax per field:
// - * all values in range
// - N single number
// - N-M range from N to M inclusive
// - N-M/S range with step S
// - */S full range with step S
// - N,M,O list of values (each element can be a number or range)
func ParseCron(expr string) (*CronExpr, error) {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return nil, fmt.Errorf("cron: expected 5 fields, got %d in %q", len(fields), expr)
}
ce := &CronExpr{}
targets := []*[]int{&ce.Minutes, &ce.Hours, &ce.DaysOfMonth, &ce.Months, &ce.DaysOfWeek}
for i, field := range fields {
vals, err := parseField(field, fieldBounds[i][0], fieldBounds[i][1])
if err != nil {
return nil, fmt.Errorf("cron field %d (%q): %w", i+1, field, err)
}
if len(vals) == 0 {
return nil, fmt.Errorf("cron field %d (%q): produced no values", i+1, field)
}
*targets[i] = vals
}
return ce, nil
}
// parseField parses a single cron field into a sorted slice of ints.
func parseField(field string, min, max int) ([]int, error) {
// Handle lists: "1,3,5" or "1-3,7,10-12"
parts := strings.Split(field, ",")
seen := make(map[int]bool)
for _, part := range parts {
vals, err := parsePart(part, min, max)
if err != nil {
return nil, err
}
for _, v := range vals {
seen[v] = true
}
}
// Collect and sort.
result := make([]int, 0, len(seen))
for v := range seen {
result = append(result, v)
}
sortInts(result)
return result, nil
}
// parsePart parses a single element that may be *, a number, a range, or have a step.
func parsePart(part string, min, max int) ([]int, error) {
// Split on "/" for step.
var stepStr string
base := part
if idx := strings.Index(part, "/"); idx >= 0 {
base = part[:idx]
stepStr = part[idx+1:]
}
// Determine the range.
var lo, hi int
if base == "*" {
lo, hi = min, max
} else if idx := strings.Index(base, "-"); idx >= 0 {
var err error
lo, err = strconv.Atoi(base[:idx])
if err != nil {
return nil, fmt.Errorf("invalid number %q: %w", base[:idx], err)
}
hi, err = strconv.Atoi(base[idx+1:])
if err != nil {
return nil, fmt.Errorf("invalid number %q: %w", base[idx+1:], err)
}
} else {
n, err := strconv.Atoi(base)
if err != nil {
return nil, fmt.Errorf("invalid number %q: %w", base, err)
}
if stepStr == "" {
// Single value, no step.
if n < min || n > max {
return nil, fmt.Errorf("value %d out of range [%d, %d]", n, min, max)
}
return []int{n}, nil
}
// e.g., "5/10" means starting at 5, step 10, up to max.
lo, hi = n, max
}
// Validate bounds.
if lo < min || lo > max {
return nil, fmt.Errorf("value %d out of range [%d, %d]", lo, min, max)
}
if hi < min || hi > max {
return nil, fmt.Errorf("value %d out of range [%d, %d]", hi, min, max)
}
if lo > hi {
return nil, fmt.Errorf("range start %d > end %d", lo, hi)
}
step := 1
if stepStr != "" {
var err error
step, err = strconv.Atoi(stepStr)
if err != nil {
return nil, fmt.Errorf("invalid step %q: %w", stepStr, err)
}
if step < 1 {
return nil, fmt.Errorf("step must be >= 1, got %d", step)
}
}
var vals []int
for v := lo; v <= hi; v += step {
vals = append(vals, v)
}
return vals, nil
}
// NextRun computes the next run time for a cron expression after the given time.
// It searches up to 2 years ahead before giving up.
func NextRun(schedule string, from time.Time) (time.Time, error) {
ce, err := ParseCron(schedule)
if err != nil {
return time.Time{}, err
}
return ce.Next(from)
}
// Next finds the earliest time after "from" that matches the cron expression.
func (ce *CronExpr) Next(from time.Time) (time.Time, error) {
// Start from the next whole minute.
t := from.Truncate(time.Minute).Add(time.Minute)
// Search limit: 2 years of minutes (~1,051,200). We iterate by
// advancing fields intelligently rather than minute-by-minute.
deadline := t.Add(2 * 365 * 24 * time.Hour)
for t.Before(deadline) {
// Check month.
if !contains(ce.Months, int(t.Month())) {
// Advance to next valid month.
t = advanceMonth(t, ce.Months)
continue
}
// Check day of month.
dom := t.Day()
domOk := contains(ce.DaysOfMonth, dom)
dowOk := contains(ce.DaysOfWeek, int(t.Weekday()))
if !domOk || !dowOk {
// Advance one day.
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
continue
}
// Check hour.
if !contains(ce.Hours, t.Hour()) {
// Advance to next valid hour today.
nextH := nextVal(ce.Hours, t.Hour())
if nextH == -1 {
// No more valid hours today, go to next day.
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
} else {
t = time.Date(t.Year(), t.Month(), t.Day(), nextH, 0, 0, 0, t.Location())
}
continue
}
// Check minute.
if !contains(ce.Minutes, t.Minute()) {
nextM := nextVal(ce.Minutes, t.Minute())
if nextM == -1 {
// No more valid minutes this hour, advance hour.
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
} else {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), nextM, 0, 0, t.Location())
}
continue
}
// All fields match.
return t, nil
}
return time.Time{}, fmt.Errorf("cron: no matching time found within 2 years for %q", ce.String())
}
// String reconstructs a human-readable representation of the cron expression.
func (ce *CronExpr) String() string {
return fmt.Sprintf("%v %v %v %v %v",
ce.Minutes, ce.Hours, ce.DaysOfMonth, ce.Months, ce.DaysOfWeek)
}
// contains checks if val is in the sorted slice.
func contains(vals []int, val int) bool {
for _, v := range vals {
if v == val {
return true
}
if v > val {
return false
}
}
return false
}
// nextVal returns the smallest value in vals that is > current, or -1.
func nextVal(vals []int, current int) int {
for _, v := range vals {
if v > current {
return v
}
}
return -1
}
// advanceMonth jumps to day 1, hour 0, minute 0 of the next valid month.
func advanceMonth(t time.Time, months []int) time.Time {
cur := int(t.Month())
year := t.Year()
// Find next valid month in this year.
for _, m := range months {
if m > cur {
return time.Date(year, time.Month(m), 1, 0, 0, 0, 0, t.Location())
}
}
// Wrap to first valid month of next year.
return time.Date(year+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location())
}
// sortInts performs an insertion sort on a small slice.
func sortInts(a []int) {
for i := 1; i < len(a); i++ {
key := a[i]
j := i - 1
for j >= 0 && a[j] > key {
a[j+1] = a[j]
j--
}
a[j+1] = key
}
}