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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +117 -0
  5. data/lib/aws_cron_parser/describer/cron_composer.rb +198 -0
  6. data/lib/aws_cron_parser/describer/day_composer.rb +74 -0
  7. data/lib/aws_cron_parser/describer/field_describers/base.rb +108 -0
  8. data/lib/aws_cron_parser/describer/field_describers/day_of_month.rb +88 -0
  9. data/lib/aws_cron_parser/describer/field_describers/day_of_week.rb +143 -0
  10. data/lib/aws_cron_parser/describer/field_describers/month.rb +92 -0
  11. data/lib/aws_cron_parser/describer/field_describers/time_field.rb +101 -0
  12. data/lib/aws_cron_parser/describer/month_composer.rb +29 -0
  13. data/lib/aws_cron_parser/describer/template.en.yml +183 -0
  14. data/lib/aws_cron_parser/describer/template.rb +87 -0
  15. data/lib/aws_cron_parser/describer/time_composer.rb +255 -0
  16. data/lib/aws_cron_parser/describer.rb +131 -0
  17. data/lib/aws_cron_parser/fugit_adapter.rb +64 -0
  18. data/lib/aws_cron_parser/helpers/calendar.rb +115 -0
  19. data/lib/aws_cron_parser/helpers/clock.rb +50 -0
  20. data/lib/aws_cron_parser/helpers/cron_expression.rb +277 -0
  21. data/lib/aws_cron_parser/helpers/numbers.rb +32 -0
  22. data/lib/aws_cron_parser/helpers.rb +17 -0
  23. data/lib/aws_cron_parser/parser.rb +152 -0
  24. data/lib/aws_cron_parser/parsers/aws_at.rb +32 -0
  25. data/lib/aws_cron_parser/parsers/aws_cron.rb +413 -0
  26. data/lib/aws_cron_parser/parsers/aws_rate.rb +33 -0
  27. data/lib/aws_cron_parser/parsers/base.rb +21 -0
  28. data/lib/aws_cron_parser/parsers/quartz_cron.rb +21 -0
  29. data/lib/aws_cron_parser/validator.rb +31 -0
  30. data/lib/aws_cron_parser/validators/aws_at_validator.rb +28 -0
  31. data/lib/aws_cron_parser/validators/aws_cron_validator.rb +212 -0
  32. data/lib/aws_cron_parser/validators/aws_macro_validator.rb +13 -0
  33. data/lib/aws_cron_parser/validators/aws_rate_validator.rb +17 -0
  34. data/lib/aws_cron_parser/validators/base.rb +23 -0
  35. data/lib/aws_cron_parser/validators/quartz_cron_validator.rb +132 -0
  36. data/lib/aws_cron_parser/version.rb +5 -0
  37. data/lib/aws_cron_parser.rb +48 -0
  38. 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