package backup import ( "fmt" "strconv" "strings" "time" ) // Schedule is a parsed five-field cron expression: // minute hour day-of-month month day-of-week. type Schedule struct { minute, hour, dom, month, dow uint64 // bit n set = value n allowed domAny, dowAny bool } var shorthands = map[string]string{ "@hourly": "0 * * * *", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@weekly": "0 0 * * 0", "@monthly": "0 0 1 * *", "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", } 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} // ParseCron parses standard cron syntax: *, lists (1,15), ranges (1-5), // steps (*/15, 0-30/10), month and weekday names, and the @daily-style // shorthands. Sunday is 0 or 7. func ParseCron(expr string) (Schedule, error) { expr = strings.TrimSpace(strings.ToLower(expr)) if s, ok := shorthands[expr]; ok { expr = s } f := strings.Fields(expr) if len(f) != 5 { return Schedule{}, fmt.Errorf("a cron schedule has 5 fields (minute hour day month weekday), got %d", len(f)) } var s Schedule var err error if s.minute, err = parseField(f[0], 0, 59, nil); err != nil { return s, fmt.Errorf("minute: %w", err) } if s.hour, err = parseField(f[1], 0, 23, nil); err != nil { return s, fmt.Errorf("hour: %w", err) } if s.dom, err = parseField(f[2], 1, 31, nil); err != nil { return s, fmt.Errorf("day of month: %w", err) } if s.month, err = parseField(f[3], 1, 12, monthNames); err != nil { return s, fmt.Errorf("month: %w", err) } if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil { return s, fmt.Errorf("weekday: %w", err) } if s.dow&(1<<7) != 0 { s.dow |= 1 // 7 is Sunday too } s.domAny = f[2] == "*" || strings.HasPrefix(f[2], "*/") s.dowAny = f[4] == "*" || strings.HasPrefix(f[4], "*/") return s, nil } func parseField(field string, min, max int, names map[string]int) (uint64, error) { var bits uint64 for _, part := range strings.Split(field, ",") { rng, stepStr, hasStep := strings.Cut(part, "/") step := 1 if hasStep { n, err := strconv.Atoi(stepStr) if err != nil || n < 1 { return 0, fmt.Errorf("bad step %q", stepStr) } step = n } lo, hi := min, max if rng != "*" { a, b, isRange := strings.Cut(rng, "-") var err error if lo, err = parseValue(a, min, max, names); err != nil { return 0, err } hi = lo if isRange { if hi, err = parseValue(b, min, max, names); err != nil { return 0, err } if hi < lo { return 0, fmt.Errorf("range %q runs backwards", rng) } } else if hasStep { hi = max // "5/10" means from 5 to the end, every 10 } } for v := lo; v <= hi; v += step { bits |= 1 << uint(v) } } return bits, nil } func parseValue(s string, min, max int, names map[string]int) (int, error) { if n, ok := names[s]; ok { return n, nil } n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("%q isn't a number", s) } if n < min || n > max { return 0, fmt.Errorf("%d is outside %d-%d", n, min, max) } return n, nil } // Next returns the first matching minute strictly after t, in t's // location. It returns the zero time if nothing matches within 5 years // (e.g. "0 0 30 2 *"). func (s Schedule) Next(t time.Time) time.Time { t = t.Truncate(time.Minute).Add(time.Minute) limit := t.AddDate(5, 0, 0) for t.Before(limit) { if s.month&(1<