package scheduler
import (
"fmt"
"math/rand/v2"
"strconv"
"strings"
"time"
"github.com/robfig/cron/v3"
)
var parser = cron.NewParser(
cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)
func ParseCrontab(spec string) (cron.Schedule, error) {
if spec == "" {
return nil, fmt.Errorf("empty spec string")
}
if _, err := time.ParseDuration(spec); err == nil {
spec = "@every " + spec
}
if !strings.Contains(spec, "~") {
return parser.Parse(spec)
}
var tzPrefix string
if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") {
i := strings.Index(spec, " ")
if i == -1 {
return nil, fmt.Errorf("missing spec after timezone")
}
tzPrefix = spec[:i] + " "
spec = strings.TrimSpace(spec[i:])
}
if strings.HasPrefix(spec, "@") {
return nil, fmt.Errorf("random ~ syntax cannot be used with descriptors: %s", spec)
}
fields := strings.Fields(spec)
fields, err := normalizeFields(fields)
if err != nil {
return nil, err
}
for i, field := range fields {
if !strings.Contains(field, "~") {
continue
}
if strings.ContainsAny(field, ",/") {
return nil, fmt.Errorf("random ~ cannot be combined with lists or steps: %s", field)
}
v, parseErr := resolveRandomField(field, fieldBounds[i])
if parseErr != nil {
return nil, parseErr
}
fields[i] = strconv.FormatUint(uint64(v), 10)
}
resolved := tzPrefix + strings.Join(fields, " ")
return parser.Parse(resolved)
}
type bounds struct {
min, max uint
}
var fieldBounds = [6]bounds{
{0, 59},
{0, 59},
{0, 23},
{1, 31},
{1, 12},
{0, 6},
}
func resolveRandomField(field string, b bounds) (uint, error) {
parts := strings.SplitN(field, "~", 2)
min := b.min
max := b.max
if parts[0] != "" {
v, err := strconv.ParseUint(parts[0], 10, 0)
if err != nil {
return 0, fmt.Errorf("invalid random range start: %s", parts[0])
}
min = uint(v)
}
if parts[1] != "" {
v, err := strconv.ParseUint(parts[1], 10, 0)
if err != nil {
return 0, fmt.Errorf("invalid random range end: %s", parts[1])
}
max = uint(v)
}
if min < b.min {
return 0, fmt.Errorf("random range start (%d) below minimum (%d): %s", min, b.min, field)
}
if max > b.max {
return 0, fmt.Errorf("random range end (%d) above maximum (%d): %s", max, b.max, field)
}
if min > max {
return 0, fmt.Errorf("random range start (%d) beyond end (%d): %s", min, max, field)
}
return min + uint(rand.IntN(int(max-min+1))), nil
}
func normalizeFields(fields []string) ([]string, error) {
switch len(fields) {
case 5:
return append([]string{"0"}, fields...), nil
case 6:
return fields, nil
default:
return nil, fmt.Errorf("expected 5 or 6 fields, found %d: %v", len(fields), fields)
}
}