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,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Describer
5
+ # Composes time descriptions (second, minute, hour)
6
+ class TimeComposer
7
+ include AWSCronParser::Helpers
8
+
9
+ attr_reader :second, :minute, :hour, :is_aws
10
+
11
+ def initialize(config, is_aws: false)
12
+ @second = config[:second]
13
+ @minute = config[:minute]
14
+ @hour = config[:hour]
15
+ @is_aws = is_aws
16
+ end
17
+
18
+ def compose
19
+ return nil if all_wildcards?
20
+
21
+ # Try specific patterns first
22
+ return describe_specific_time if specific_time?
23
+ return describe_step_pattern if step_pattern?
24
+ return describe_specific_second if specific_second_pattern?
25
+
26
+ # General composition
27
+ describe_general
28
+ end
29
+
30
+ private
31
+
32
+ def all_wildcards?
33
+ [second, minute, hour].all? { |f| f.nil? || f == '*' || f == '?' }
34
+ end
35
+
36
+ def specific_time?
37
+ hour =~ DIGIT_ONLY_REGEX && minute =~ DIGIT_ONLY_REGEX
38
+ end
39
+
40
+ def step_pattern?
41
+ (minute&.include?('/') && hour == '*') ||
42
+ (hour&.include?('/') && minute == '*') ||
43
+ (minute&.include?('/') && hour =~ DIGIT_ONLY_REGEX) || # minute step with specific hour
44
+ (minute&.include?('/') && hour&.include?('-')) # minute step with hour range
45
+ end
46
+
47
+ def specific_second_pattern?
48
+ second.present? && second =~ DIGIT_ONLY_REGEX && minute == '*' && hour == '*'
49
+ end
50
+
51
+ def describe_specific_time
52
+ h = hour.to_i
53
+ m = minute.to_i
54
+
55
+ time_str = if second.present? && second =~ DIGIT_ONLY_REGEX
56
+ s = second.to_i
57
+ format_time_with_seconds(h, m, s)
58
+ else
59
+ format_time(h, m)
60
+ end
61
+
62
+ "At #{time_str}"
63
+ end
64
+
65
+ def describe_step_pattern
66
+ # Hour step: */2 or 0/2 or 1/5 (should be checked first)
67
+ # Check if minute is 0 (start of the hour) - simplify description
68
+ if hour&.match?(%r{^(\*|\d+)/(\d+)$}) && (minute == '0')
69
+ match = hour.match(%r{^(\*|\d+)/(\d+)$})
70
+ step = match[2].to_i
71
+ plural = step == 1 ? '' : 's'
72
+ return step == 1 ? 'Every hour' : "Every #{step} hour#{plural}"
73
+ end
74
+
75
+ # Minute step: */5 or 0/5
76
+ if minute&.match?(%r{^(\*|\d+)/(\d+)$})
77
+ match = minute.match(%r{^(\*|\d+)/(\d+)$})
78
+ step = match[2].to_i
79
+
80
+ # Check for hour constraints
81
+ if hour.present? && hour != '*' && hour != '?'
82
+ return describe_constrained_minute_step(step)
83
+ end
84
+
85
+ plural = step == 1 ? '' : 's'
86
+ return step == 1 ? 'Every minute' : "Every #{step} minute#{plural}"
87
+ end
88
+
89
+ nil
90
+ end
91
+
92
+ def describe_constrained_minute_step(step)
93
+ plural = step == 1 ? '' : 's'
94
+ base = step == 1 ? 'Every minute' : "Every #{step} minute#{plural}"
95
+
96
+ # Hour range: 9-17
97
+ if hour.include?('-')
98
+ parts = hour.split('-')
99
+ start_h = format_hour_12(parts[0].to_i)
100
+ end_h = format_hour_12(parts[1].to_i)
101
+ return "#{base} between #{start_h} and #{end_h}"
102
+ end
103
+
104
+ # Hour list: 9,12,15
105
+ if hour.include?(',')
106
+ hours = hour.split(',').map { |h| format_hour_12(h.to_i) }
107
+ hour_part = if hours.length == 2
108
+ hours.join(' and ')
109
+ else
110
+ last = hours.pop
111
+ "#{hours.join(', ')}, and #{last}"
112
+ end
113
+ return "#{base} at #{hour_part}"
114
+ end
115
+
116
+ # Single hour: e.g., "1/5 9 * * *" -> "Every 5 minutes from 9:01 AM to 9:56 AM"
117
+ if /^\d+$/.match?(hour)
118
+ hour_int = hour.to_i
119
+ match = minute.match(%r{^(\d+)/(\d+)$})
120
+ start_min = match ? match[1].to_i : 0
121
+
122
+ # Calculate end minute (start + step * floor((59-start)/step))
123
+ end_min = start_min + (step * ((59 - start_min) / step))
124
+
125
+ # Convert to 12-hour format
126
+ hour_12 = hour_int % 12
127
+ hour_12 = 12 if hour_12.zero?
128
+ period = hour_int < 12 ? 'AM' : 'PM'
129
+
130
+ return "#{base} from #{hour_12}:#{start_min.to_s.rjust(
131
+ 2, '0'
132
+ )} #{period} to #{hour_12}:#{end_min.to_s.rjust(
133
+ 2, '0'
134
+ )} #{period}"
135
+ end
136
+
137
+ base
138
+ end
139
+
140
+ def describe_specific_second
141
+ s = second.to_i
142
+ Template.interpolate('composition.every_minute_at_second', second: s)
143
+ end
144
+
145
+ def describe_general
146
+ # Handle minute-specific with hour=*
147
+ if hour == '*' && minute.present? && minute != '*' && minute != '?' && !minute.match?(%r{[-,/]})
148
+ m = minute.to_i
149
+ return Template.interpolate('composition.every_hour_at_minute', minute: m)
150
+ end
151
+
152
+ # Complex case: describe each component
153
+ parts = []
154
+
155
+ # Add hour if set
156
+ parts << describe_component(hour, :hour) if hour.present? && hour != '*' && hour != '?'
157
+
158
+ # Add minute only if it's not 0 when we have an hour step pattern, hour range, or hour list
159
+ # Example: "0 */4 * * *" should be "Every 4 hours", not "0 minutes and every 4 hours"
160
+ # Example: "0 10-15 * * *" should be "Every day from 10 AM to 3 PM", not "0 minutes and from..."
161
+ # Example: "0 9,12,15 * * *" should be "at 9 AM, 12 PM, and 3 PM", not "0 minutes and at..."
162
+ should_skip_minute = minute == '0' && (hour&.include?('/') || hour&.include?('-') || hour&.include?(','))
163
+ if !should_skip_minute && minute.present? && minute != '*' && minute != '?'
164
+ parts << describe_component(minute, :minute)
165
+ end
166
+
167
+ # Add second if significant
168
+ if second.present? && second != '*' && second != '?' && second != '0'
169
+ parts << describe_component(second, :second)
170
+ end
171
+
172
+ return nil if parts.empty?
173
+
174
+ # Special case: hour step with specific minute (e.g., "30 */6 * * *")
175
+ if parts.length == 2 && hour&.include?('/') && minute =~ DIGIT_ONLY_REGEX
176
+ hour_desc = parts[0]
177
+ m = minute.to_i
178
+ return "#{hour_desc} at minute #{m}"
179
+ end
180
+
181
+ # Join parts
182
+ if parts.length == 1
183
+ # Single part - return as is if it starts with a capital letter (proper sentence)
184
+ parts[0]
185
+ elsif hour&.include?('-') && minute =~ DIGIT_ONLY_REGEX
186
+ # Multiple parts - join with spaces (descriptions already have proper formatting)
187
+ # For hour ranges, we want to avoid "30 minutes and from 10 AM to 3 PM"
188
+ # Instead: "Every day at 10:30 AM to 3:30 PM" or similar
189
+ # We have a time range - format differently
190
+ format_time_range
191
+ else
192
+ parts.reverse.join(' ')
193
+ end
194
+ end
195
+
196
+ def format_time_range
197
+ hour_parts = hour.split('-')
198
+ start_h = hour_parts[0].to_i
199
+ end_h = hour_parts[1].to_i
200
+ m = minute.to_i
201
+
202
+ start_time = format_time(start_h, m)
203
+ end_time = format_time(end_h, m)
204
+
205
+ # Return without "Every day" prefix - it will be added by composer if needed
206
+ "from #{start_time} to #{end_time}"
207
+ end
208
+
209
+ def describe_component(value, type)
210
+ FieldDescribers::TimeField.new(value, type, is_aws: is_aws).describe
211
+ end
212
+
213
+ def format_time(hour, min)
214
+ hour_24 = hour.to_i % 24
215
+ am_pm = hour_24 < 12 ? 'AM' : 'PM'
216
+ hour_12 = if hour_24.zero?
217
+ 12
218
+ elsif hour_24 > 12
219
+ hour_24 - 12
220
+ else
221
+ hour_24
222
+ end
223
+ time_str = hour_12.to_s
224
+ time_str += ":#{min.to_s.rjust(2, '0')}" if min != 0 || !is_aws
225
+ "#{time_str} #{am_pm}"
226
+ end
227
+
228
+ def format_time_with_seconds(hour, min, sec)
229
+ hour_24 = hour.to_i % 24
230
+ am_pm = hour_24 < 12 ? 'AM' : 'PM'
231
+ hour_12 = if hour_24.zero?
232
+ 12
233
+ elsif hour_24 > 12
234
+ hour_24 - 12
235
+ else
236
+ hour_24
237
+ end
238
+ "#{hour_12}:#{min.to_s.rjust(2, '0')}:#{sec.to_s.rjust(2, '0')} #{am_pm}"
239
+ end
240
+
241
+ def format_hour_12(hour_24)
242
+ hour_24 = hour_24.to_i % 24
243
+ period = hour_24 < 12 ? 'AM' : 'PM'
244
+ hour_12 = if hour_24.zero?
245
+ 12
246
+ elsif hour_24 > 12
247
+ hour_24 - 12
248
+ else
249
+ hour_24
250
+ end
251
+ "#{hour_12} #{period}"
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Describer
5
+ include AWSCronParser::Helpers
6
+
7
+ module_function
8
+
9
+ # Main entry point: describe a CRON expression in human-readable form
10
+ # Returns string like "Every weekday at 9:00 AM"
11
+ def describe(expression)
12
+ return nil if expression.nil? || expression.to_s.strip.empty? || !expression.is_a?(String)
13
+
14
+ begin
15
+ AWSCronParser::Validator.validate(expression)
16
+ config = parse_config(expression)
17
+ return nil unless config
18
+
19
+ case config[:type]
20
+ when 'rate'
21
+ describe_rate(config)
22
+ when 'macro'
23
+ describe_macro(config)
24
+ when 'at'
25
+ describe_at(config)
26
+ when /^(aws-)?cron/
27
+ describe_cron(config)
28
+ end
29
+ end
30
+ end
31
+
32
+ # Parse parser into config hash for describing
33
+ def parse_config(expression)
34
+ trimmed = expression.strip
35
+
36
+ # Rate expression: rate(value unit)
37
+ if (rate_match = trimmed.match(RATE_REGEX))
38
+ unit = rate_match[:unit].downcase.chomp('s').to_sym
39
+ return { type: 'rate', value: rate_match[:value].to_i, unit: unit }
40
+ end
41
+
42
+ # Macro expressions: @yearly, @monthly, etc.
43
+ if (macro_match = trimmed.match(MACRO_REGEX))
44
+ macro = macro_match[:macro].downcase
45
+ return { type: 'macro', macro: macro }
46
+ end
47
+
48
+ # At expression: at(iso8601)
49
+ if (at_match = trimmed.match(AT_REGEX))
50
+ return { type: 'at', dateTime: at_match[:datetime] }
51
+ end
52
+
53
+ # Detect AWS cron wrapper
54
+ is_aws = false
55
+ content = trimmed
56
+ if (cron_match = content.match(AWS_CRON_REGEX))
57
+ is_aws = true
58
+ content = cron_match[:expression].strip
59
+ end
60
+
61
+ fields = content.split(/\s+/)
62
+ case fields.length
63
+ when 5
64
+ # Standard CRON: MINUTE HOUR DAY MONTH DOW
65
+ # OR AWS (no wrapper): not applicable, AWS needs cron() wrapper
66
+ { type: is_aws ? 'aws-cron-5' : 'cron-5', second: nil, minute: fields[0], hour: fields[1], day: fields[2],
67
+ month: fields[3], dow: fields[4], is_aws: is_aws }
68
+ when 6
69
+ if is_aws
70
+ # AWS with wrapper: MINUTE HOUR DAY MONTH DOW YEAR
71
+ { type: 'aws-cron-6', second: nil, minute: fields[0], hour: fields[1], day: fields[2], month: fields[3],
72
+ dow: fields[4], year: fields[5], is_aws: is_aws }
73
+ else
74
+ # Unix CRON: SECOND MINUTE HOUR DAY MONTH DOW
75
+ { type: 'cron-6', second: fields[0], minute: fields[1], hour: fields[2], day: fields[3], month: fields[4],
76
+ dow: fields[5], is_aws: is_aws }
77
+ end
78
+ when 7
79
+ # AWS with seconds: SECOND MINUTE HOUR DAY MONTH DOW YEAR
80
+ { type: 'aws-cron-7', second: fields[0], minute: fields[1], hour: fields[2], day: fields[3], month: fields[4],
81
+ dow: fields[5], year: fields[6], is_aws: is_aws }
82
+ end
83
+ end
84
+
85
+ # Describe rate expression
86
+ def describe_rate(config)
87
+ value = config[:value]
88
+ unit = config[:unit]
89
+
90
+ Template.interpolate(value == 1 ? 'rate.singular' : 'rate.plural', value: value, unit: unit)
91
+ end
92
+
93
+ # Describe macro expression
94
+ def describe_macro(config)
95
+ macro = config[:macro]
96
+ Template.interpolate("macro.#{macro}") || "Every #{macro}"
97
+ end
98
+
99
+ # Describe at expression
100
+ def describe_at(config)
101
+ date_time = Time.zone.parse(config[:dateTime])
102
+ "At #{date_time.strftime('%A, %b %d, %Y %I:%M:%S %p')}"
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ # Describe CRON expression using composers
108
+ def describe_cron(config)
109
+ CronComposer.new(config).compose
110
+ end
111
+
112
+ # Legacy method - kept for backward compatibility
113
+ # Delegates to appropriate field describer
114
+ def describe_field(value, field_type, is_aws: false)
115
+ return nil if value.blank? || ['*', '?'].include?(value)
116
+
117
+ case field_type
118
+ when 'minute', 'second', 'hour'
119
+ FieldDescribers::TimeField.new(value, field_type.to_sym, is_aws: is_aws).describe
120
+ when 'day-of-month'
121
+ FieldDescribers::DayOfMonth.new(value, is_aws: is_aws).describe
122
+ when 'day-of-week'
123
+ FieldDescribers::DayOfWeek.new(value, is_aws: is_aws).describe
124
+ when 'month'
125
+ FieldDescribers::Month.new(value, is_aws: is_aws).describe
126
+ else
127
+ value
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fugit'
4
+
5
+ module AWSCronParser
6
+ # Adapter to use Fugit gem as replacement for parse-cron
7
+ # Provides a unified interface for next/last time calculations
8
+ class FugitAdapter
9
+ def initialize(source, time_source = Time)
10
+ @source = source
11
+ @time_source = time_source
12
+ @normalized_source = normalize_source(source)
13
+
14
+ # Parse with fugit
15
+ @fugit_cron = Fugit::Cron.parse(@normalized_source)
16
+
17
+ raise ArgumentError, "Invalid CRON expression: #{source}" if @fugit_cron.nil?
18
+ end
19
+
20
+ # Get next occurrence of the cron expression
21
+ def next(now)
22
+ result = @fugit_cron.next_time(now)
23
+ normalize_to_time_zone(result)
24
+ end
25
+
26
+ # Get previous occurrence of the cron expression
27
+ def last(now)
28
+ result = @fugit_cron.previous_time(now)
29
+ normalize_to_time_zone(result)
30
+ end
31
+
32
+ # Get the original source expression
33
+ def original
34
+ @source
35
+ end
36
+
37
+ private
38
+
39
+ # Normalize cron expression for fugit compatibility
40
+ # Fugit doesn't support ? and L in the same way as Quartz/AWS
41
+ def normalize_source(source)
42
+ normalized = source.dup
43
+
44
+ # Replace ? with * for compatibility
45
+ # ? means "no specific value" in Quartz, but fugit uses * for wildcard
46
+ normalized.tr('?', '*')
47
+
48
+ # NOTE: L (last day) is supported by fugit in 5-field format
49
+ # No need to replace it like parse-cron required
50
+ end
51
+
52
+ # Convert EtOrbi time to Rails Time.zone if available
53
+ # This ensures consistency with the rest of the application
54
+ def normalize_to_time_zone(time)
55
+ return nil unless time
56
+
57
+ # If Time.zone is not available, return as-is
58
+ return time unless Time.respond_to?(:zone) && Time.zone
59
+
60
+ # Convert EtOrbi::EoTime to Time.zone
61
+ Time.zone.at(time.to_i)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Helpers
5
+ module Calendar
6
+ AWS_DAY_NAMES = {
7
+ 'SUN' => 1, 'SUNDAY' => 1,
8
+ 'MON' => 2, 'MONDAY' => 2,
9
+ 'TUE' => 3, 'TUESDAY' => 3,
10
+ 'WED' => 4, 'WEDNESDAY' => 4,
11
+ 'THU' => 5, 'THURSDAY' => 5,
12
+ 'FRI' => 6, 'FRIDAY' => 6,
13
+ 'SAT' => 7, 'SATURDAY' => 7
14
+ }.freeze
15
+
16
+ DAY_NAMES = {
17
+ 'SUN' => 0, 'SUNDAY' => 0,
18
+ 'MON' => 1, 'MONDAY' => 1,
19
+ 'TUE' => 2, 'TUESDAY' => 2,
20
+ 'WED' => 3, 'WEDNESDAY' => 3,
21
+ 'THU' => 4, 'THURSDAY' => 4,
22
+ 'FRI' => 5, 'FRIDAY' => 5,
23
+ 'SAT' => 6, 'SATURDAY' => 6
24
+ }.freeze
25
+
26
+ # Weekday names (0-6: SUN-SAT, where 0=Sunday, 6=Saturday)
27
+ WEEKDAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
28
+
29
+ # AWS weekday names (1-7: SUN-SAT, where 1=Sunday, 7=Saturday).
30
+ # Must stay aligned with AWS_DAY_NAMES above and with the parser, which
31
+ # matches candidates on Date#wday + 1.
32
+ AWS_WEEKDAY_NAMES = [nil, 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].freeze
33
+
34
+ # Month names (1-based indexing for CRON)
35
+ MONTH_NAMES = [nil, 'January', 'February', 'March', 'April', 'May', 'June',
36
+ 'July', 'August', 'September', 'October', 'November', 'December'].freeze
37
+
38
+ # Month abbreviations (for parsing JAN, FEB, etc.)
39
+ # Using upcase in lookup to avoid case duplication
40
+ MONTH_ABBREVIATIONS = {
41
+ 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, 'MAY' => 5, 'JUN' => 6,
42
+ 'JUL' => 7, 'AUG' => 8, 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
43
+ }.freeze
44
+
45
+ # Month full names (for parsing JANUARY, FEBRUARY, etc.)
46
+ # Using upcase in lookup to avoid case duplication
47
+ MONTH_FULL_NAMES = {
48
+ 'JANUARY' => 1, 'FEBRUARY' => 2, 'MARCH' => 3, 'APRIL' => 4, 'MAY' => 5, 'JUNE' => 6,
49
+ 'JULY' => 7, 'AUGUST' => 8, 'SEPTEMBER' => 9, 'OCTOBER' => 10, 'NOVEMBER' => 11, 'DECEMBER' => 12
50
+ }.freeze
51
+
52
+ # Convert day name from number or string
53
+ def day_name(num_or_str, is_aws: false)
54
+ if num_or_str.is_a?(String) && num_or_str.match?(/[A-Z]/)
55
+ case num_or_str.upcase
56
+ when 'MON' then 'Monday'
57
+ when 'TUE' then 'Tuesday'
58
+ when 'WED' then 'Wednesday'
59
+ when 'THU' then 'Thursday'
60
+ when 'FRI' then 'Friday'
61
+ when 'SAT' then 'Saturday'
62
+ when 'SUN' then 'Sunday'
63
+ else num_or_str
64
+ end
65
+ else
66
+ weekday_names = is_aws ? AWS_WEEKDAY_NAMES : WEEKDAY_NAMES
67
+ num = num_or_str.to_i
68
+ # Standard CRON spells Sunday either 0 or 7; AWS only uses 1-7.
69
+ num = 0 if !is_aws && num == 7
70
+ weekday_names[num] || "day #{num_or_str}"
71
+ end
72
+ end
73
+
74
+ # Map month name from number
75
+ def month_name(num)
76
+ MONTH_NAMES[num.to_i] || "month #{num}"
77
+ end
78
+
79
+ # Convert month abbreviation to number
80
+ def month_abbr_to_num(abbr)
81
+ raise ArgumentError, "Invalid month abbreviation: #{abbr.inspect}" unless abbr.is_a?(String) && !abbr.empty?
82
+
83
+ return abbr.to_i if /^\d+$/.match?(abbr)
84
+
85
+ upcase_abbr = abbr.upcase
86
+ MONTH_FULL_NAMES[upcase_abbr] || MONTH_ABBREVIATIONS[upcase_abbr] ||
87
+ (raise ArgumentError, "Invalid month abbreviation: #{abbr}")
88
+ end
89
+
90
+ # Convert weekday abbreviation to number
91
+ def weekday_abbr_to_num(abbr, is_aws: false)
92
+ raise ArgumentError, "Invalid weekday abbreviation: #{abbr.inspect}" unless abbr.is_a?(String) && !abbr.empty?
93
+
94
+ return abbr.to_i if /^\d+$/.match?(abbr)
95
+
96
+ day_names = is_aws ? AWS_DAY_NAMES : DAY_NAMES
97
+ day_names[abbr.upcase] || (raise ArgumentError, "Invalid weekday abbreviation: #{abbr}")
98
+ end
99
+
100
+ # Convert weekday name from abbreviation
101
+ def weekday_name_from_abbr(abbr)
102
+ case abbr.upcase
103
+ when 'MON' then 'Monday'
104
+ when 'TUE' then 'Tuesday'
105
+ when 'WED' then 'Wednesday'
106
+ when 'THU' then 'Thursday'
107
+ when 'FRI' then 'Friday'
108
+ when 'SAT' then 'Saturday'
109
+ when 'SUN' then 'Sunday'
110
+ else abbr
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Helpers
5
+ # Deliberately not named Time: every class that includes Helpers would then
6
+ # resolve a bare `Time` to this module instead of ::Time through the
7
+ # ancestor chain, silently breaking default arguments and Time.zone calls.
8
+ module Clock
9
+ # Ensure the given 'now' is a Time object
10
+ def ensure_time(now, time_source = nil)
11
+ time_source ||= @time_source || ::Time
12
+ return now if now.is_a?(::Time)
13
+
14
+ # If it's a Date or DateTime, convert preserving timezone info
15
+ if now.respond_to?(:hour) && now.respond_to?(:min)
16
+ # It's a DateTime
17
+ time = time_source.local(now.year, now.month, now.day, now.hour, now.min, 0)
18
+ now.respond_to?(:utc?) && now.utc? ? time.utc : time
19
+ else
20
+ # It's a Date
21
+ time_source.local(now.year, now.month, now.day, 0, 0, 0)
22
+ end
23
+ end
24
+
25
+ # Get the start of the next month from a given date
26
+ def next_month_start(search_date, time_source = nil)
27
+ time_source ||= @time_source || ::Time
28
+ next_month = Date.new(search_date.year, search_date.month, 1).next_month
29
+
30
+ # Handle both Time.zone (Rails) and ::Time (Ruby)
31
+ if time_source.respond_to?(:local)
32
+ time_source.local(next_month.year, next_month.month, 1, 0, 0, 0)
33
+ else
34
+ ::Time.zone.local(next_month.year, next_month.month, 1, 0, 0, 0)
35
+ end
36
+ end
37
+
38
+ # Adjust time to the target second, rolling over minute if needed
39
+ def adjust_seconds(time, target_second)
40
+ if time.sec == target_second
41
+ time
42
+ elsif time.sec < target_second
43
+ time.change(sec: target_second)
44
+ else
45
+ (time + 60.seconds).change(sec: target_second)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end