wheneverd 0.3.0 → 0.4.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +45 -2
  4. data/lib/wheneverd/dsl/period_parser.rb +8 -107
  5. data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
  6. data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
  7. data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
  8. data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
  9. data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
  10. data/lib/wheneverd/dsl/period_strategy.rb +43 -0
  11. data/lib/wheneverd/duration.rb +1 -7
  12. data/lib/wheneverd/errors.rb +3 -0
  13. data/lib/wheneverd/interval.rb +22 -7
  14. data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
  15. data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
  16. data/lib/wheneverd/systemd/cron_parser.rb +56 -303
  17. data/lib/wheneverd/systemd/renderer.rb +6 -64
  18. data/lib/wheneverd/systemd/unit_content_builder.rb +76 -0
  19. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  20. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  21. data/lib/wheneverd/systemd/unit_namer.rb +6 -14
  22. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  23. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  24. data/lib/wheneverd/trigger/base.rb +22 -0
  25. data/lib/wheneverd/trigger/boot.rb +8 -6
  26. data/lib/wheneverd/trigger/calendar.rb +7 -0
  27. data/lib/wheneverd/trigger/interval.rb +8 -6
  28. data/lib/wheneverd/validation.rb +89 -0
  29. data/lib/wheneverd/version.rb +1 -1
  30. data/lib/wheneverd.rb +4 -1
  31. data/test/domain_model_test.rb +105 -0
  32. data/test/systemd_cron_parser_test.rb +41 -25
  33. data/test/systemd_renderer_errors_test.rb +1 -1
  34. metadata +13 -1
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ module CronParser
6
+ # Parses and formats day-of-week cron field expressions.
7
+ #
8
+ # Day-of-week has specialized handling:
9
+ # - Both 0 and 7 represent Sunday
10
+ # - Wrap-around ranges (e.g., Fri-Mon)
11
+ # - Formatting to systemd's Mon..Fri syntax
12
+ module DowParser
13
+ DOW_NAMES = {
14
+ "sun" => 0,
15
+ "mon" => 1,
16
+ "tue" => 2,
17
+ "wed" => 3,
18
+ "thu" => 4,
19
+ "fri" => 5,
20
+ "sat" => 6
21
+ }.freeze
22
+
23
+ DOW_SYSTEMD = {
24
+ 0 => "Sun",
25
+ 1 => "Mon",
26
+ 2 => "Tue",
27
+ 3 => "Wed",
28
+ 4 => "Thu",
29
+ 5 => "Fri",
30
+ 6 => "Sat"
31
+ }.freeze
32
+
33
+ # Day order for formatting (Mon-Sun instead of Sun-Sat)
34
+ DOW_ORDER = [1, 2, 3, 4, 5, 6, 0].freeze
35
+
36
+ # Parses a day-of-week field into a set of days.
37
+ #
38
+ # @param dow_str [String] the day-of-week field value
39
+ # @param input [String] full cron expression for error messages
40
+ # @return [Array<Integer>, nil] array of day numbers (0-6), or nil if all days
41
+ # @raise [Wheneverd::Systemd::UnsupportedCronError]
42
+ def self.parse(dow_str, input:)
43
+ raw = dow_str.to_s.strip
44
+ raise_empty_error(input) if raw.empty?
45
+ return nil if raw == "*"
46
+
47
+ present = Array.new(7, false)
48
+
49
+ raw.split(",").map(&:strip).each do |part|
50
+ apply_part(present, part, input: input)
51
+ end
52
+
53
+ days = present.each_index.select { |idx| present[idx] }
54
+ return nil if days.length == 7
55
+
56
+ days
57
+ end
58
+
59
+ # Formats a set of days into a systemd-compatible expression.
60
+ #
61
+ # @param days [Array<Integer>] array of day numbers (0-6)
62
+ # @return [String] systemd expression (e.g., "Mon..Fri", "Mon,Wed,Fri")
63
+ def self.format(days)
64
+ present = Array.new(7, false)
65
+ days.each { |day| present[day] = true }
66
+
67
+ tokens = []
68
+ i = 0
69
+ while i < DOW_ORDER.length
70
+ day = DOW_ORDER.fetch(i)
71
+ unless present.fetch(day)
72
+ i += 1
73
+ next
74
+ end
75
+
76
+ start = day
77
+ j = i
78
+ j += 1 while (j + 1) < DOW_ORDER.length && present.fetch(DOW_ORDER.fetch(j + 1))
79
+ finish = DOW_ORDER.fetch(j)
80
+
81
+ token = format_range(start, finish)
82
+ tokens << token
83
+ i = j + 1
84
+ end
85
+
86
+ tokens.join(",")
87
+ end
88
+
89
+ def self.apply_part(present, part, input:)
90
+ raise_invalid_token_error(input) if part.empty?
91
+
92
+ base, step_str = part.split("/", 2)
93
+ step = parse_step(step_str, input: input)
94
+
95
+ sequence = parse_sequence(base, step, input: input)
96
+
97
+ if step
98
+ sequence.each_with_index { |day, idx| present[day] = true if (idx % step).zero? }
99
+ else
100
+ sequence.each { |day| present[day] = true }
101
+ end
102
+ end
103
+ private_class_method :apply_part
104
+
105
+ def self.parse_sequence(base, step, input:)
106
+ if base == "*"
107
+ (0..6).to_a
108
+ elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
109
+ range_sequence(match[:start], match[:finish], input: input)
110
+ else
111
+ start_day = parse_value(base, input: input)
112
+ start_day = 0 if start_day == 7
113
+ step ? (start_day..6).to_a : [start_day]
114
+ end
115
+ end
116
+ private_class_method :parse_sequence
117
+
118
+ def self.range_sequence(start_token, finish_token, input:)
119
+ start_raw = parse_value(start_token, input: input)
120
+ finish_raw = parse_value(finish_token, input: input)
121
+
122
+ start_day = start_raw == 7 ? 0 : start_raw
123
+ finish_day = finish_raw == 7 ? 0 : finish_raw
124
+
125
+ return (0..6).to_a if start_raw.zero? && finish_raw == 7
126
+
127
+ return (start_day..6).to_a + [0] if finish_raw == 7 && !start_raw.zero?
128
+
129
+ return (start_day..finish_day).to_a if start_day <= finish_day
130
+
131
+ (start_day..6).to_a + (0..finish_day).to_a
132
+ end
133
+ private_class_method :range_sequence
134
+
135
+ def self.parse_value(token, input:)
136
+ raw = token.to_s.strip
137
+ raise_invalid_token_error(input) if raw.empty?
138
+
139
+ return parse_numeric_value(raw, input: input) if /\A\d+\z/.match?(raw)
140
+
141
+ parse_named_value(raw, input: input)
142
+ end
143
+ private_class_method :parse_value
144
+
145
+ def self.parse_numeric_value(raw, input:)
146
+ value = Integer(raw, 10)
147
+ return value if value.between?(0, 7)
148
+
149
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: day-of-week out of range"
150
+ end
151
+ private_class_method :parse_numeric_value
152
+
153
+ def self.parse_named_value(raw, input:)
154
+ unless /\A[A-Za-z]+\z/.match?(raw)
155
+ raise UnsupportedCronError,
156
+ "Unsupported cron #{input.inspect}: invalid day-of-week token"
157
+ end
158
+
159
+ key = raw.downcase
160
+ key = key[0, 3] if key.length > 3
161
+ return DOW_NAMES.fetch(key) if DOW_NAMES.key?(key)
162
+
163
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid day-of-week token"
164
+ end
165
+ private_class_method :parse_named_value
166
+
167
+ def self.parse_step(step_str, input:)
168
+ return nil if step_str.nil?
169
+
170
+ unless /\A\d+\z/.match?(step_str)
171
+ raise UnsupportedCronError,
172
+ "Unsupported cron #{input.inspect}: day-of-week step must be a number"
173
+ end
174
+
175
+ value = Integer(step_str, 10)
176
+ unless value.positive?
177
+ raise UnsupportedCronError,
178
+ "Unsupported cron #{input.inspect}: day-of-week step must be positive"
179
+ end
180
+
181
+ value
182
+ end
183
+ private_class_method :parse_step
184
+
185
+ def self.format_range(start, finish)
186
+ if start == finish
187
+ DOW_SYSTEMD.fetch(start)
188
+ else
189
+ "#{DOW_SYSTEMD.fetch(start)}..#{DOW_SYSTEMD.fetch(finish)}"
190
+ end
191
+ end
192
+ private_class_method :format_range
193
+
194
+ def self.raise_empty_error(input)
195
+ raise UnsupportedCronError,
196
+ "Unsupported cron #{input.inspect}: day-of-week is empty"
197
+ end
198
+ private_class_method :raise_empty_error
199
+
200
+ def self.raise_invalid_token_error(input)
201
+ raise UnsupportedCronError,
202
+ "Unsupported cron #{input.inspect}: invalid day-of-week token"
203
+ end
204
+ private_class_method :raise_invalid_token_error
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ module CronParser
6
+ # Parses individual cron field expressions (minute, hour, day-of-month, month).
7
+ #
8
+ # Handles:
9
+ # - Wildcards: `*`
10
+ # - Lists: `1,2,3`
11
+ # - Ranges: `1-5`
12
+ # - Steps: `*/2`, `1-10/2`
13
+ # - Named values (for month): `jan`, `feb`, etc.
14
+ module FieldParser
15
+ # Parses a numeric cron field expression.
16
+ #
17
+ # @param str [String] the field value
18
+ # @param range [Range] valid range for values
19
+ # @param field [String] field name for error messages
20
+ # @param input [String] full cron expression for error messages
21
+ # @param pad [Integer] zero-padding width (0 = no padding)
22
+ # @return [String] systemd-compatible expression
23
+ # @raise [Wheneverd::Systemd::UnsupportedCronError]
24
+ def self.parse_numeric(str, range, field:, input:, pad:)
25
+ expr = parse_mapped(str, range, field: field, input: input, names: {})
26
+ return expr if pad <= 0 || expr == "*"
27
+
28
+ pad_expression_numbers(expr, pad: pad)
29
+ end
30
+
31
+ # Parses a cron field expression with optional name mappings.
32
+ #
33
+ # @param str [String] the field value
34
+ # @param range [Range] valid range for values
35
+ # @param field [String] field name for error messages
36
+ # @param input [String] full cron expression for error messages
37
+ # @param names [Hash<String, Integer>] name-to-value mappings (e.g., {"jan" => 1})
38
+ # @return [String] systemd-compatible expression
39
+ # @raise [Wheneverd::Systemd::UnsupportedCronError]
40
+ def self.parse_mapped(str, range, field:, input:, names:)
41
+ raw = str.to_s.strip
42
+ raise_empty_field_error(field, input) if raw.empty?
43
+
44
+ parts = raw.split(",").map(&:strip)
45
+ return "*" if parts.any? { |part| part == "*" }
46
+
47
+ parts.map do |part|
48
+ part_to_systemd(part, range, field: field, input: input, names: names)
49
+ end.join(",")
50
+ end
51
+
52
+ def self.part_to_systemd(part, range, field:, input:, names:)
53
+ base, step_str = part.split("/", 2)
54
+ step = parse_step(step_str, field: field, input: input)
55
+
56
+ base_expr = parse_base_expression(base, range, field: field, input: input, names: names)
57
+
58
+ return base_expr if step.nil?
59
+
60
+ "#{base_expr}/#{step}"
61
+ end
62
+ private_class_method :part_to_systemd
63
+
64
+ def self.parse_base_expression(base, range, field:, input:, names:)
65
+ if base == "*"
66
+ range.begin.to_s
67
+ elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
68
+ parse_range_expression(match[:start], match[:finish], range, field: field, input: input,
69
+ names: names)
70
+ else
71
+ parse_value(base, range, field: field, input: input, names: names).to_s
72
+ end
73
+ end
74
+ private_class_method :parse_base_expression
75
+
76
+ def self.parse_range_expression(start_token, finish_token, range, field:, input:, names:)
77
+ start_value = parse_value(start_token, range, field: field, input: input, names: names)
78
+ finish_value = parse_value(finish_token, range, field: field, input: input, names: names)
79
+
80
+ unless start_value <= finish_value
81
+ raise UnsupportedCronError,
82
+ "Unsupported cron #{input.inspect}: invalid #{field} range"
83
+ end
84
+
85
+ "#{start_value}..#{finish_value}"
86
+ end
87
+ private_class_method :parse_range_expression
88
+
89
+ def self.parse_value(token, range, field:, input:, names:)
90
+ raw = token.to_s.strip
91
+ raise_empty_token_error(field, input) if raw.empty?
92
+
93
+ if /\A\d+\z/.match?(raw)
94
+ return parse_numeric_value(raw, range, field: field,
95
+ input: input)
96
+ end
97
+
98
+ parse_named_value(raw, range, field: field, input: input, names: names)
99
+ end
100
+ private_class_method :parse_value
101
+
102
+ def self.parse_numeric_value(raw, range, field:, input:)
103
+ value = Integer(raw, 10)
104
+ return value if range.cover?(value)
105
+
106
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: #{field} out of range"
107
+ end
108
+ private_class_method :parse_numeric_value
109
+
110
+ def self.parse_named_value(raw, range, field:, input:, names:)
111
+ key = raw.downcase
112
+ if names.key?(key)
113
+ value = names.fetch(key)
114
+ return value if range.cover?(value)
115
+ end
116
+
117
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid #{field} token"
118
+ end
119
+ private_class_method :parse_named_value
120
+
121
+ def self.parse_step(step_str, field:, input:)
122
+ return nil if step_str.nil?
123
+
124
+ parse_positive_int(step_str, field: field, input: input, label: "step")
125
+ end
126
+ private_class_method :parse_step
127
+
128
+ def self.parse_positive_int(str, field:, input:, label:)
129
+ unless /\A\d+\z/.match?(str)
130
+ raise UnsupportedCronError,
131
+ "Unsupported cron #{input.inspect}: #{field} #{label} must be a number"
132
+ end
133
+
134
+ value = Integer(str, 10)
135
+ unless value.positive?
136
+ raise UnsupportedCronError,
137
+ "Unsupported cron #{input.inspect}: #{field} #{label} must be positive"
138
+ end
139
+
140
+ value
141
+ end
142
+ private_class_method :parse_positive_int
143
+
144
+ def self.pad_expression_numbers(expr, pad:)
145
+ expr.gsub(%r{(?<![/\d])\d+}) { |m| m.rjust(pad, "0") }
146
+ end
147
+ private_class_method :pad_expression_numbers
148
+
149
+ def self.raise_empty_field_error(field, input)
150
+ raise UnsupportedCronError,
151
+ "Unsupported cron #{input.inspect}: #{field} is empty"
152
+ end
153
+ private_class_method :raise_empty_field_error
154
+
155
+ def self.raise_empty_token_error(field, input)
156
+ raise UnsupportedCronError,
157
+ "Unsupported cron #{input.inspect}: empty #{field} token"
158
+ end
159
+ private_class_method :raise_empty_token_error
160
+ end
161
+ end
162
+ end
163
+ end