| 1 | // Package schedule is the platform's answer to "run this later": the WHEN of |
| 2 | // scheduled work, so an app that hibernates does not have to keep a process |
| 3 | // alive to keep a timer. Spec: docs/superpowers/specs/2026-08-23-scheduled-work-design.md. |
| 4 | // |
| 5 | // This file is the cron dialect. Deliberately small (five fields, UTC, no |
| 6 | // names, no @aliases — spec §4.1) and deliberately ours: the box binary is |
| 7 | // built with GOPROXY=off, and a parser this size is cheaper to own than to |
| 8 | // vendor. |
| 9 | package schedule |
| 10 | |
| 11 | import ( |
| 12 | "errors" |
| 13 | "fmt" |
| 14 | "strconv" |
| 15 | "strings" |
| 16 | "time" |
| 17 | ) |
| 18 | |
| 19 | // CronExpr is a parsed five-field expression. Each field is a bitset over |
| 20 | // its legal range (minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, |
| 21 | // day-of-week 0-6 with 7 folded onto 0). |
| 22 | type CronExpr struct { |
| 23 | min, hour, dom, month, dow uint64 |
| 24 | raw string |
| 25 | domStar, dowStar bool // "*" in a day field — the classic OR-vs-AND rule below |
| 26 | } |
| 27 | |
| 28 | func (c *CronExpr) String() string { return c.raw } |
| 29 | |
| 30 | // ParseCron accepts "min hour dom month dow" with *, n, a-b, */n, a-b/n and |
| 31 | // comma lists. Anything else — names, six fields, @daily — is an error with |
| 32 | // the field named, because the member typing it has no box to debug on. |
| 33 | func ParseCron(expr string) (*CronExpr, error) { |
| 34 | fields := strings.Fields(expr) |
| 35 | if len(fields) != 5 { |
| 36 | return nil, fmt.Errorf("cron: want 5 fields (min hour day month weekday), got %d", len(fields)) |
| 37 | } |
| 38 | c := &CronExpr{raw: strings.Join(fields, " ")} |
| 39 | var err error |
| 40 | if c.min, _, err = parseField(fields[0], 0, 59, "minute"); err != nil { |
| 41 | return nil, err |
| 42 | } |
| 43 | if c.hour, _, err = parseField(fields[1], 0, 23, "hour"); err != nil { |
| 44 | return nil, err |
| 45 | } |
| 46 | if c.dom, c.domStar, err = parseField(fields[2], 1, 31, "day"); err != nil { |
| 47 | return nil, err |
| 48 | } |
| 49 | if c.month, _, err = parseField(fields[3], 1, 12, "month"); err != nil { |
| 50 | return nil, err |
| 51 | } |
| 52 | if c.dow, c.dowStar, err = parseField(fields[4], 0, 7, "weekday"); err != nil { |
| 53 | return nil, err |
| 54 | } |
| 55 | if c.dow&(1<<7) != 0 { // 7 is Sunday too |
| 56 | c.dow = (c.dow &^ (1 << 7)) | 1 |
| 57 | } |
| 58 | return c, nil |
| 59 | } |
| 60 | |
| 61 | // parseField returns the bitset and whether the field was a bare "*". |
| 62 | func parseField(f string, lo, hi int, name string) (uint64, bool, error) { |
| 63 | var bits uint64 |
| 64 | star := f == "*" |
| 65 | for _, part := range strings.Split(f, ",") { |
| 66 | rangePart, stepPart, hasStep := strings.Cut(part, "/") |
| 67 | step := 1 |
| 68 | if hasStep { |
| 69 | n, err := strconv.Atoi(stepPart) |
| 70 | if err != nil || n <= 0 { |
| 71 | return 0, false, fmt.Errorf("cron %s: bad step %q", name, part) |
| 72 | } |
| 73 | step = n |
| 74 | } |
| 75 | a, b := lo, hi |
| 76 | switch { |
| 77 | case rangePart == "*": |
| 78 | case strings.Contains(rangePart, "-"): |
| 79 | as, bs, _ := strings.Cut(rangePart, "-") |
| 80 | var err error |
| 81 | if a, err = atoiIn(as, lo, hi); err != nil { |
| 82 | return 0, false, fmt.Errorf("cron %s: %w", name, err) |
| 83 | } |
| 84 | if b, err = atoiIn(bs, lo, hi); err != nil { |
| 85 | return 0, false, fmt.Errorf("cron %s: %w", name, err) |
| 86 | } |
| 87 | if a > b { |
| 88 | return 0, false, fmt.Errorf("cron %s: range %q runs backwards", name, rangePart) |
| 89 | } |
| 90 | default: |
| 91 | n, err := atoiIn(rangePart, lo, hi) |
| 92 | if err != nil { |
| 93 | return 0, false, fmt.Errorf("cron %s: %w", name, err) |
| 94 | } |
| 95 | a, b = n, n |
| 96 | if hasStep { |
| 97 | b = hi // "5/10" means from 5 to the end, stepping |
| 98 | } |
| 99 | } |
| 100 | for v := a; v <= b; v += step { |
| 101 | bits |= 1 << uint(v) |
| 102 | } |
| 103 | } |
| 104 | if bits == 0 { |
| 105 | return 0, false, errors.New("cron " + name + ": empty") |
| 106 | } |
| 107 | return bits, star, nil |
| 108 | } |
| 109 | |
| 110 | func atoiIn(s string, lo, hi int) (int, error) { |
| 111 | n, err := strconv.Atoi(s) |
| 112 | if err != nil { |
| 113 | return 0, fmt.Errorf("%q is not a number", s) |
| 114 | } |
| 115 | if n < lo || n > hi { |
| 116 | return 0, fmt.Errorf("%d is outside %d-%d", n, lo, hi) |
| 117 | } |
| 118 | return n, nil |
| 119 | } |
| 120 | |
| 121 | // Next is the first matching minute strictly after `after`, in UTC, or the |
| 122 | // zero time if none falls within the search window (a "0 0 30 2 *" never |
| 123 | // matches, and terminates quickly since each mismatched day is a single |
| 124 | // jump, not a minute-by-minute walk). The window is 5 years, not 366 days: |
| 125 | // "0 0 29 2 *" (Feb 29) can be up to ~4 years out depending on where `after` |
| 126 | // falls in the leap-year cycle, and a 366-day window would wrongly report |
| 127 | // "never" for it. |
| 128 | func (c *CronExpr) Next(after time.Time) time.Time { |
| 129 | t := after.UTC().Truncate(time.Minute).Add(time.Minute) |
| 130 | limit := t.AddDate(5, 0, 0) |
| 131 | for t.Before(limit) { |
| 132 | if c.month&(1<<uint(t.Month())) == 0 { |
| 133 | // Skip to the first of next month. |
| 134 | t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC) |
| 135 | continue |
| 136 | } |
| 137 | if !c.dayMatches(t) { |
| 138 | t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC) |
| 139 | continue |
| 140 | } |
| 141 | if c.hour&(1<<uint(t.Hour())) == 0 { |
| 142 | t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, time.UTC) |
| 143 | continue |
| 144 | } |
| 145 | if c.min&(1<<uint(t.Minute())) == 0 { |
| 146 | t = t.Add(time.Minute) |
| 147 | continue |
| 148 | } |
| 149 | return t |
| 150 | } |
| 151 | return time.Time{} |
| 152 | } |
| 153 | |
| 154 | // dayMatches applies Vixie cron's rule: when BOTH day fields are restricted |
| 155 | // the day matches if EITHER does; when one is "*" the other decides. |
| 156 | func (c *CronExpr) dayMatches(t time.Time) bool { |
| 157 | dom := c.dom&(1<<uint(t.Day())) != 0 |
| 158 | dow := c.dow&(1<<uint(t.Weekday())) != 0 |
| 159 | switch { |
| 160 | case c.domStar && c.dowStar: |
| 161 | return true |
| 162 | case c.domStar: |
| 163 | return dow |
| 164 | case c.dowStar: |
| 165 | return dom |
| 166 | default: |
| 167 | return dom || dow |
| 168 | } |
| 169 | } |
| 170 | |