Skip to content

Commit c39ef2a

Browse files
committed
add support for overnight ranges
1 parent a773c6b commit c39ef2a

File tree

2 files changed

+208
-22
lines changed

2 files changed

+208
-22
lines changed

rule.go

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ type Rule struct {
1818

1919
// TimeRange represents a time period within a day
2020
type TimeRange struct {
21-
start time.Duration // minutes since midnight
22-
end time.Duration
23-
all bool
21+
start time.Duration // minutes since midnight
22+
end time.Duration
23+
all bool
24+
overnight bool // true if range spans across midnight
25+
hasSeconds bool // track if the original format included seconds
2426
}
2527

2628
// Field represents a cronrange field that can contain multiple values
@@ -65,61 +67,73 @@ func parseRule(rule string) (Rule, error) {
6567
}
6668

6769
// parseTimeRange parses a time range string in the following formats: HH:MM-HH:MM, HH:MM:SS-HH:MM:SS
68-
// or a single asterisk for all day
70+
// or a single asterisk for all day. Handles ranges that span across midnight.
6971
func parseTimeRange(s string) (TimeRange, error) {
7072
if s == "*" {
7173
return TimeRange{all: true}, nil
7274
}
7375

7476
parts := strings.Split(s, "-")
7577
if len(parts) != 2 {
76-
// this is not a valid time range
7778
return TimeRange{}, fmt.Errorf("invalid time range format")
7879
}
7980

80-
start, err := parseTime(parts[0])
81+
start, hasStartSeconds, err := parseTime(parts[0])
8182
if err != nil {
8283
return TimeRange{}, err
8384
}
8485

85-
end, err := parseTime(parts[1])
86+
end, hasEndSeconds, err := parseTime(parts[1])
8687
if err != nil {
8788
return TimeRange{}, err
8889
}
8990

90-
return TimeRange{start: start, end: end}, nil
91+
// Check if this is an overnight range
92+
overnight := false
93+
if end < start {
94+
overnight = true
95+
}
96+
97+
return TimeRange{
98+
start: start,
99+
end: end,
100+
overnight: overnight,
101+
hasSeconds: hasStartSeconds || hasEndSeconds,
102+
}, nil
91103
}
92104

93-
// parseTime parses a time string in the following formats: HH:MM, HH-MM-SS
94-
func parseTime(s string) (time.Duration, error) {
105+
// parseTime parses a time string in the following formats: HH:MM, HH:MM:SS
106+
// Returns the duration, whether seconds were specified, and any error
107+
func parseTime(s string) (time.Duration, bool, error) {
95108
parts := strings.Split(s, ":")
96109
if len(parts) < 2 || len(parts) > 3 {
97-
return 0, fmt.Errorf("invalid time format")
110+
return 0, false, fmt.Errorf("invalid time format")
98111
}
99112

100113
hours, err := strconv.Atoi(parts[0])
101114
if err != nil {
102-
return 0, err
115+
return 0, false, err
103116
}
104117

105118
minutes, err := strconv.Atoi(parts[1])
106119
if err != nil {
107-
return 0, err
120+
return 0, false, err
108121
}
109122

110123
seconds := 0
111-
if len(parts) == 3 {
124+
hasSeconds := len(parts) == 3
125+
if hasSeconds {
112126
seconds, err = strconv.Atoi(parts[2])
113127
if err != nil {
114-
return 0, err
128+
return 0, false, err
115129
}
116130
}
117131

118132
if hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59 {
119-
return 0, fmt.Errorf("invalid time values")
133+
return 0, false, fmt.Errorf("invalid time values")
120134
}
121135

122-
return time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second, nil
136+
return time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second, hasSeconds, nil
123137
}
124138

125139
// parseField parses a field string in the following formats: 1,2,3, 1-3,5-6 or a single asterisk for all values.
@@ -176,7 +190,8 @@ func parseField(s string, min, max int) (Field, error) {
176190
return Field{values: values}, nil
177191
}
178192

179-
// matches checks if the rule matches the given time
193+
// matches checks if the current time falls within the time range,
194+
// handling ranges that span across midnight
180195
func (r Rule) matches(t time.Time) bool {
181196
if !r.month.matches(int(t.Month())) {
182197
return false
@@ -194,8 +209,20 @@ func (r Rule) matches(t time.Time) bool {
194209
return true
195210
}
196211

197-
currentMinutes := time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute
198-
return currentMinutes >= r.timeRange.start && currentMinutes <= r.timeRange.end
212+
currentTime := time.Duration(t.Hour())*time.Hour +
213+
time.Duration(t.Minute())*time.Minute +
214+
time.Duration(t.Second())*time.Second
215+
216+
if r.timeRange.overnight {
217+
// For overnight ranges (e.g. 23:00-02:00)
218+
// The time matches if it's:
219+
// - After or equal to start time (e.g. >= 23:00) OR
220+
// - Before or equal to end time (e.g. <= 02:00)
221+
return currentTime >= r.timeRange.start || currentTime <= r.timeRange.end
222+
}
223+
224+
// For same-day ranges, time must be between start and end
225+
return currentTime >= r.timeRange.start && currentTime <= r.timeRange.end
199226
}
200227

201228
func (f Field) matches(val int) bool {
@@ -217,15 +244,17 @@ func (tr TimeRange) String() string {
217244
if tr.all {
218245
return "*"
219246
}
247+
220248
startH := tr.start / time.Hour
221249
startM := (tr.start % time.Hour) / time.Minute
222250
startS := (tr.start % time.Minute) / time.Second
223251
endH := tr.end / time.Hour
224252
endM := (tr.end % time.Hour) / time.Minute
225253
endS := (tr.end % time.Minute) / time.Second
226254

227-
if startS > 0 || endS > 0 {
228-
return fmt.Sprintf("%02d:%02d:%02d-%02d:%02d:%02d", startH, startM, startS, endH, endM, endS)
255+
if tr.hasSeconds {
256+
return fmt.Sprintf("%02d:%02d:%02d-%02d:%02d:%02d",
257+
startH, startM, startS, endH, endM, endS)
229258
}
230259
return fmt.Sprintf("%02d:%02d-%02d:%02d", startH, startM, endH, endM)
231260
}

rule_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,160 @@ func TestTimeRangeString(t *testing.T) {
187187
})
188188
}
189189
}
190+
191+
func TestOvernightTimeRange(t *testing.T) {
192+
cases := []struct {
193+
name string
194+
input string
195+
want string
196+
}{
197+
{"overnight range", "23:00-02:00", "23:00-02:00"},
198+
{"overnight range with seconds", "22:30:00-04:15:00", "22:30:00-04:15:00"},
199+
{"regular range", "09:00-17:00", "09:00-17:00"},
200+
}
201+
202+
for _, c := range cases {
203+
t.Run(c.name, func(t *testing.T) {
204+
timeRange, err := parseTimeRange(c.input)
205+
if err != nil {
206+
t.Errorf("unexpected error: %v", err)
207+
return
208+
}
209+
210+
if got := timeRange.String(); got != c.want {
211+
t.Errorf("got %q, want %q", got, c.want)
212+
}
213+
214+
if c.input == "23:00-02:00" {
215+
if !timeRange.overnight {
216+
t.Error("overnight should be true for 23:00-02:00")
217+
}
218+
219+
rule := Rule{
220+
timeRange: timeRange,
221+
dow: Field{all: true},
222+
dom: Field{all: true},
223+
month: Field{all: true},
224+
}
225+
226+
baseTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
227+
times := []struct {
228+
t time.Time
229+
name string
230+
want bool
231+
}{
232+
{baseTime.Add(-time.Hour), "23:00", true},
233+
{baseTime, "00:00", true},
234+
{baseTime.Add(time.Hour), "01:00", true},
235+
{baseTime.Add(2 * time.Hour), "02:00", true},
236+
{baseTime.Add(3 * time.Hour), "03:00", false},
237+
{baseTime.Add(-2 * time.Hour), "22:00", false},
238+
}
239+
240+
for _, tc := range times {
241+
if got := rule.matches(tc.t); got != tc.want {
242+
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
243+
}
244+
}
245+
}
246+
})
247+
}
248+
}
249+
250+
func TestMatches(t *testing.T) {
251+
cases := []struct {
252+
name string
253+
rule string
254+
times []time.Time
255+
wantMatch []bool
256+
}{
257+
{
258+
name: "simple range",
259+
rule: "09:00-17:00 * * *",
260+
times: []time.Time{
261+
time.Date(2024, 1, 1, 8, 59, 59, 0, time.UTC), // before range
262+
time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC), // start of range
263+
time.Date(2024, 1, 1, 13, 0, 0, 0, time.UTC), // middle
264+
time.Date(2024, 1, 1, 17, 0, 0, 0, time.UTC), // end of range
265+
time.Date(2024, 1, 1, 17, 0, 1, 0, time.UTC), // after range
266+
},
267+
wantMatch: []bool{false, true, true, true, false},
268+
},
269+
{
270+
name: "overnight range",
271+
rule: "23:00-02:00 * * *",
272+
times: []time.Time{
273+
time.Date(2024, 1, 1, 22, 59, 59, 0, time.UTC), // just before
274+
time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // start
275+
time.Date(2024, 1, 1, 23, 59, 59, 0, time.UTC), // before midnight
276+
time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), // midnight
277+
time.Date(2024, 1, 2, 1, 30, 0, 0, time.UTC), // middle
278+
time.Date(2024, 1, 2, 2, 0, 0, 0, time.UTC), // end
279+
time.Date(2024, 1, 2, 2, 0, 1, 0, time.UTC), // just after
280+
},
281+
wantMatch: []bool{false, true, true, true, true, true, false},
282+
},
283+
{
284+
name: "specific days",
285+
rule: "10:00-12:00 1,3,5 * *", // Mon,Wed,Fri
286+
times: []time.Time{
287+
time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC), // Monday
288+
time.Date(2024, 1, 2, 11, 0, 0, 0, time.UTC), // Tuesday
289+
time.Date(2024, 1, 3, 11, 0, 0, 0, time.UTC), // Wednesday
290+
},
291+
wantMatch: []bool{true, false, true},
292+
},
293+
{
294+
name: "specific months",
295+
rule: "* * * 3,6,9,12", // Mar,Jun,Sep,Dec
296+
times: []time.Time{
297+
time.Date(2024, 2, 1, 11, 0, 0, 0, time.UTC), // February
298+
time.Date(2024, 3, 1, 11, 0, 0, 0, time.UTC), // March
299+
time.Date(2024, 4, 1, 11, 0, 0, 0, time.UTC), // April
300+
},
301+
wantMatch: []bool{false, true, false},
302+
},
303+
{
304+
name: "complex range",
305+
rule: "09:00-17:00 1-5 1-7 3,6,9,12", // weekdays, first week, specific months
306+
times: []time.Time{
307+
time.Date(2024, 3, 1, 13, 0, 0, 0, time.UTC), // Fri, Mar 1
308+
time.Date(2024, 3, 2, 13, 0, 0, 0, time.UTC), // Sat, Mar 2
309+
time.Date(2024, 3, 4, 13, 0, 0, 0, time.UTC), // Mon, Mar 4
310+
time.Date(2024, 3, 8, 13, 0, 0, 0, time.UTC), // Fri, Mar 8
311+
time.Date(2024, 4, 1, 13, 0, 0, 0, time.UTC), // Mon, Apr 1
312+
},
313+
wantMatch: []bool{true, false, true, false, false},
314+
},
315+
{
316+
name: "all wildcards",
317+
rule: "* * * *",
318+
times: []time.Time{
319+
time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
320+
time.Date(2024, 6, 15, 12, 30, 0, 0, time.UTC),
321+
time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC),
322+
},
323+
wantMatch: []bool{true, true, true},
324+
},
325+
}
326+
327+
for _, c := range cases {
328+
t.Run(c.name, func(t *testing.T) {
329+
rule, err := parseRule(c.rule)
330+
if err != nil {
331+
t.Fatalf("failed to parse rule %q: %v", c.rule, err)
332+
}
333+
334+
if len(c.times) != len(c.wantMatch) {
335+
t.Fatal("times and wantMatch slices must have equal length")
336+
}
337+
338+
for i, tm := range c.times {
339+
got := rule.matches(tm)
340+
if got != c.wantMatch[i] {
341+
t.Errorf("time %v: got %v, want %v", tm.Format("2006-01-02 15:04:05"), got, c.wantMatch[i])
342+
}
343+
}
344+
})
345+
}
346+
}

0 commit comments

Comments
 (0)