aws-cron-parser 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +117 -0
- data/lib/aws_cron_parser/describer/cron_composer.rb +198 -0
- data/lib/aws_cron_parser/describer/day_composer.rb +74 -0
- data/lib/aws_cron_parser/describer/field_describers/base.rb +108 -0
- data/lib/aws_cron_parser/describer/field_describers/day_of_month.rb +88 -0
- data/lib/aws_cron_parser/describer/field_describers/day_of_week.rb +143 -0
- data/lib/aws_cron_parser/describer/field_describers/month.rb +92 -0
- data/lib/aws_cron_parser/describer/field_describers/time_field.rb +101 -0
- data/lib/aws_cron_parser/describer/month_composer.rb +29 -0
- data/lib/aws_cron_parser/describer/template.en.yml +183 -0
- data/lib/aws_cron_parser/describer/template.rb +87 -0
- data/lib/aws_cron_parser/describer/time_composer.rb +255 -0
- data/lib/aws_cron_parser/describer.rb +131 -0
- data/lib/aws_cron_parser/fugit_adapter.rb +64 -0
- data/lib/aws_cron_parser/helpers/calendar.rb +115 -0
- data/lib/aws_cron_parser/helpers/clock.rb +50 -0
- data/lib/aws_cron_parser/helpers/cron_expression.rb +277 -0
- data/lib/aws_cron_parser/helpers/numbers.rb +32 -0
- data/lib/aws_cron_parser/helpers.rb +17 -0
- data/lib/aws_cron_parser/parser.rb +152 -0
- data/lib/aws_cron_parser/parsers/aws_at.rb +32 -0
- data/lib/aws_cron_parser/parsers/aws_cron.rb +413 -0
- data/lib/aws_cron_parser/parsers/aws_rate.rb +33 -0
- data/lib/aws_cron_parser/parsers/base.rb +21 -0
- data/lib/aws_cron_parser/parsers/quartz_cron.rb +21 -0
- data/lib/aws_cron_parser/validator.rb +31 -0
- data/lib/aws_cron_parser/validators/aws_at_validator.rb +28 -0
- data/lib/aws_cron_parser/validators/aws_cron_validator.rb +212 -0
- data/lib/aws_cron_parser/validators/aws_macro_validator.rb +13 -0
- data/lib/aws_cron_parser/validators/aws_rate_validator.rb +17 -0
- data/lib/aws_cron_parser/validators/base.rb +23 -0
- data/lib/aws_cron_parser/validators/quartz_cron_validator.rb +132 -0
- data/lib/aws_cron_parser/version.rb +5 -0
- data/lib/aws_cron_parser.rb +48 -0
- metadata +127 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module AWSCronParser
|
|
6
|
+
module Parsers
|
|
7
|
+
class AWSCron < Base
|
|
8
|
+
def initialize(source, time_source = Time)
|
|
9
|
+
super(source, time_source)
|
|
10
|
+
|
|
11
|
+
@source = normalize_source(source)
|
|
12
|
+
|
|
13
|
+
# Detect special patterns
|
|
14
|
+
@has_nth_weekday = detect_pattern(@source, '#')
|
|
15
|
+
@has_last_weekday = detect_pattern(@source, 'L')
|
|
16
|
+
@has_last_day_of_month = detect_dom_pattern(@source, 'L')
|
|
17
|
+
@has_weekday_closest = detect_dom_pattern(@source, 'W')
|
|
18
|
+
@has_complex_step = detect_complex_step(@source)
|
|
19
|
+
@weekday_only = weekday_only?(@source)
|
|
20
|
+
@day_only = day_only?(@source)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def next(now = @time_source.now)
|
|
24
|
+
if @has_last_weekday
|
|
25
|
+
calculate_next_last_weekday(now)
|
|
26
|
+
elsif @has_nth_weekday
|
|
27
|
+
calculate_next_nth_weekday(now)
|
|
28
|
+
elsif @has_last_day_of_month || @has_weekday_closest || @day_only
|
|
29
|
+
calculate_next_day_only(now)
|
|
30
|
+
elsif @weekday_only
|
|
31
|
+
calculate_next_weekday_only(now)
|
|
32
|
+
elsif @has_complex_step || @source.split(/\s+/).length >= 5
|
|
33
|
+
calculate_next_standard(now)
|
|
34
|
+
else
|
|
35
|
+
raise NotImplementedError, 'Unexpected AWS CRON pattern'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Override method inherited from Helpers::CronExpression
|
|
42
|
+
def normalize_source(source)
|
|
43
|
+
@year_field = nil # Initialize year field
|
|
44
|
+
|
|
45
|
+
# Handle macro expressions
|
|
46
|
+
if (match = source.match(/^@(?<macro>\w+)$/i))
|
|
47
|
+
return expand_macro(match[:macro].downcase)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if source.start_with?('cron(') && source.end_with?(')')
|
|
51
|
+
inner = source[5...-1].strip
|
|
52
|
+
fields = inner.split(/\s+/)
|
|
53
|
+
# Store year field if present and not wildcard
|
|
54
|
+
@year_field = fields.length > 5 && fields[5] != '*' ? fields[5] : nil
|
|
55
|
+
# Remove trailing * (year field) if present
|
|
56
|
+
fields.pop if fields.last == '*'
|
|
57
|
+
fields.join(' ')
|
|
58
|
+
else
|
|
59
|
+
source
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Handles nth weekday specifications like "MON#2"
|
|
64
|
+
def nth_weekday_spec
|
|
65
|
+
tokens = @source.split(/\s+/)
|
|
66
|
+
minute, hour, _dom, month, dow = tokens.length == 5 ? tokens[0..4] : tokens[1..5]
|
|
67
|
+
|
|
68
|
+
# Normalize month field to handle abbreviations
|
|
69
|
+
normalized_month = normalize_month_field(month)
|
|
70
|
+
|
|
71
|
+
normalized_dow = normalize_weekday_in_nth_spec(dow, is_aws: true)
|
|
72
|
+
match = normalized_dow.match(NTH_WEEKDAY_REGEX)
|
|
73
|
+
|
|
74
|
+
weekday_num = match[:weekday].to_i
|
|
75
|
+
is_aws = @original_source.start_with?('cron(')
|
|
76
|
+
|
|
77
|
+
# Convert weekday number to Ruby format (0-6, where 0=Sunday)
|
|
78
|
+
weekday_ruby = if is_aws
|
|
79
|
+
# AWS CRON: 1=Sunday, 2=Monday, ..., 7=Saturday
|
|
80
|
+
weekday_num - 1
|
|
81
|
+
else
|
|
82
|
+
# Standard CRON: 0=Sunday, 1=Monday, ..., 6=Saturday, 7=Sunday
|
|
83
|
+
weekday_num == 7 ? 0 : weekday_num
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
minute: parse_simple_field(minute, 0..59),
|
|
88
|
+
hour: parse_simple_field(hour, 0..23),
|
|
89
|
+
month: parse_simple_field(normalized_month, 1..12),
|
|
90
|
+
weekday: weekday_ruby,
|
|
91
|
+
occurrence: match[:occurrence].to_i
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Handles last weekday specifications like "FRIL"
|
|
96
|
+
def last_weekday_spec
|
|
97
|
+
tokens = @source.split(/\s+/)
|
|
98
|
+
minute, hour, _dom, month, dow = tokens.length == 5 ? tokens[0..4] : tokens[1..5]
|
|
99
|
+
|
|
100
|
+
# Normalize month field to handle abbreviations
|
|
101
|
+
normalized_month = normalize_month_field(month)
|
|
102
|
+
|
|
103
|
+
normalized_dow = normalize_weekday_in_last_spec(dow)
|
|
104
|
+
match = normalized_dow.match(LAST_WEEKDAY_REGEX)
|
|
105
|
+
|
|
106
|
+
weekday_aws = match[:weekday].to_i
|
|
107
|
+
is_aws = @original_source.start_with?('cron(')
|
|
108
|
+
weekday_ruby = if is_aws
|
|
109
|
+
weekday_aws - 1
|
|
110
|
+
else
|
|
111
|
+
(weekday_aws == 7 ? 6 : weekday_aws)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
minute: parse_simple_field(minute, 0..59),
|
|
116
|
+
hour: parse_simple_field(hour, 0..23),
|
|
117
|
+
month: parse_simple_field(normalized_month, 1..12),
|
|
118
|
+
weekday: weekday_ruby
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Finds the day of month for the nth occurrence of a weekday in a month
|
|
123
|
+
def find_nth_weekday_in_month(year, month, target_weekday, occurrence)
|
|
124
|
+
date = Date.new(year, month, 1)
|
|
125
|
+
end_of_month = Date.new(year, month, -1)
|
|
126
|
+
count = 0
|
|
127
|
+
|
|
128
|
+
while date <= end_of_month
|
|
129
|
+
count += 1 if date.wday == target_weekday
|
|
130
|
+
return date.day if count == occurrence
|
|
131
|
+
|
|
132
|
+
date = date.next_day
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Finds the weekday closest to a target day in a given month
|
|
138
|
+
def find_weekday_closest_to(target_day, year, month)
|
|
139
|
+
target_date = Date.new(year, month, target_day)
|
|
140
|
+
target_wday = target_date.wday
|
|
141
|
+
|
|
142
|
+
if (1..5).cover?(target_wday)
|
|
143
|
+
return [target_day]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
closest_day = nil
|
|
147
|
+
min_distance = Float::INFINITY
|
|
148
|
+
|
|
149
|
+
(-3..3).each do |offset|
|
|
150
|
+
next if offset.zero?
|
|
151
|
+
|
|
152
|
+
candidate_day = target_day + offset
|
|
153
|
+
next if candidate_day < 1 || candidate_day > Date.new(year, month, -1).day
|
|
154
|
+
|
|
155
|
+
candidate_date = Date.new(year, month, candidate_day)
|
|
156
|
+
candidate_wday = candidate_date.wday
|
|
157
|
+
|
|
158
|
+
next unless (1..5).cover?(candidate_wday)
|
|
159
|
+
|
|
160
|
+
distance = offset.abs
|
|
161
|
+
if distance < min_distance
|
|
162
|
+
min_distance = distance
|
|
163
|
+
closest_day = candidate_day
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
closest_day ? [closest_day] : [target_day]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Finds the last occurrence of a weekday in a month
|
|
171
|
+
def find_last_weekday_in_month(year, month, target_weekday)
|
|
172
|
+
date = Date.new(year, month, -1)
|
|
173
|
+
while date.day >= 1
|
|
174
|
+
return date.day if date.wday == target_weekday
|
|
175
|
+
|
|
176
|
+
date = date.prev_day
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Calculates the next occurrence of the nth weekday
|
|
182
|
+
def calculate_next_nth_weekday(now)
|
|
183
|
+
spec = nth_weekday_spec
|
|
184
|
+
current_time = ensure_time(now)
|
|
185
|
+
search_date = current_time
|
|
186
|
+
|
|
187
|
+
120.times do
|
|
188
|
+
month = search_date.month
|
|
189
|
+
year = search_date.year
|
|
190
|
+
|
|
191
|
+
if spec[:month].include?(month)
|
|
192
|
+
nth_day = find_nth_weekday_in_month(year, month, spec[:weekday], spec[:occurrence])
|
|
193
|
+
if nth_day
|
|
194
|
+
result = try_times(year, month, nth_day, spec, current_time)
|
|
195
|
+
return result if result
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
search_date = next_month_start(search_date)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
raise 'No occurrence found within 120 months'
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Calculates the next occurrence of the last weekday
|
|
206
|
+
def calculate_next_last_weekday(now)
|
|
207
|
+
spec = last_weekday_spec
|
|
208
|
+
current_time = ensure_time(now)
|
|
209
|
+
search_date = current_time
|
|
210
|
+
|
|
211
|
+
120.times do
|
|
212
|
+
month = search_date.month
|
|
213
|
+
year = search_date.year
|
|
214
|
+
|
|
215
|
+
if spec[:month].include?(month)
|
|
216
|
+
last_day = find_last_weekday_in_month(year, month, spec[:weekday])
|
|
217
|
+
if last_day
|
|
218
|
+
result = try_times(year, month, last_day, spec, current_time)
|
|
219
|
+
return result if result
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
search_date = next_month_start(search_date)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
raise 'No occurrence found within 120 months'
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Calculates the next occurrence considering only weekdays
|
|
230
|
+
def calculate_next_weekday_only(now)
|
|
231
|
+
tokens = @source.split(/\s+/)
|
|
232
|
+
minute, hour, _dom, month, dow = tokens.length == 5 ? tokens[0..4] : tokens[1..5]
|
|
233
|
+
|
|
234
|
+
minutes = parse_simple_field(minute, 0..59)
|
|
235
|
+
hours = parse_simple_field(hour, 0..23)
|
|
236
|
+
months = parse_simple_field(month, 1..12)
|
|
237
|
+
weekdays = parse_dow_field(dow)
|
|
238
|
+
|
|
239
|
+
current_time = ensure_time(now)
|
|
240
|
+
search_date = current_time
|
|
241
|
+
|
|
242
|
+
365.times do
|
|
243
|
+
month_num = search_date.month
|
|
244
|
+
year = search_date.year
|
|
245
|
+
|
|
246
|
+
if months.include?(month_num)
|
|
247
|
+
last_day = Date.new(year, month_num, -1).day
|
|
248
|
+
start_day = search_date.month == month_num && search_date.year == year ? search_date.day : 1
|
|
249
|
+
(start_day..last_day).each do |day|
|
|
250
|
+
candidate_date = Date.new(year, month_num, day)
|
|
251
|
+
aws_wday = candidate_date.wday + 1
|
|
252
|
+
|
|
253
|
+
if weekdays.include?(aws_wday)
|
|
254
|
+
result = try_times(year, month_num, day, { minute: minutes, hour: hours }, current_time)
|
|
255
|
+
return result if result
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
search_date = next_month_start(search_date)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
raise 'No occurrence found within 365 days'
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Calculates the next occurrence of day-only specifications
|
|
267
|
+
def calculate_next_day_only(now)
|
|
268
|
+
tokens = @source.split(/\s+/)
|
|
269
|
+
minute, hour, dom, month, _dow = tokens.length == 5 ? tokens[0..4] : tokens[1..5]
|
|
270
|
+
|
|
271
|
+
minutes = parse_simple_field(minute, 0..59)
|
|
272
|
+
hours = parse_simple_field(hour, 0..23)
|
|
273
|
+
months = parse_simple_field(month, 1..12)
|
|
274
|
+
|
|
275
|
+
current_time = ensure_time(now)
|
|
276
|
+
search_date = current_time
|
|
277
|
+
|
|
278
|
+
365.times do
|
|
279
|
+
month_num = search_date.month
|
|
280
|
+
year = search_date.year
|
|
281
|
+
|
|
282
|
+
if months.include?(month_num)
|
|
283
|
+
last_day = Date.new(year, month_num, -1).day
|
|
284
|
+
days = if dom == 'L'
|
|
285
|
+
[last_day]
|
|
286
|
+
elsif dom.match?(/\d+W/)
|
|
287
|
+
day_num = dom.match(/(\d+)W/)[1].to_i
|
|
288
|
+
find_weekday_closest_to(day_num, year, month_num)
|
|
289
|
+
else
|
|
290
|
+
parse_simple_field(dom, 1..31)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
days.each do |day_num|
|
|
294
|
+
next if day_num > last_day
|
|
295
|
+
|
|
296
|
+
result = try_times(year, month_num, day_num, { minute: minutes, hour: hours }, current_time)
|
|
297
|
+
return result if result
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
search_date = next_month_start(search_date)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
raise 'No occurrence found within 365 days'
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Calculates the next occurrence using standard CRON parsing
|
|
308
|
+
def calculate_next_standard(now)
|
|
309
|
+
tokens = @source.split(/\s+/)
|
|
310
|
+
if tokens.length == 6
|
|
311
|
+
second, minute, hour, dom, month, dow = tokens
|
|
312
|
+
seconds = parse_simple_field(second, 0..59)
|
|
313
|
+
else
|
|
314
|
+
minute, hour, dom, month, dow = tokens[0..4]
|
|
315
|
+
seconds = [0]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
minutes = parse_simple_field(minute, 0..59)
|
|
319
|
+
hours = parse_simple_field(hour, 0..23)
|
|
320
|
+
days = parse_simple_field(dom, 1..31)
|
|
321
|
+
months = parse_simple_field(month, 1..12)
|
|
322
|
+
weekdays = parse_dow_field(dow)
|
|
323
|
+
|
|
324
|
+
current_time = ensure_time(now)
|
|
325
|
+
search_date = current_time
|
|
326
|
+
|
|
327
|
+
# If year field is specified, start from that year
|
|
328
|
+
if @year_field
|
|
329
|
+
target_year = @year_field.to_i
|
|
330
|
+
if current_time.year < target_year
|
|
331
|
+
# Jump to the target year start
|
|
332
|
+
search_date = Date.new(target_year, 1, 1).to_time(current_time.zone)
|
|
333
|
+
end
|
|
334
|
+
# For expressions with year, search 10 years ahead
|
|
335
|
+
search_limit = 3650
|
|
336
|
+
else
|
|
337
|
+
search_limit = 365
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
search_limit.times do
|
|
341
|
+
month_num = search_date.month
|
|
342
|
+
year = search_date.year
|
|
343
|
+
|
|
344
|
+
if months.include?(month_num)
|
|
345
|
+
last_day = Date.new(year, month_num, -1).day
|
|
346
|
+
start_day = search_date.month == month_num && search_date.year == year ? search_date.day : 1
|
|
347
|
+
|
|
348
|
+
(start_day..last_day).each do |day|
|
|
349
|
+
candidate_date = Date.new(year, month_num, day)
|
|
350
|
+
|
|
351
|
+
next unless days.include?(day)
|
|
352
|
+
|
|
353
|
+
if weekdays != (0..6).to_a
|
|
354
|
+
aws_wday = candidate_date.wday + 1
|
|
355
|
+
next unless weekdays.include?(aws_wday)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
result = try_times(year, month_num, day, { second: seconds, minute: minutes, hour: hours }, current_time)
|
|
359
|
+
return result if result
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
search_date = next_month_start(search_date)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
raise 'No occurrence found within search limit'
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Tries all time combinations for a given day
|
|
370
|
+
def try_times(year, month, day, spec, current_time)
|
|
371
|
+
seconds = spec[:second] ? Array(spec[:second]) : [0]
|
|
372
|
+
seconds.each do |second|
|
|
373
|
+
Array(spec[:hour]).each do |hour|
|
|
374
|
+
Array(spec[:minute]).each do |minute|
|
|
375
|
+
# Create candidate time, preserving the timezone of current_time
|
|
376
|
+
candidate = if current_time.utc?
|
|
377
|
+
# If current_time is UTC, create UTC time
|
|
378
|
+
::Time.utc(year, month, day, hour, minute, second)
|
|
379
|
+
elsif current_time.respond_to?(:zone) && Time.respond_to?(:zone)
|
|
380
|
+
::Time.zone.local(year, month, day, hour, minute, second)
|
|
381
|
+
else
|
|
382
|
+
@time_source.local(year, month, day, hour, minute, second)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Compare moments in time using UTC for consistency
|
|
386
|
+
return candidate if candidate.utc > current_time.utc
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Expansions use this parser's AWS day-of-week numbering (1=SUN..7=SAT),
|
|
394
|
+
# not the standard CRON one (0=SUN), so @weekly says SUN rather than 0.
|
|
395
|
+
def expand_macro(macro)
|
|
396
|
+
case macro
|
|
397
|
+
when 'yearly'
|
|
398
|
+
'0 0 1 1 *'
|
|
399
|
+
when 'monthly'
|
|
400
|
+
'0 0 1 * *'
|
|
401
|
+
when 'weekly'
|
|
402
|
+
'0 0 * * SUN'
|
|
403
|
+
when 'daily'
|
|
404
|
+
'0 0 * * *'
|
|
405
|
+
when 'hourly'
|
|
406
|
+
'0 * * * *'
|
|
407
|
+
else
|
|
408
|
+
raise ArgumentError, "Unknown macro: @#{macro}"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Parsers
|
|
5
|
+
class AWSRate < Base
|
|
6
|
+
def initialize(source, time_source = Time)
|
|
7
|
+
super(source, time_source)
|
|
8
|
+
|
|
9
|
+
match = source.match(RATE_REGEX)
|
|
10
|
+
raise ArgumentError, "Invalid rate format: #{source}" unless match
|
|
11
|
+
|
|
12
|
+
value = match[1].to_i
|
|
13
|
+
unit = match[2].downcase
|
|
14
|
+
unit = unit.chomp('s') if unit.end_with?('s')
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "Rate value must be positive: #{value}" if value <= 0
|
|
17
|
+
|
|
18
|
+
@rate = { value: value, unit: unit }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def next(now = @time_source.now)
|
|
22
|
+
current_time = ensure_time(now)
|
|
23
|
+
seconds_to_add = case @rate[:unit]
|
|
24
|
+
when 'minute' then @rate[:value] * 60
|
|
25
|
+
when 'hour' then @rate[:value] * 3600
|
|
26
|
+
when 'day' then @rate[:value] * 86_400
|
|
27
|
+
else raise ArgumentError, "Unknown rate unit: #{@rate[:unit]}"
|
|
28
|
+
end
|
|
29
|
+
current_time + seconds_to_add.seconds
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Parsers
|
|
5
|
+
class Base
|
|
6
|
+
include AWSCronParser::Helpers
|
|
7
|
+
|
|
8
|
+
attr_reader :original_source, :time_source, :source, :time_zone
|
|
9
|
+
|
|
10
|
+
def initialize(source, time_source = Time)
|
|
11
|
+
@original_source = source
|
|
12
|
+
@time_source = time_source
|
|
13
|
+
@time_zone = time_source.respond_to?(:zone) ? time_source.zone : :utc
|
|
14
|
+
@source = source
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def next(...) = raise NotImplementedError, 'This method should be implemented in a subclass'
|
|
18
|
+
def last(...) = raise NotImplementedError, 'This method should be implemented in a subclass'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Parsers
|
|
5
|
+
class QuartzCron < Base
|
|
6
|
+
delegate :next, :last, to: :@fugit_adapter
|
|
7
|
+
|
|
8
|
+
def initialize(source, time_source = Time)
|
|
9
|
+
super(source, time_source)
|
|
10
|
+
|
|
11
|
+
# Initialize FugitAdapter for standard CRON parsing
|
|
12
|
+
# FugitAdapter handles:
|
|
13
|
+
# - Normalization (? → *, etc.)
|
|
14
|
+
# - Validation (field count, ranges, special chars)
|
|
15
|
+
# - Calculation of next/last occurrences
|
|
16
|
+
# - Timezone handling
|
|
17
|
+
@fugit_adapter = FugitAdapter.new(@source, time_source)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
class Validator
|
|
5
|
+
include AWSCronParser::Helpers
|
|
6
|
+
|
|
7
|
+
def self.validate(source)
|
|
8
|
+
raise ArgumentError, 'CRON expression cannot be blank' if source.nil? || source.strip.empty?
|
|
9
|
+
|
|
10
|
+
trimmed = source.strip
|
|
11
|
+
|
|
12
|
+
if RATE_REGEX.match?(trimmed)
|
|
13
|
+
return AWSCronParser::Validators::AWSRateValidator.validate(trimmed)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if MACRO_REGEX.match?(trimmed)
|
|
17
|
+
return AWSCronParser::Validators::AWSMacroValidator.validate(trimmed)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if AT_REGEX.match?(trimmed)
|
|
21
|
+
return AWSCronParser::Validators::AWSAtValidator.validate(trimmed)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if trimmed.start_with?('cron(')
|
|
25
|
+
return AWSCronParser::Validators::AWSCronValidator.validate(trimmed)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
AWSCronParser::Validators::QuartzCronValidator.validate(trimmed)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Validators
|
|
5
|
+
# Validates AWS one-time schedules: at(yyyy-MM-ddThh:mm:ss)
|
|
6
|
+
class AWSAtValidator < Base
|
|
7
|
+
DATE_TIME_FORMAT = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\z/
|
|
8
|
+
|
|
9
|
+
def validate
|
|
10
|
+
match = source.match(AT_REGEX)
|
|
11
|
+
raise ArgumentError, 'Invalid at expression' unless match
|
|
12
|
+
|
|
13
|
+
date_time = match[:datetime].strip
|
|
14
|
+
parts = date_time.match(DATE_TIME_FORMAT)
|
|
15
|
+
raise ArgumentError, "Invalid at date-time: expected yyyy-MM-ddThh:mm:ss, got #{date_time}" unless parts
|
|
16
|
+
|
|
17
|
+
year, month, day, hour, minute, second = parts.captures.map(&:to_i)
|
|
18
|
+
|
|
19
|
+
# Date.parse would roll 2025-02-30 forward into March instead of failing.
|
|
20
|
+
raise ArgumentError, "Invalid at date: #{date_time}" unless Date.valid_date?(year, month, day)
|
|
21
|
+
|
|
22
|
+
raise ArgumentError, "Invalid at time: #{date_time}" unless hour <= 23 && minute <= 59 && second <= 59
|
|
23
|
+
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|