hron 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/hron/cron.rb CHANGED
@@ -75,12 +75,19 @@ module Hron
75
75
  "#{time.minute} #{time.hour} #{dom} * *"
76
76
  when LastDayTarget
77
77
  raise HronError.cron("not expressible as cron (last day of month not supported)")
78
- else
78
+ when LastWeekdayTarget
79
79
  raise HronError.cron("not expressible as cron (last weekday of month not supported)")
80
- end
80
+ when NearestWeekdayTarget
81
+ if expr.target.direction
82
+ raise HronError.cron("not expressible as cron (directional nearest weekday not supported)")
83
+ end
81
84
 
82
- when OrdinalRepeat
83
- raise HronError.cron("not expressible as cron (ordinal weekday of month not supported)")
85
+ "#{time.minute} #{time.hour} #{expr.target.day}W * *"
86
+ when OrdinalWeekdayTarget
87
+ raise HronError.cron("not expressible as cron (ordinal weekday of month not supported)")
88
+ else
89
+ raise HronError.cron("not expressible as cron (unsupported month target)")
90
+ end
84
91
 
85
92
  when SingleDateExpr
86
93
  raise HronError.cron("not expressible as cron (single dates are not repeating)")
@@ -107,144 +114,634 @@ module Hron
107
114
  end
108
115
  end
109
116
 
117
+ DOW_NAME_MAP = {
118
+ "SUN" => 0, "MON" => 1, "TUE" => 2, "WED" => 3,
119
+ "THU" => 4, "FRI" => 5, "SAT" => 6
120
+ }.freeze
121
+
122
+ MONTH_NAME_MAP = {
123
+ "JAN" => 1, "FEB" => 2, "MAR" => 3, "APR" => 4,
124
+ "MAY" => 5, "JUN" => 6, "JUL" => 7, "AUG" => 8,
125
+ "SEP" => 9, "OCT" => 10, "NOV" => 11, "DEC" => 12
126
+ }.freeze
127
+
110
128
  def self.from_cron(cron_str)
111
- fields = cron_str.strip.split
129
+ cron_str = cron_str.strip
130
+
131
+ # Handle @ shortcuts first
132
+ return parse_cron_shortcut(cron_str) if cron_str.start_with?("@")
133
+
134
+ fields = cron_str.split
112
135
  raise HronError.cron("expected 5 cron fields, got #{fields.length}") if fields.length != 5
113
136
 
114
- minute_field, hour_field, dom_field, _month_field, dow_field = fields
137
+ minute_field, hour_field, dom_field, month_field, dow_field = fields
138
+
139
+ # Normalize ? to * (they're semantically equivalent for our purposes)
140
+ dom_field = "*" if dom_field == "?"
141
+ dow_field = "*" if dow_field == "?"
142
+
143
+ # Parse month field into during clause
144
+ during = parse_month_field(month_field)
145
+
146
+ # Check for special DOW patterns: nth weekday (#), last weekday (5L)
147
+ result = try_parse_nth_weekday(minute_field, hour_field, dom_field, dow_field, during)
148
+ return result if result
149
+
150
+ # Check for L (last day) or LW (last weekday) in DOM
151
+ result = try_parse_last_day(minute_field, hour_field, dom_field, dow_field, during)
152
+ return result if result
153
+
154
+ # Check for W (nearest weekday): e.g., 15W
155
+ if dom_field.end_with?("W") && dom_field != "LW"
156
+ result = try_parse_nearest_weekday(minute_field, hour_field, dom_field, dow_field, during)
157
+ return result if result
158
+ end
159
+
160
+ # Check for interval patterns: */N or range/N
161
+ result = try_parse_interval(minute_field, hour_field, dom_field, dow_field, during)
162
+ return result if result
163
+
164
+ # Standard time-based cron
165
+ minute = parse_single_value(minute_field, "minute", 0, 59)
166
+ hour = parse_single_value(hour_field, "hour", 0, 23)
167
+ time = TimeOfDay.new(hour, minute)
168
+
169
+ # DOM-based (monthly) - when DOM is specified and DOW is *
170
+ if dom_field != "*" && dow_field == "*"
171
+ target = parse_dom_field(dom_field)
172
+ return ScheduleData.new(
173
+ expr: MonthRepeat.new(1, target, [time]),
174
+ during: during
175
+ )
176
+ end
177
+
178
+ # DOW-based (day repeat)
179
+ days = parse_cron_dow(dow_field)
180
+ ScheduleData.new(
181
+ expr: DayRepeat.new(1, days, [time]),
182
+ during: during
183
+ )
184
+ end
185
+
186
+ # Parse @ shortcuts like @daily, @hourly, etc.
187
+ def self.parse_cron_shortcut(cron_str)
188
+ case cron_str.downcase
189
+ when "@yearly", "@annually"
190
+ ScheduleData.new(
191
+ expr: YearRepeat.new(
192
+ 1,
193
+ YearDateTarget.new(MonthName::JAN, 1),
194
+ [TimeOfDay.new(0, 0)]
195
+ )
196
+ )
197
+ when "@monthly"
198
+ ScheduleData.new(
199
+ expr: MonthRepeat.new(
200
+ 1,
201
+ DaysTarget.new([SingleDay.new(1)]),
202
+ [TimeOfDay.new(0, 0)]
203
+ )
204
+ )
205
+ when "@weekly"
206
+ ScheduleData.new(
207
+ expr: DayRepeat.new(
208
+ 1,
209
+ DayFilterDays.new([Weekday::SUNDAY]),
210
+ [TimeOfDay.new(0, 0)]
211
+ )
212
+ )
213
+ when "@daily", "@midnight"
214
+ ScheduleData.new(
215
+ expr: DayRepeat.new(
216
+ 1,
217
+ DayFilterEvery.new,
218
+ [TimeOfDay.new(0, 0)]
219
+ )
220
+ )
221
+ when "@hourly"
222
+ ScheduleData.new(
223
+ expr: IntervalRepeat.new(
224
+ 1,
225
+ IntervalUnit::HOURS,
226
+ TimeOfDay.new(0, 0),
227
+ TimeOfDay.new(23, 59),
228
+ nil
229
+ )
230
+ )
231
+ else
232
+ raise HronError.cron("unknown @ shortcut: #{cron_str}")
233
+ end
234
+ end
235
+
236
+ # Parse month field into a Vec<MonthName> for the `during` clause.
237
+ def self.parse_month_field(field)
238
+ return [] if field == "*"
239
+
240
+ months = []
241
+ field.split(",").each do |part|
242
+ # Check for step values FIRST (e.g., 1-12/3 or */3)
243
+ if part.include?("/")
244
+ range_part, step_str = part.split("/", 2)
245
+ if range_part == "*"
246
+ start_num = 1
247
+ end_num = 12
248
+ elsif range_part.include?("-")
249
+ s, e = range_part.split("-", 2)
250
+ start_num = MonthName.number(parse_month_value(s))
251
+ end_num = MonthName.number(parse_month_value(e))
252
+ else
253
+ raise HronError.cron("invalid month step expression: #{part}")
254
+ end
255
+
256
+ step = begin
257
+ Integer(step_str)
258
+ rescue
259
+ raise(HronError.cron("invalid month step value: #{step_str}"))
260
+ end
261
+ raise HronError.cron("step cannot be 0") if step == 0
262
+
263
+ n = start_num
264
+ while n <= end_num
265
+ months << month_from_number(n)
266
+ n += step
267
+ end
268
+ elsif part.include?("-")
269
+ # Range like 1-3 or JAN-MAR
270
+ start_str, end_str = part.split("-", 2)
271
+ start_month = parse_month_value(start_str)
272
+ end_month = parse_month_value(end_str)
273
+ start_num = MonthName.number(start_month)
274
+ end_num = MonthName.number(end_month)
275
+ raise HronError.cron("invalid month range: #{start_str} > #{end_str}") if start_num > end_num
276
+
277
+ (start_num..end_num).each { |n| months << month_from_number(n) }
278
+ else
279
+ # Single month
280
+ months << parse_month_value(part)
281
+ end
282
+ end
283
+
284
+ months
285
+ end
286
+
287
+ # Parse a single month value (number 1-12 or name JAN-DEC).
288
+ def self.parse_month_value(s)
289
+ # Try as number first
290
+ if /^\d+$/.match?(s)
291
+ n = Integer(s)
292
+ return month_from_number(n)
293
+ end
294
+ # Try as name
295
+ n = MONTH_NAME_MAP[s.upcase]
296
+ raise HronError.cron("invalid month: #{s}") unless n
297
+
298
+ month_from_number(n)
299
+ end
300
+
301
+ def self.month_from_number(n)
302
+ month = MonthName.from_number(n)
303
+ raise HronError.cron("invalid month number: #{n}") unless month
115
304
 
116
- # Minute interval: */N
117
- if minute_field.start_with?("*/")
118
- interval_str = minute_field[2..]
119
- begin
120
- interval = Integer(interval_str)
121
- rescue ArgumentError
122
- raise HronError.cron("invalid minute interval")
305
+ month
306
+ end
307
+
308
+ # Try to parse nth weekday patterns like 1#1 (first Monday) or 5L (last Friday).
309
+ def self.try_parse_nth_weekday(minute_field, hour_field, dom_field, dow_field, during)
310
+ # Check for # pattern (nth weekday of month)
311
+ if dow_field.include?("#")
312
+ dow_str, nth_str = dow_field.split("#", 2)
313
+ dow_num = parse_dow_value(dow_str)
314
+ weekday = cron_dow_to_weekday(dow_num)
315
+ nth = begin
316
+ Integer(nth_str)
317
+ rescue
318
+ raise(HronError.cron("invalid nth value: #{nth_str}"))
319
+ end
320
+ raise HronError.cron("nth must be 1-5, got #{nth}") if nth < 1 || nth > 5
321
+
322
+ ordinal = case nth
323
+ when 1 then OrdinalPosition::FIRST
324
+ when 2 then OrdinalPosition::SECOND
325
+ when 3 then OrdinalPosition::THIRD
326
+ when 4 then OrdinalPosition::FOURTH
327
+ when 5 then OrdinalPosition::FIFTH
123
328
  end
124
329
 
125
- from_hour = 0
126
- to_hour = 23
330
+ raise HronError.cron("DOM must be * when using # for nth weekday") if dom_field != "*" && dom_field != "?"
331
+
332
+ minute = parse_single_value(minute_field, "minute", 0, 59)
333
+ hour = parse_single_value(hour_field, "hour", 0, 23)
334
+
335
+ return ScheduleData.new(
336
+ expr: MonthRepeat.new(1, OrdinalWeekdayTarget.new(ordinal, weekday), [TimeOfDay.new(hour, minute)]),
337
+ during: during
338
+ )
339
+ end
340
+
341
+ # Check for nL pattern (last weekday of month, e.g., 5L = last Friday)
342
+ if dow_field.end_with?("L") && dow_field.length > 1
343
+ dow_str = dow_field[0..-2]
344
+ dow_num = parse_dow_value(dow_str)
345
+ weekday = cron_dow_to_weekday(dow_num)
346
+
347
+ raise HronError.cron("DOM must be * when using nL for last weekday") if dom_field != "*" && dom_field != "?"
348
+
349
+ minute = parse_single_value(minute_field, "minute", 0, 59)
350
+ hour = parse_single_value(hour_field, "hour", 0, 23)
351
+
352
+ return ScheduleData.new(
353
+ expr: MonthRepeat.new(1, OrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday), [TimeOfDay.new(hour, minute)]),
354
+ during: during
355
+ )
356
+ end
357
+
358
+ nil
359
+ end
127
360
 
361
+ # Try to parse L (last day) or LW (last weekday) patterns.
362
+ def self.try_parse_last_day(minute_field, hour_field, dom_field, dow_field, during)
363
+ return nil unless dom_field == "L" || dom_field == "LW"
364
+
365
+ raise HronError.cron("DOW must be * when using L or LW in DOM") if dow_field != "*" && dow_field != "?"
366
+
367
+ minute = parse_single_value(minute_field, "minute", 0, 59)
368
+ hour = parse_single_value(hour_field, "hour", 0, 23)
369
+
370
+ target = (dom_field == "LW") ? LastWeekdayTarget.new : LastDayTarget.new
371
+
372
+ ScheduleData.new(
373
+ expr: MonthRepeat.new(1, target, [TimeOfDay.new(hour, minute)]),
374
+ during: during
375
+ )
376
+ end
377
+
378
+ # Try to parse W (nearest weekday) patterns: 15W, 1W, etc.
379
+ def self.try_parse_nearest_weekday(minute_field, hour_field, dom_field, dow_field, during)
380
+ return nil unless dom_field.end_with?("W") && dom_field != "LW"
381
+
382
+ raise HronError.cron("DOW must be * when using W in DOM") if dow_field != "*" && dow_field != "?"
383
+
384
+ day_str = dom_field[0..-2]
385
+ day = begin
386
+ Integer(day_str)
387
+ rescue
388
+ raise(HronError.cron("invalid W day: #{day_str}"))
389
+ end
390
+ raise HronError.cron("W day must be 1-31, got #{day}") if day < 1 || day > 31
391
+
392
+ minute = parse_single_value(minute_field, "minute", 0, 59)
393
+ hour = parse_single_value(hour_field, "hour", 0, 23)
394
+
395
+ target = NearestWeekdayTarget.new(day, nil)
396
+
397
+ ScheduleData.new(
398
+ expr: MonthRepeat.new(1, target, [TimeOfDay.new(hour, minute)]),
399
+ during: during
400
+ )
401
+ end
402
+
403
+ # Try to parse interval patterns: */N, range/N in minute or hour fields.
404
+ def self.try_parse_interval(minute_field, hour_field, dom_field, dow_field, during)
405
+ # Minute interval: */N or range/N
406
+ if minute_field.include?("/")
407
+ range_part, step_str = minute_field.split("/", 2)
408
+ interval = begin
409
+ Integer(step_str)
410
+ rescue
411
+ raise(HronError.cron("invalid minute interval value"))
412
+ end
413
+ raise HronError.cron("step cannot be 0") if interval == 0
414
+
415
+ if range_part == "*"
416
+ from_minute = 0
417
+ to_minute = 59
418
+ elsif range_part.include?("-")
419
+ s, e = range_part.split("-", 2)
420
+ from_minute = begin
421
+ Integer(s)
422
+ rescue
423
+ raise(HronError.cron("invalid minute range"))
424
+ end
425
+ to_minute = begin
426
+ Integer(e)
427
+ rescue
428
+ raise(HronError.cron("invalid minute range"))
429
+ end
430
+ raise HronError.cron("range start must be <= end: #{s}-#{e}") if from_minute > to_minute
431
+ else
432
+ # Single value with step (e.g., 0/15) - treat as starting point
433
+ from_minute = begin
434
+ Integer(range_part)
435
+ rescue
436
+ raise(HronError.cron("invalid minute value"))
437
+ end
438
+ to_minute = 59
439
+ end
440
+
441
+ # Determine the hour window
128
442
  if hour_field == "*"
129
- # full day
443
+ from_hour = 0
444
+ to_hour = 23
130
445
  elsif hour_field.include?("-")
131
- parts = hour_field.split("-")
132
- begin
133
- from_hour = Integer(parts[0])
134
- to_hour = Integer(parts[1])
135
- rescue ArgumentError, IndexError
136
- raise HronError.cron("invalid hour range")
446
+ s, e = hour_field.split("-", 2)
447
+ from_hour = begin
448
+ Integer(s)
449
+ rescue
450
+ raise(HronError.cron("invalid hour range"))
451
+ end
452
+ to_hour = begin
453
+ Integer(e)
454
+ rescue
455
+ raise(HronError.cron("invalid hour range"))
137
456
  end
457
+ elsif hour_field.include?("/")
458
+ # Hour also has step - this is complex, handle as hour interval
459
+ return nil
138
460
  else
139
- begin
140
- h = Integer(hour_field)
141
- rescue ArgumentError
142
- raise HronError.cron("invalid hour")
461
+ h = begin
462
+ Integer(hour_field)
463
+ rescue
464
+ raise(HronError.cron("invalid hour"))
143
465
  end
144
466
  from_hour = h
145
467
  to_hour = h
146
468
  end
147
469
 
470
+ # Check if this should be a day filter
148
471
  day_filter = (dow_field == "*") ? nil : parse_cron_dow(dow_field)
149
472
 
150
- if dom_field == "*"
473
+ if dom_field == "*" || dom_field == "?"
474
+ # Determine the end minute based on context
475
+ end_minute = if from_minute == 0 && to_minute == 59 && to_hour == 23
476
+ # Full day: 00:00 to 23:59
477
+ 59
478
+ elsif from_minute == 0 && to_minute == 59
479
+ # Partial day with full minutes range: use :00 for cleaner output
480
+ 0
481
+ else
482
+ to_minute
483
+ end
484
+
151
485
  return ScheduleData.new(
152
486
  expr: IntervalRepeat.new(
153
487
  interval,
154
488
  IntervalUnit::MIN,
155
- TimeOfDay.new(from_hour, 0),
156
- TimeOfDay.new(to_hour, (to_hour == 23) ? 59 : 0),
489
+ TimeOfDay.new(from_hour, from_minute),
490
+ TimeOfDay.new(to_hour, end_minute),
157
491
  day_filter
158
- )
492
+ ),
493
+ during: during
159
494
  )
160
495
  end
161
496
  end
162
497
 
163
- # Hour interval: 0 */N
164
- if hour_field.start_with?("*/") && minute_field == "0"
165
- interval_str = hour_field[2..]
166
- begin
167
- interval = Integer(interval_str)
168
- rescue ArgumentError
169
- raise HronError.cron("invalid hour interval")
498
+ # Hour interval: 0 */N or 0 range/N
499
+ if hour_field.include?("/") && (minute_field == "0" || minute_field == "00")
500
+ range_part, step_str = hour_field.split("/", 2)
501
+ interval = begin
502
+ Integer(step_str)
503
+ rescue
504
+ raise(HronError.cron("invalid hour interval value"))
170
505
  end
171
- if dom_field == "*" && dow_field == "*"
506
+ raise HronError.cron("step cannot be 0") if interval == 0
507
+
508
+ if range_part == "*"
509
+ from_hour = 0
510
+ to_hour = 23
511
+ elsif range_part.include?("-")
512
+ s, e = range_part.split("-", 2)
513
+ from_hour = begin
514
+ Integer(s)
515
+ rescue
516
+ raise(HronError.cron("invalid hour range"))
517
+ end
518
+ to_hour = begin
519
+ Integer(e)
520
+ rescue
521
+ raise(HronError.cron("invalid hour range"))
522
+ end
523
+ raise HronError.cron("range start must be <= end: #{s}-#{e}") if from_hour > to_hour
524
+ else
525
+ from_hour = begin
526
+ Integer(range_part)
527
+ rescue
528
+ raise(HronError.cron("invalid hour value"))
529
+ end
530
+ to_hour = 23
531
+ end
532
+
533
+ if (dom_field == "*" || dom_field == "?") && (dow_field == "*" || dow_field == "?")
534
+ # Use :59 only for full day (00:00 to 23:59), otherwise use :00
535
+ end_minute = (from_hour == 0 && to_hour == 23) ? 59 : 0
536
+
172
537
  return ScheduleData.new(
173
538
  expr: IntervalRepeat.new(
174
539
  interval,
175
540
  IntervalUnit::HOURS,
176
- TimeOfDay.new(0, 0),
177
- TimeOfDay.new(23, 59),
541
+ TimeOfDay.new(from_hour, 0),
542
+ TimeOfDay.new(to_hour, end_minute),
178
543
  nil
179
- )
544
+ ),
545
+ during: during
180
546
  )
181
547
  end
182
548
  end
183
549
 
184
- # Standard time-based cron
185
- begin
186
- minute = Integer(minute_field)
187
- rescue ArgumentError
188
- raise HronError.cron("invalid minute field: #{minute_field}")
189
- end
190
- begin
191
- hour = Integer(hour_field)
192
- rescue ArgumentError
193
- raise HronError.cron("invalid hour field: #{hour_field}")
194
- end
195
- t = TimeOfDay.new(hour, minute)
550
+ nil
551
+ end
196
552
 
197
- # DOM-based (monthly)
198
- if dom_field != "*" && dow_field == "*"
199
- raise HronError.cron("DOM ranges not supported: #{dom_field}") if dom_field.include?("-")
553
+ # Parse a DOM field into a MonthTarget.
554
+ def self.parse_dom_field(field)
555
+ specs = []
556
+
557
+ field.split(",").each do |part|
558
+ if part.include?("/")
559
+ # Step value: 1-31/2 or */5
560
+ range_part, step_str = part.split("/", 2)
561
+ if range_part == "*"
562
+ start_day = 1
563
+ end_day = 31
564
+ elsif range_part.include?("-")
565
+ s, e = range_part.split("-", 2)
566
+ start_day = begin
567
+ Integer(s)
568
+ rescue
569
+ raise(HronError.cron("invalid DOM range start: #{s}"))
570
+ end
571
+ end_day = begin
572
+ Integer(e)
573
+ rescue
574
+ raise(HronError.cron("invalid DOM range end: #{e}"))
575
+ end
576
+ raise HronError.cron("range start must be <= end: #{start_day}-#{end_day}") if start_day > end_day
577
+ else
578
+ start_day = begin
579
+ Integer(range_part)
580
+ rescue
581
+ raise(HronError.cron("invalid DOM value: #{range_part}"))
582
+ end
583
+ end_day = 31
584
+ end
585
+
586
+ step = begin
587
+ Integer(step_str)
588
+ rescue
589
+ raise(HronError.cron("invalid DOM step: #{step_str}"))
590
+ end
591
+ raise HronError.cron("step cannot be 0") if step == 0
592
+
593
+ validate_dom(start_day)
594
+ validate_dom(end_day)
200
595
 
201
- day_nums = []
202
- dom_field.split(",").each do |s|
203
- begin
204
- n = Integer(s)
205
- rescue ArgumentError
206
- raise HronError.cron("invalid DOM field: #{dom_field}")
596
+ d = start_day
597
+ while d <= end_day
598
+ specs << SingleDay.new(d)
599
+ d += step
600
+ end
601
+ elsif part.include?("-")
602
+ # Range: 1-5
603
+ start_str, end_str = part.split("-", 2)
604
+ start_day = begin
605
+ Integer(start_str)
606
+ rescue
607
+ raise(HronError.cron("invalid DOM range start: #{start_str}"))
207
608
  end
208
- day_nums << n
609
+ end_day = begin
610
+ Integer(end_str)
611
+ rescue
612
+ raise(HronError.cron("invalid DOM range end: #{end_str}"))
613
+ end
614
+ raise HronError.cron("range start must be <= end: #{start_day}-#{end_day}") if start_day > end_day
615
+ validate_dom(start_day)
616
+ validate_dom(end_day)
617
+ specs << DayRange.new(start_day, end_day)
618
+ else
619
+ # Single: 15
620
+ day = begin
621
+ Integer(part)
622
+ rescue
623
+ raise(HronError.cron("invalid DOM value: #{part}"))
624
+ end
625
+ validate_dom(day)
626
+ specs << SingleDay.new(day)
209
627
  end
210
- specs = day_nums.map { |d| SingleDay.new(d) }
211
- return ScheduleData.new(
212
- expr: MonthRepeat.new(1, DaysTarget.new(specs), [t])
213
- )
214
628
  end
215
629
 
216
- # DOW-based (day repeat)
217
- days = parse_cron_dow(dow_field)
218
- expr = DayRepeat.new(1, days, [t])
219
- ScheduleData.new(expr: expr)
630
+ DaysTarget.new(specs)
220
631
  end
221
632
 
633
+ def self.validate_dom(day)
634
+ raise HronError.cron("DOM must be 1-31, got #{day}") if day < 1 || day > 31
635
+ end
636
+
637
+ # Parse a DOW field into a DayFilter.
222
638
  def self.parse_cron_dow(field)
223
639
  return DayFilterEvery.new if field == "*"
224
- return DayFilterWeekday.new if field == "1-5"
225
- return DayFilterWeekend.new if ["0,6", "6,0"].include?(field)
226
640
 
227
- raise HronError.cron("DOW ranges not supported: #{field}") if field.include?("-")
641
+ days = []
642
+
643
+ field.split(",").each do |part|
644
+ if part.include?("/")
645
+ # Step value: 0-6/2 or */2
646
+ range_part, step_str = part.split("/", 2)
647
+ if range_part == "*"
648
+ start_dow = 0
649
+ end_dow = 6
650
+ elsif range_part.include?("-")
651
+ s, e = range_part.split("-", 2)
652
+ start_dow = parse_dow_value_raw(s)
653
+ end_dow = parse_dow_value_raw(e)
654
+ raise HronError.cron("range start must be <= end: #{s}-#{e}") if start_dow > end_dow
655
+ else
656
+ start_dow = parse_dow_value_raw(range_part)
657
+ end_dow = 6
658
+ end
228
659
 
229
- nums = []
230
- field.split(",").each do |s|
231
- begin
232
- n = Integer(s)
233
- rescue ArgumentError
234
- raise HronError.cron("invalid DOW field: #{field}")
660
+ step = begin
661
+ Integer(step_str)
662
+ rescue
663
+ raise(HronError.cron("invalid DOW step: #{step_str}"))
664
+ end
665
+ raise HronError.cron("step cannot be 0") if step == 0
666
+
667
+ d = start_dow
668
+ while d <= end_dow
669
+ days << cron_dow_to_weekday(d)
670
+ d += step
671
+ end
672
+ elsif part.include?("-")
673
+ # Range: 1-5 or MON-FRI
674
+ # Parse without normalizing 7 to 0 for range purposes
675
+ start_str, end_str = part.split("-", 2)
676
+ start_dow = parse_dow_value_raw(start_str)
677
+ end_dow = parse_dow_value_raw(end_str)
678
+ raise HronError.cron("range start must be <= end: #{start_str}-#{end_str}") if start_dow > end_dow
679
+
680
+ (start_dow..end_dow).each do |d|
681
+ # Normalize 7 to 0 (Sunday) when converting to weekday
682
+ normalized = (d == 7) ? 0 : d
683
+ days << cron_dow_to_weekday(normalized)
684
+ end
685
+ else
686
+ # Single: 1 or MON
687
+ dow = parse_dow_value(part)
688
+ days << cron_dow_to_weekday(dow)
235
689
  end
236
- nums << n
237
690
  end
238
691
 
239
- days = nums.map { |n| cron_dow_to_weekday(n) }
692
+ # Check for special patterns
693
+ if days.length == 5
694
+ sorted = days.sort_by { |d| Weekday.number(d) }
695
+ return DayFilterWeekday.new if sorted == Weekday::WEEKDAYS
696
+ end
697
+ if days.length == 2
698
+ sorted = days.sort_by { |d| Weekday.number(d) }
699
+ return DayFilterWeekend.new if sorted == Weekday::WEEKEND
700
+ end
701
+
240
702
  DayFilterDays.new(days)
241
703
  end
242
704
 
705
+ # Parse a DOW value (number 0-7 or name SUN-SAT), normalizing 7 to 0.
706
+ def self.parse_dow_value(s)
707
+ raw = parse_dow_value_raw(s)
708
+ # Normalize 7 to 0 (both mean Sunday)
709
+ (raw == 7) ? 0 : raw
710
+ end
711
+
712
+ # Parse a DOW value without normalizing 7 to 0 (for range checking).
713
+ def self.parse_dow_value_raw(s)
714
+ # Try as number first
715
+ if /^\d+$/.match?(s)
716
+ n = Integer(s)
717
+ raise HronError.cron("DOW must be 0-7, got #{n}") if n > 7
718
+
719
+ return n
720
+ end
721
+ # Try as name
722
+ n = DOW_NAME_MAP[s.upcase]
723
+ raise HronError.cron("invalid DOW: #{s}") unless n
724
+
725
+ n
726
+ end
727
+
243
728
  def self.cron_dow_to_weekday(n)
244
729
  result = CRON_DOW_MAP[n]
245
730
  raise HronError.cron("invalid DOW number: #{n}") unless result
246
731
 
247
732
  result
248
733
  end
734
+
735
+ # Parse a single numeric value with validation.
736
+ def self.parse_single_value(field, name, min, max)
737
+ value = begin
738
+ Integer(field)
739
+ rescue
740
+ raise(HronError.cron("invalid #{name} field: #{field}"))
741
+ end
742
+ raise HronError.cron("#{name} must be #{min}-#{max}, got #{value}") if value < min || value > max
743
+
744
+ value
745
+ end
249
746
  end
250
747
  end