carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
9package schedule
10
11import (
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).
22type 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
28func (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.
33func 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 "*".
62func 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
110func 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.
128func (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.
156func (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