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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0284e39612c4ce6c3f6a32d9f0c296d95db41827b0f9cb61a1e384c6bd81480d'
4
+ data.tar.gz: 4f769078b37d559c1db706aeb1b02e99c9f88d879bb2ebe93cec135f4c381170
5
+ SHA512:
6
+ metadata.gz: 1f86462e91dfff10b4a9b18376572361e523a5d981ca11bd898e65a4cc4d1d2e3e4126e379114142ec9f721456b3fa98d42080bd8f19760bf33c20b76f2ca91b
7
+ data.tar.gz: 98ef95a962a5b3bcbcd4cc90ef20c6541b3185659d2f215991d4d75b9ac4f721616cd33508997dceba5aa4537606af519cc556b6324b798ca9eb135e0d2e46d1
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0]
6
+
7
+ Extracted from an internal Rails application into a standalone gem. Autoloading
8
+ runs through the gem's own Zeitwerk loader and Rails is no longer required.
9
+
10
+ Fixed while extracting, all of which the inherited suite passed straight through:
11
+
12
+ - AWS weekday numbers were described one day off. The parser matches on
13
+ `1=SUN..7=SAT` but the describer named `1` Monday, so `cron(0 9 ? * 2 *)` ran on
14
+ Monday while reporting Tuesday.
15
+ - `Helpers::Time` shadowed `::Time` for every class including `Helpers`, since a
16
+ bare `Time` resolves through the ancestor chain. `Parser.new(expr).next` raised
17
+ `NoMethodError`, and `Describer` silently returned nil for `at(...)`. The module
18
+ is now `Helpers::Clock`.
19
+ - `@daily` and the other macros validated and described correctly but raised
20
+ `NotImplementedError` on `next`: the expansion was computed and discarded, and
21
+ `@weekly` expanded to standard CRON's `0=SUN` which this parser never matches.
22
+ - A day-of-week given as a bare name described as "day 0" — `FRI` was run through
23
+ `to_i`. Ranges and lists were unaffected.
24
+ - Month names described as "month January", from a double-applied formatter.
25
+ - `*/N` was rejected in the month and day-of-week fields, the two that also
26
+ resolve names: the `*` was passed to the abbreviation lookup. Past that, the
27
+ AWS day-of-week field had no step expansion, so `cron(0 9 ? * */2 *)` searched
28
+ a year of candidates and failed with a bare `RuntimeError`.
29
+ - Standard CRON spells Sunday 0 or 7; `0 9 * * 7` described as "day 7".
30
+ - `at(...)` was unreachable: the expression had a describer branch and a regex but
31
+ no validator or parser, so it was rejected before reaching them. It is now
32
+ supported end to end, including a `next` that stops once the instant passes.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Washington Silva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated software documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # aws-cron-parser
2
+
3
+ [![CI](https://github.com/w-osilva/aws-cron-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/w-osilva/aws-cron-parser/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/aws-cron-parser.svg)](https://rubygems.org/gems/aws-cron-parser)
5
+
6
+ Parse, validate and describe the scheduling expressions accepted by AWS EventBridge and
7
+ Systems Manager, plus Quartz and standard 5-field CRON.
8
+
9
+ - **AWS cron** — `cron(0 9 ? * MON-FRI *)`, 6 fields, `?` / `L` / `W` / `#`, weekdays `1=SUN..7=SAT`
10
+ - **AWS rate** — `rate(5 minutes)`, `rate(1 day)`
11
+ - **AWS at** — `at(2025-06-15T10:30:00)`, a one-time schedule
12
+ - **Standard / Quartz cron** — `0 9 * * 1-5`, `*/10 * * * *`, 6-field with seconds
13
+ - **Macros** — `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`
14
+
15
+ No Rails required. The gem autoloads through its own Zeitwerk loader.
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ gem 'aws-cron-parser'
21
+ ```
22
+
23
+ ```ruby
24
+ require 'aws_cron_parser'
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ruby
30
+ parser = AWSCronParser.parse('cron(0 9 ? * MON-FRI *)')
31
+
32
+ parser.describe # => "Every Monday through Friday at 9 AM"
33
+ parser.expression # => "cron(0 9 ? * MON-FRI *)"
34
+ parser.source # => "0 9 ? * MON-FRI"
35
+ ```
36
+
37
+ ### Next and previous occurrences
38
+
39
+ ```ruby
40
+ now = Time.utc(2025, 1, 15, 10, 30)
41
+ parser = AWSCronParser.parse('0 9 * * 1-5')
42
+
43
+ parser.next(now) # => 2025-01-16 09:00:00 UTC
44
+ parser.last(now) # => 2025-01-15 09:00:00 UTC
45
+ parser.next_runs(3, now) # => [2025-01-16 09:00, 2025-01-17 09:00, 2025-01-20 09:00]
46
+ ```
47
+
48
+ `next_unique_dates(count, from)` and `next_unique_hours(count, from)` collapse repeated
49
+ executions down to one entry per day or per hour — useful when rendering a schedule preview.
50
+
51
+ `last` is only available for standard/Quartz expressions; AWS `cron(...)`, `rate(...)`
52
+ and `at(...)` raise `ArgumentError`, since they are anchored forwards in time.
53
+
54
+ ### One-time schedules
55
+
56
+ `at(...)` fires exactly once. `next` returns the instant while it is still ahead and
57
+ `nil` once it has passed, so `next_runs` yields at most one entry:
58
+
59
+ ```ruby
60
+ parser = AWSCronParser.parse('at(2025-06-15T10:30:00)')
61
+
62
+ parser.next(Time.utc(2025, 1, 1)) # => 2025-06-15 10:30:00 UTC
63
+ parser.next(Time.utc(2026, 1, 1)) # => nil
64
+ parser.next_runs(5, Time.utc(2025, 1, 1)).size # => 1
65
+ ```
66
+
67
+ ### Weekday numbering
68
+
69
+ Inside `cron(...)` weekdays follow AWS: **1 = Sunday** through 7 = Saturday. Bare
70
+ 5-field expressions follow standard CRON, where 0 and 7 are Sunday and 1 is Monday.
71
+ `cron(0 9 ? * 1 *)` and `0 9 * * 1` are therefore different schedules.
72
+
73
+ ### Validation
74
+
75
+ ```ruby
76
+ AWSCronParser::Validator.validate('0 9 * * 1-5') # => true
77
+ AWSCronParser.parse('cron(99 9 ? * MON *)') # raises ArgumentError: minute 99 out of range
78
+
79
+ parser = AWSCronParser.parse('0 9 * * 1-5')
80
+ parser.valid? # => true
81
+ parser.errors # => []
82
+ ```
83
+
84
+ `AWSCronParser.parse` validates eagerly and raises `ArgumentError` with a field-level
85
+ message. Use `valid?` / `errors` when you want to report the problem instead of raising.
86
+
87
+ ### Descriptions
88
+
89
+ ```ruby
90
+ AWSCronParser::Describer.describe('cron(0/15 * * * ? *)') # => "Every 15 minutes"
91
+ AWSCronParser::Describer.describe('cron(0 0 L * ? *)') # => "On the last day of the month at 12 AM"
92
+ AWSCronParser::Describer.describe('cron(0 12 ? * 6#3 *)') # => "On the third Saturday at 12 PM"
93
+ AWSCronParser::Describer.describe('rate(5 minutes)') # => "Every 5 minutes"
94
+ ```
95
+
96
+ Wording lives in `lib/aws_cron_parser/describer/template.en.yml`.
97
+
98
+ ### Time zones
99
+
100
+ The parsers build zone-aware times through ActiveSupport's `Time.zone`. Outside Rails the
101
+ gem defaults it to UTC if the host has not set one; inside Rails your `config.time_zone`
102
+ wins. You can also pass an explicit time source:
103
+
104
+ ```ruby
105
+ AWSCronParser.parse('0 9 * * 1-5', Time.find_zone('America/Sao_Paulo'))
106
+ ```
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ bundle install
112
+ bundle exec rspec
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Describer
5
+ # Composes complete CRON descriptions from field descriptions
6
+ class CronComposer
7
+ include AWSCronParser::Helpers
8
+
9
+ attr_reader :config, :is_aws
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ @is_aws = config[:type]&.start_with?('aws-cron')
14
+ end
15
+
16
+ def compose
17
+ # Handle simple patterns first
18
+ return quick_patterns if quick_patterns
19
+
20
+ # Build component descriptions
21
+ time_desc = TimeComposer.new(config, is_aws: is_aws).compose
22
+ day_desc = DayComposer.new(config, is_aws: is_aws).compose
23
+ month_desc = MonthComposer.new(config, is_aws: is_aws).compose
24
+
25
+ # Compose final description based on what's set
26
+ compose_description(time_desc, day_desc, month_desc)
27
+ end
28
+
29
+ private
30
+
31
+ def quick_patterns
32
+ s = config[:second]
33
+ m = config[:minute]
34
+ h = config[:hour]
35
+ day = config[:day]
36
+ dow = config[:dow]
37
+ month = config[:month]
38
+
39
+ # Quick patterns for common cases
40
+ # Standard CRON (5 fields, no seconds)
41
+ if s.nil? && all_wildcards?(m, h, day, dow, month)
42
+ return 'Every minute'
43
+ end
44
+
45
+ # Unix CRON with seconds (6 fields) or AWS CRON with seconds (7 fields)
46
+ if s.present? && all_wildcards?(s, m, h, day, dow, month)
47
+ return 'Every second'
48
+ end
49
+
50
+ # Only seconds field is set (rest are wildcards)
51
+ if s.present? && s != '*' && all_wildcards?(m, h, day, dow, month)
52
+ return nil # Let time composer handle it
53
+ end
54
+
55
+ # Every minute (when second is 0 or wildcard, minute is wildcard)
56
+ if (s.nil? || s == '*' || s == '0') && all_wildcards?(m, h, day, dow, month)
57
+ return 'Every minute'
58
+ end
59
+
60
+ # NOTE: "0 * * * *" should be "Every hour at minute 0", not just "Every hour"
61
+ # Let TimeComposer handle this case
62
+
63
+ nil
64
+ end
65
+
66
+ def all_wildcards?(*fields)
67
+ fields.all? { |f| wildcards?(f) }
68
+ end
69
+
70
+ def wildcards?(*fields)
71
+ fields.all? { |f| f.nil? || f == '*' || f == '?' }
72
+ end
73
+
74
+ def compose_description(time_desc, day_desc, month_desc)
75
+ # Determine which components are present
76
+ has_time = time_desc.present?
77
+ has_day = day_desc.present?
78
+ has_month = month_desc.present?
79
+
80
+ case [has_time, has_day, has_month]
81
+ when [true, false, false]
82
+ # Time only (day/dow/month are wildcards) = "Every day at..."
83
+ add_every_day(time_desc)
84
+ when [false, false, true]
85
+ month_desc
86
+ when [false, true, false]
87
+ day_desc
88
+ when [true, false, true]
89
+ compose_time_month(time_desc, month_desc)
90
+ when [true, true, false]
91
+ compose_time_day(time_desc, day_desc)
92
+ when [false, true, true]
93
+ compose_day_month(day_desc, month_desc)
94
+ when [true, true, true]
95
+ compose_all(time_desc, day_desc, month_desc)
96
+ else
97
+ 'Every minute'
98
+ end
99
+ end
100
+
101
+ def add_every_day(time_desc)
102
+ # If time is simple "At X:XX AM/PM", "at X AM...", or "from X to Y", add "Every day" prefix
103
+ if time_desc.match?(/^At \d+/)
104
+ "Every day #{time_desc.sub(/^At /, 'at ')}"
105
+ elsif time_desc.match?(/^(at|from) \d+/)
106
+ "Every day #{time_desc}"
107
+ else
108
+ time_desc
109
+ end
110
+ end
111
+
112
+ def compose_time_month(time_desc, month_desc)
113
+ # Time + Month (no day): "At 12:00 AM in July"
114
+ "#{time_desc} #{month_desc}"
115
+ end
116
+
117
+ def compose_time_day(time_desc, day_desc)
118
+ # Day + Time (no month): "On the 1st at 6:00 PM"
119
+ # Capitalize day description and add "of every month" if applicable
120
+ day_desc = capitalize_first(day_desc)
121
+ day_desc = add_month_context(day_desc) if needs_month_context?(day_desc)
122
+
123
+ # Handle duplicate "Every" when both time and day start with "Every"
124
+ if time_desc.start_with?('Every') && day_desc.start_with?('Every')
125
+ # Remove "Every" from time description and make it lowercase
126
+ time_clean = time_desc.sub(/^Every /, 'every ')
127
+ "#{day_desc}, #{time_clean}"
128
+ elsif time_desc.start_with?('Every day') && day_desc.start_with?('Every')
129
+ # Remove "Every day" from time, keep time portion only
130
+ time_clean = time_desc.sub(/^Every day (from|at) /, '')
131
+ "#{day_desc} from #{time_clean}"
132
+ elsif time_desc.start_with?('at ')
133
+ # Time starts with lowercase "at" - if we have DOW, just append; otherwise add "Every day"
134
+ if day_desc.start_with?('Every')
135
+ "#{day_desc} #{time_desc}"
136
+ else
137
+ # No day context, add "Every day"
138
+ "Every day #{time_desc}"
139
+ end
140
+ elsif time_desc.start_with?('At ')
141
+ time_lower = time_desc.sub(/^At /, 'at ')
142
+ "#{day_desc} #{time_lower}"
143
+ else
144
+ "#{day_desc} #{time_desc}"
145
+ end
146
+ end
147
+
148
+ def compose_day_month(day_desc, month_desc)
149
+ # Day + Month (no specific time): "On the 1st in July"
150
+ "#{day_desc} #{month_desc}"
151
+ end
152
+
153
+ def compose_all(time_desc, day_desc, month_desc)
154
+ # All three: Time + Day + Month
155
+ # Patterns:
156
+ # - Special case: "On January 1st at 1:00 AM" (day 1 of month 1)
157
+ # - General case: "Every October on the 10th at 10:10 AM"
158
+
159
+ # Convert month from "in October" to clean form
160
+ month_clean = month_desc.sub(/^in /, '')
161
+
162
+ # Convert day from "on the 1st" to "1st" (remove both "on the")
163
+ day_clean = day_desc.sub(/^on the /, '')
164
+
165
+ # Convert time from "At 10:10 AM" or "Every day at X" to clean form
166
+ time_clean = time_desc.sub(/^At /, '').sub(/^Every day at /, '')
167
+
168
+ # Check if this is a special date: January 1st
169
+ is_january_first = month_clean == 'January' && day_clean == '1st'
170
+
171
+ if is_january_first
172
+ "On #{month_clean} #{day_clean} at #{time_clean}"
173
+ elsif month_desc.start_with?('Every')
174
+ # Month has step pattern like "Every 2 months starting in January"
175
+ "#{month_desc} on the #{day_clean} at #{time_clean}"
176
+ else
177
+ "Every #{month_clean} on the #{day_clean} at #{time_clean}"
178
+ end
179
+ end
180
+
181
+ def capitalize_first(text)
182
+ return text if text.blank?
183
+
184
+ text[0].upcase + text[1..]
185
+ end
186
+
187
+ def needs_month_context?(day_desc)
188
+ # Add "of every month" when describing days but month is wildcard
189
+ month_wildcard = wildcards?(config[:month])
190
+ month_wildcard && day_desc.match?(/^(on|On) the \d/)
191
+ end
192
+
193
+ def add_month_context(day_desc)
194
+ "#{day_desc} of every month"
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Describer
5
+ # Composes day descriptions (day-of-month and day-of-week)
6
+ class DayComposer
7
+ include AWSCronParser::Helpers
8
+
9
+ attr_reader :day, :dow, :is_aws
10
+
11
+ def initialize(config, is_aws: false)
12
+ @day = config[:day]
13
+ @dow = config[:dow]
14
+ @is_aws = is_aws
15
+ end
16
+
17
+ def compose
18
+ day_wildcard = wildcards?(day)
19
+ dow_wildcard = wildcards?(dow)
20
+
21
+ # Both wildcards - no day description
22
+ return nil if day_wildcard && dow_wildcard
23
+
24
+ # Only day-of-week
25
+ return describe_dow if day_wildcard && !dow_wildcard
26
+
27
+ # Only day-of-month
28
+ return describe_day if !day_wildcard && dow_wildcard
29
+
30
+ # Both specified (AWS allows this for OR logic)
31
+ describe_both
32
+ end
33
+
34
+ private
35
+
36
+ def wildcards?(*fields)
37
+ fields.all? { |f| f.nil? || f == '*' || f == '?' }
38
+ end
39
+
40
+ def describe_day
41
+ FieldDescribers::DayOfMonth.new(day, is_aws: is_aws).describe
42
+ end
43
+
44
+ def describe_dow
45
+ result = FieldDescribers::DayOfWeek.new(dow, is_aws: is_aws).describe
46
+ return result if result.nil?
47
+
48
+ # Add "Every" prefix for day ranges and simple day names
49
+ # "on the" prefix means it's already formatted (like "on the 2nd Monday")
50
+ return result if result.start_with?('on the', 'On the', 'Every')
51
+
52
+ # For "on Monday" or "Monday through Friday" patterns, add "Every"
53
+ if result.start_with?('on ')
54
+ "Every #{result.sub('on ', '')}"
55
+ else
56
+ # For ranges like "Monday through Friday", add "Every" prefix
57
+ "Every #{result}"
58
+ end
59
+ end
60
+
61
+ def describe_both
62
+ day_desc = describe_day
63
+ dow_desc = FieldDescribers::DayOfWeek.new(dow, is_aws: is_aws).describe
64
+
65
+ # For AWS, both fields can be set for OR logic
66
+ # Example: day=1-7 dow=MON means "1st-7th when those days are Monday"
67
+ return nil if day_desc.nil? || dow_desc.nil?
68
+
69
+ # Simplify the composition
70
+ day_desc
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Describer
5
+ module FieldDescribers
6
+ # Base class for field describers - provides common pattern matching
7
+ class Base
8
+ include AWSCronParser::Helpers
9
+
10
+ STEP_PATTERN = %r{^(?:(?<base>[\d*]+)|(?<start>\d+)-(?<end>\d+))?/(?<step>\d+)$}
11
+ RANGE_PATTERN = /^(?<start>\d+)-(?<end>\d+)$/
12
+ LIST_PATTERN = /^[\d,]+$/
13
+
14
+ attr_reader :value, :is_aws
15
+
16
+ def initialize(value, is_aws: false)
17
+ @value = value
18
+ @is_aws = is_aws
19
+ end
20
+
21
+ def describe
22
+ return nil if wildcard?
23
+
24
+ # Try patterns in order of specificity
25
+ describe_step || describe_list || describe_range || describe_single
26
+ end
27
+
28
+ private
29
+
30
+ def wildcard?
31
+ ['*', '?', ''].include?(value)
32
+ end
33
+
34
+ def describe_step
35
+ return nil unless value.include?('/')
36
+
37
+ match = value.match(STEP_PATTERN)
38
+ return nil unless match
39
+
40
+ step = match[:step].to_i
41
+ start_val = match[:start] || match[:base]
42
+ end_val = match[:end]
43
+
44
+ if start_val.nil? || start_val == '*'
45
+ step_wildcard(step)
46
+ elsif end_val
47
+ # Range with step: 8-18/2
48
+ step_range(start_val.to_i, end_val.to_i, step)
49
+ else
50
+ step_from(start_val.to_i, step)
51
+ end
52
+ end
53
+
54
+ def describe_list
55
+ return nil unless value.include?(',') && value.match?(LIST_PATTERN)
56
+
57
+ items = value.split(',').map(&:strip).sort_by(&:to_i)
58
+ formatted = items.map { |v| format_value(v.to_i) }
59
+ list_template(formatted)
60
+ end
61
+
62
+ def describe_range
63
+ return nil unless value.match?(RANGE_PATTERN)
64
+
65
+ match = value.match(RANGE_PATTERN)
66
+ start_val = match[:start].to_i
67
+ end_val = match[:end].to_i
68
+
69
+ range_template(format_value(start_val), format_value(end_val))
70
+ end
71
+
72
+ def describe_single
73
+ single_template(format_value(value.to_i))
74
+ end
75
+
76
+ # Template methods - to be overridden by subclasses
77
+ def step_wildcard(_step)
78
+ raise NotImplementedError
79
+ end
80
+
81
+ def step_from(_start, _step)
82
+ raise NotImplementedError
83
+ end
84
+
85
+ def step_range(start_val, _end_val, step)
86
+ # Default: fall back to step_from if not overridden
87
+ step_from(start_val, step)
88
+ end
89
+
90
+ def list_template(_items)
91
+ raise NotImplementedError
92
+ end
93
+
94
+ def range_template(_start, _end)
95
+ raise NotImplementedError
96
+ end
97
+
98
+ def single_template(_value)
99
+ raise NotImplementedError
100
+ end
101
+
102
+ def format_value(val)
103
+ val
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module AWSCronParser
6
+ module Describer
7
+ module FieldDescribers
8
+ # Describes day-of-month field
9
+ class DayOfMonth < Base
10
+ NTH_PATTERN = /^(\d+)#(\d+)$/
11
+ LAST_PATTERN = /^(\d+)?L$/
12
+ WEEKDAY_PATTERN = /^(\d+)W$/
13
+
14
+ def describe
15
+ return nil if wildcard?
16
+
17
+ # AWS-specific patterns
18
+ if is_aws
19
+ return describe_nth if value.match?(NTH_PATTERN)
20
+ return describe_last if value.match?(LAST_PATTERN)
21
+ return describe_closest_weekday if value.match?(WEEKDAY_PATTERN)
22
+ end
23
+
24
+ super # Use base class patterns
25
+ end
26
+
27
+ private
28
+
29
+ def describe_nth
30
+ # Not typically used for day-of-month, but handle if present
31
+ nil
32
+ end
33
+
34
+ def describe_last
35
+ return Template.interpolate('day_of_month.last_day') if value == 'L'
36
+
37
+ nil
38
+ end
39
+
40
+ def describe_closest_weekday
41
+ match = value.match(WEEKDAY_PATTERN)
42
+ day = match[1].to_i
43
+ Template.interpolate('day_of_month.closest_weekday', ordinal: ordinal(day))
44
+ end
45
+
46
+ def step_wildcard(step)
47
+ Template.interpolate('day_of_month.step', step: step, step_plural: Template.plural_for(step))
48
+ end
49
+
50
+ def step_from(start_val, step)
51
+ Template.interpolate('day_of_month.step_range',
52
+ step: step,
53
+ step_plural: Template.plural_for(step),
54
+ start_ordinal: ordinal(start_val))
55
+ end
56
+
57
+ def list_template(items)
58
+ Template.interpolate('day_of_month.list', items: format_list(items))
59
+ end
60
+
61
+ def range_template(start_val, end_val)
62
+ Template.interpolate('day_of_month.range',
63
+ start_ordinal: ordinal(start_val),
64
+ end_ordinal: ordinal(end_val))
65
+ end
66
+
67
+ def single_template(val)
68
+ Template.interpolate('day_of_month.single', ordinal: ordinal(val))
69
+ end
70
+
71
+ def format_value(val)
72
+ ordinal(val.to_i)
73
+ end
74
+
75
+ def format_list(items)
76
+ return items.first if items.length == 1
77
+
78
+ if items.length == 2
79
+ items.join(' and ')
80
+ else
81
+ last = items.pop
82
+ "#{items.join(', ')}, and #{last}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end