wheneverd 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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +26 -0
  3. data/.rubocop.yml +41 -0
  4. data/.yardopts +8 -0
  5. data/AGENTS.md +42 -0
  6. data/CHANGELOG.md +28 -0
  7. data/FEATURE_SUMMARY.md +38 -0
  8. data/Gemfile +16 -0
  9. data/Gemfile.lock +129 -0
  10. data/LICENSE +21 -0
  11. data/README.md +204 -0
  12. data/Rakefile +196 -0
  13. data/bin/console +8 -0
  14. data/bin/setup +5 -0
  15. data/exe/wheneverd +9 -0
  16. data/lib/wheneverd/cli/activate.rb +19 -0
  17. data/lib/wheneverd/cli/current.rb +22 -0
  18. data/lib/wheneverd/cli/deactivate.rb +19 -0
  19. data/lib/wheneverd/cli/delete.rb +20 -0
  20. data/lib/wheneverd/cli/help.rb +18 -0
  21. data/lib/wheneverd/cli/init.rb +78 -0
  22. data/lib/wheneverd/cli/reload.rb +40 -0
  23. data/lib/wheneverd/cli/show.rb +23 -0
  24. data/lib/wheneverd/cli/write.rb +32 -0
  25. data/lib/wheneverd/cli.rb +87 -0
  26. data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
  27. data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
  28. data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
  29. data/lib/wheneverd/dsl/context.rb +72 -0
  30. data/lib/wheneverd/dsl/errors.rb +29 -0
  31. data/lib/wheneverd/dsl/loader.rb +49 -0
  32. data/lib/wheneverd/dsl/period_parser.rb +135 -0
  33. data/lib/wheneverd/duration.rb +27 -0
  34. data/lib/wheneverd/entry.rb +31 -0
  35. data/lib/wheneverd/errors.rb +9 -0
  36. data/lib/wheneverd/interval.rb +37 -0
  37. data/lib/wheneverd/job/command.rb +29 -0
  38. data/lib/wheneverd/schedule.rb +25 -0
  39. data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
  40. data/lib/wheneverd/systemd/cron_parser.rb +352 -0
  41. data/lib/wheneverd/systemd/errors.rb +23 -0
  42. data/lib/wheneverd/systemd/renderer.rb +153 -0
  43. data/lib/wheneverd/systemd/systemctl.rb +38 -0
  44. data/lib/wheneverd/systemd/time_parser.rb +75 -0
  45. data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
  46. data/lib/wheneverd/systemd/unit_lister.rb +59 -0
  47. data/lib/wheneverd/systemd/unit_namer.rb +69 -0
  48. data/lib/wheneverd/systemd/unit_writer.rb +132 -0
  49. data/lib/wheneverd/trigger/boot.rb +26 -0
  50. data/lib/wheneverd/trigger/calendar.rb +26 -0
  51. data/lib/wheneverd/trigger/interval.rb +30 -0
  52. data/lib/wheneverd/version.rb +6 -0
  53. data/lib/wheneverd.rb +41 -0
  54. data/test/cli_activate_test.rb +110 -0
  55. data/test/cli_current_test.rb +94 -0
  56. data/test/cli_deactivate_test.rb +111 -0
  57. data/test/cli_end_to_end_test.rb +98 -0
  58. data/test/cli_reload_test.rb +132 -0
  59. data/test/cli_systemctl_integration_test.rb +76 -0
  60. data/test/cli_systemd_analyze_test.rb +64 -0
  61. data/test/cli_test.rb +332 -0
  62. data/test/domain_model_test.rb +108 -0
  63. data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
  64. data/test/dsl_loader_test.rb +384 -0
  65. data/test/support/cli_subprocess_test_helpers.rb +38 -0
  66. data/test/support/cli_test_helpers.rb +114 -0
  67. data/test/systemd_calendar_spec_test.rb +45 -0
  68. data/test/systemd_cron_parser_test.rb +114 -0
  69. data/test/systemd_renderer_errors_test.rb +85 -0
  70. data/test/systemd_renderer_test.rb +161 -0
  71. data/test/systemd_systemctl_test.rb +46 -0
  72. data/test/systemd_time_parser_test.rb +25 -0
  73. data/test/systemd_unit_deleter_test.rb +83 -0
  74. data/test/systemd_unit_writer_prune_test.rb +85 -0
  75. data/test/systemd_unit_writer_test.rb +71 -0
  76. data/test/test_helper.rb +34 -0
  77. data/test/wheneverd_test.rb +9 -0
  78. data/wheneverd.gemspec +35 -0
  79. metadata +136 -0
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Converts 5-field cron expressions into systemd `OnCalendar=` specs.
6
+ module CronParser
7
+ # @param cron_5_fields [String]
8
+ # @return [Array<String>] systemd `OnCalendar=` values
9
+ # @raise [Wheneverd::Systemd::UnsupportedCronError]
10
+ def self.to_on_calendar_values(cron_5_fields)
11
+ input = cron_5_fields.to_s.strip
12
+ minute_str, hour_str, dom_str, month_str, dow_str = split_fields(input)
13
+
14
+ minute = parse_numeric_expression(minute_str, 0..59, field: "minute", input: input, pad: 2)
15
+ hour = parse_numeric_expression(hour_str, 0..23, field: "hour", input: input, pad: 2)
16
+ dom = parse_numeric_expression(dom_str, 1..31, field: "day-of-month", input: input, pad: 0)
17
+ month = parse_month_expression(month_str, input: input)
18
+ dow_set = parse_dow_set(dow_str, input: input)
19
+
20
+ time = "#{hour}:#{minute}:00"
21
+ dom_any = dom == "*"
22
+ month_any = month == "*"
23
+
24
+ date = "*-#{month_any ? '*' : month}-#{dom_any ? '*' : dom}"
25
+
26
+ if dow_set.nil? && dom_any
27
+ return ["*-#{month_any ? '*' : month}-* #{time}"]
28
+ end
29
+
30
+ values = []
31
+
32
+ if dow_set
33
+ dow = format_dow_set(dow_set)
34
+ values << "#{dow} *-#{month_any ? '*' : month}-* #{time}"
35
+ end
36
+
37
+ values << "#{date} #{time}" unless dom_any
38
+
39
+ values
40
+ end
41
+
42
+ # @param cron_5_fields [String]
43
+ # @return [String] systemd `OnCalendar=` value (only when cron translates to a single value)
44
+ # @raise [Wheneverd::Systemd::UnsupportedCronError]
45
+ def self.to_on_calendar(cron_5_fields)
46
+ values = to_on_calendar_values(cron_5_fields)
47
+ return values.fetch(0) if values.length == 1
48
+
49
+ message =
50
+ "Unsupported cron #{cron_5_fields.to_s.strip.inspect}: " \
51
+ "requires multiple OnCalendar values"
52
+ raise UnsupportedCronError, message
53
+ end
54
+
55
+ def self.split_fields(input)
56
+ parts = input.split(/\s+/)
57
+ validate_parts_length(parts, input: input)
58
+
59
+ parts
60
+ end
61
+ private_class_method :split_fields
62
+
63
+ def self.validate_parts_length(parts, input:)
64
+ return if parts.length == 5
65
+
66
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: expected 5 fields"
67
+ end
68
+ private_class_method :validate_parts_length
69
+
70
+ MONTH_NAMES = {
71
+ "jan" => 1,
72
+ "feb" => 2,
73
+ "mar" => 3,
74
+ "apr" => 4,
75
+ "may" => 5,
76
+ "jun" => 6,
77
+ "jul" => 7,
78
+ "aug" => 8,
79
+ "sep" => 9,
80
+ "oct" => 10,
81
+ "nov" => 11,
82
+ "dec" => 12
83
+ }.freeze
84
+
85
+ DOW_NAMES = {
86
+ "sun" => 0,
87
+ "mon" => 1,
88
+ "tue" => 2,
89
+ "wed" => 3,
90
+ "thu" => 4,
91
+ "fri" => 5,
92
+ "sat" => 6
93
+ }.freeze
94
+
95
+ DOW_SYSTEMD = {
96
+ 0 => "Sun",
97
+ 1 => "Mon",
98
+ 2 => "Tue",
99
+ 3 => "Wed",
100
+ 4 => "Thu",
101
+ 5 => "Fri",
102
+ 6 => "Sat"
103
+ }.freeze
104
+
105
+ DOW_ORDER = [1, 2, 3, 4, 5, 6, 0].freeze
106
+
107
+ def self.parse_month_expression(month_str, input:)
108
+ parse_mapped_numeric_expression(
109
+ month_str,
110
+ 1..12,
111
+ field: "month",
112
+ input: input,
113
+ names: MONTH_NAMES
114
+ )
115
+ end
116
+ private_class_method :parse_month_expression
117
+
118
+ def self.parse_numeric_expression(str, range, field:, input:, pad:)
119
+ expr = parse_mapped_numeric_expression(str, range, field: field, input: input, names: {})
120
+ return expr if pad <= 0 || expr == "*"
121
+
122
+ pad_expression_numbers(expr, pad: pad)
123
+ end
124
+ private_class_method :parse_numeric_expression
125
+
126
+ def self.parse_mapped_numeric_expression(str, range, field:, input:, names:)
127
+ raw = str.to_s.strip
128
+ if raw.empty?
129
+ raise UnsupportedCronError,
130
+ "Unsupported cron #{input.inspect}: #{field} is empty"
131
+ end
132
+
133
+ parts = raw.split(",").map(&:strip)
134
+ return "*" if parts.any? { |part| part == "*" }
135
+
136
+ parts.map do |part|
137
+ mapped_part_to_systemd(part, range, field: field, input: input, names: names)
138
+ end.join(",")
139
+ end
140
+ private_class_method :parse_mapped_numeric_expression
141
+
142
+ def self.mapped_part_to_systemd(part, range, field:, input:, names:)
143
+ base, step_str = part.split("/", 2)
144
+ step = if step_str.nil?
145
+ nil
146
+ else
147
+ parse_positive_int(step_str, field: field, input: input,
148
+ label: "step")
149
+ end
150
+
151
+ base_expr =
152
+ if base == "*"
153
+ range.begin.to_s
154
+ elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
155
+ start_value = parse_mapped_value(match[:start], range, field: field, input: input,
156
+ names: names)
157
+ finish_value = parse_mapped_value(match[:finish], range, field: field, input: input,
158
+ names: names)
159
+ unless start_value <= finish_value
160
+ raise UnsupportedCronError,
161
+ "Unsupported cron #{input.inspect}: invalid #{field} range"
162
+ end
163
+
164
+ "#{start_value}..#{finish_value}"
165
+ else
166
+ parse_mapped_value(base, range, field: field, input: input, names: names).to_s
167
+ end
168
+
169
+ return base_expr if step.nil?
170
+
171
+ "#{base_expr}/#{step}"
172
+ end
173
+ private_class_method :mapped_part_to_systemd
174
+
175
+ def self.parse_mapped_value(token, range, field:, input:, names:)
176
+ raw = token.to_s.strip
177
+ if raw.empty?
178
+ raise UnsupportedCronError,
179
+ "Unsupported cron #{input.inspect}: empty #{field} token"
180
+ end
181
+
182
+ if /\A\d+\z/.match?(raw)
183
+ value = Integer(raw, 10)
184
+ unless range.cover?(value)
185
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: #{field} out of range"
186
+ end
187
+
188
+ return value
189
+ end
190
+
191
+ key = raw.downcase
192
+ if names.key?(key)
193
+ value = names.fetch(key)
194
+ return value if range.cover?(value)
195
+ end
196
+
197
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid #{field} token"
198
+ end
199
+ private_class_method :parse_mapped_value
200
+
201
+ def self.pad_expression_numbers(expr, pad:)
202
+ expr.gsub(%r{(?<![/\d])\d+}) { |m| m.rjust(pad, "0") }
203
+ end
204
+ private_class_method :pad_expression_numbers
205
+
206
+ def self.parse_positive_int(str, field:, input:, label:)
207
+ unless /\A\d+\z/.match?(str)
208
+ raise UnsupportedCronError,
209
+ "Unsupported cron #{input.inspect}: #{field} #{label} must be a number"
210
+ end
211
+
212
+ value = Integer(str, 10)
213
+ unless value.positive?
214
+ raise UnsupportedCronError,
215
+ "Unsupported cron #{input.inspect}: #{field} #{label} must be positive"
216
+ end
217
+
218
+ value
219
+ end
220
+ private_class_method :parse_positive_int
221
+
222
+ def self.parse_dow_set(dow_str, input:)
223
+ raw = dow_str.to_s.strip
224
+ if raw.empty?
225
+ raise UnsupportedCronError,
226
+ "Unsupported cron #{input.inspect}: day-of-week is empty"
227
+ end
228
+ return nil if raw == "*"
229
+
230
+ present = Array.new(7, false)
231
+
232
+ raw.split(",").map(&:strip).each do |part|
233
+ apply_dow_part(present, part, input: input)
234
+ end
235
+
236
+ days = present.each_index.select { |idx| present[idx] }
237
+ return nil if days.length == 7
238
+
239
+ days
240
+ end
241
+ private_class_method :parse_dow_set
242
+
243
+ def self.apply_dow_part(present, part, input:)
244
+ if part.empty?
245
+ raise UnsupportedCronError,
246
+ "Unsupported cron #{input.inspect}: invalid day-of-week token"
247
+ end
248
+
249
+ base, step_str = part.split("/", 2)
250
+ step = if step_str.nil?
251
+ nil
252
+ else
253
+ parse_positive_int(step_str, field: "day-of-week",
254
+ input: input, label: "step")
255
+ end
256
+
257
+ sequence =
258
+ if base == "*"
259
+ (0..6).to_a
260
+ elsif (match = /\A(?<start>[^-]+)-(?<finish>[^-]+)\z/.match(base))
261
+ dow_range_sequence(match[:start], match[:finish], input: input)
262
+ else
263
+ start_day = parse_dow_value(base, input: input)
264
+ start_day = 0 if start_day == 7
265
+ step ? (start_day..6).to_a : [start_day]
266
+ end
267
+
268
+ if step
269
+ sequence.each_with_index { |day, idx| present[day] = true if (idx % step).zero? }
270
+ else
271
+ sequence.each { |day| present[day] = true }
272
+ end
273
+ end
274
+ private_class_method :apply_dow_part
275
+
276
+ def self.dow_range_sequence(start_token, finish_token, input:)
277
+ start_raw = parse_dow_value(start_token, input: input)
278
+ finish_raw = parse_dow_value(finish_token, input: input)
279
+
280
+ start_day = start_raw == 7 ? 0 : start_raw
281
+ finish_day = finish_raw == 7 ? 0 : finish_raw
282
+
283
+ return (0..6).to_a if start_raw.zero? && finish_raw == 7
284
+
285
+ return (start_day..6).to_a + [0] if finish_raw == 7 && !start_raw.zero?
286
+
287
+ return (start_day..finish_day).to_a if start_day <= finish_day
288
+
289
+ (start_day..6).to_a + (0..finish_day).to_a
290
+ end
291
+ private_class_method :dow_range_sequence
292
+
293
+ def self.parse_dow_value(token, input:)
294
+ raw = token.to_s.strip
295
+ if raw.empty?
296
+ raise UnsupportedCronError,
297
+ "Unsupported cron #{input.inspect}: invalid day-of-week token"
298
+ end
299
+
300
+ if /\A\d+\z/.match?(raw)
301
+ value = Integer(raw, 10)
302
+ return value if value.between?(0, 7)
303
+
304
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: day-of-week out of range"
305
+ end
306
+
307
+ unless /\A[A-Za-z]+\z/.match?(raw)
308
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid day-of-week token"
309
+ end
310
+
311
+ key = raw.downcase
312
+ key = key[0, 3] if key.length > 3
313
+ return DOW_NAMES.fetch(key) if DOW_NAMES.key?(key)
314
+
315
+ raise UnsupportedCronError, "Unsupported cron #{input.inspect}: invalid day-of-week token"
316
+ end
317
+ private_class_method :parse_dow_value
318
+
319
+ def self.format_dow_set(days)
320
+ present = Array.new(7, false)
321
+ days.each { |day| present[day] = true }
322
+
323
+ tokens = []
324
+ i = 0
325
+ while i < DOW_ORDER.length
326
+ day = DOW_ORDER.fetch(i)
327
+ unless present.fetch(day)
328
+ i += 1
329
+ next
330
+ end
331
+
332
+ start = day
333
+ j = i
334
+ j += 1 while (j + 1) < DOW_ORDER.length && present.fetch(DOW_ORDER.fetch(j + 1))
335
+ finish = DOW_ORDER.fetch(j)
336
+
337
+ token =
338
+ if start == finish
339
+ DOW_SYSTEMD.fetch(start)
340
+ else
341
+ "#{DOW_SYSTEMD.fetch(start)}..#{DOW_SYSTEMD.fetch(finish)}"
342
+ end
343
+ tokens << token
344
+ i = j + 1
345
+ end
346
+
347
+ tokens.join(",")
348
+ end
349
+ private_class_method :format_dow_set
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Base error class for systemd rendering and system interactions.
6
+ class Error < Wheneverd::Error; end
7
+
8
+ # Raised when an identifier is invalid for use in unit file names.
9
+ class InvalidIdentifierError < Error; end
10
+
11
+ # Raised when a human-friendly time string cannot be parsed.
12
+ class InvalidTimeError < Error; end
13
+
14
+ # Raised when a calendar spec cannot be mapped to a valid `OnCalendar=` value.
15
+ class InvalidCalendarSpecError < Error; end
16
+
17
+ # Raised when the provided cron expression is outside the supported subset.
18
+ class UnsupportedCronError < Error; end
19
+
20
+ # Raised when a `systemctl` invocation fails.
21
+ class SystemctlError < Error; end
22
+ end
23
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # A rendered systemd unit (service or timer).
6
+ #
7
+ # @!attribute [r] path_basename
8
+ # @return [String] file name, e.g. `"wheneverd-myapp-0123abcd4567.timer"`
9
+ # @!attribute [r] kind
10
+ # @return [Symbol] `:service` or `:timer`
11
+ # @!attribute [r] contents
12
+ # @return [String] unit file contents
13
+ Unit = Struct.new(:path_basename, :kind, :contents, keyword_init: true)
14
+
15
+ # Renders a {Wheneverd::Schedule} into systemd units.
16
+ #
17
+ # Each job in each entry becomes a `*.service` plus matching `*.timer`.
18
+ #
19
+ # Unit file names are generated as:
20
+ #
21
+ # `wheneverd-<identifier>-<stable_id>.{service,timer}`
22
+ #
23
+ # The stable ID is derived from the job's trigger + command so reordering schedule blocks
24
+ # does not rename units. If there are duplicate jobs with identical trigger + command, a stable
25
+ # `-N` suffix is appended to avoid collisions.
26
+ #
27
+ # The identifier is sanitized for use in filenames (invalid characters become `-`).
28
+ class Renderer
29
+ MARKER_PREFIX = "# Generated by wheneverd (wheneverd)"
30
+ SERVICE_SECTION = ["[Service]", "Type=oneshot"].freeze
31
+ TIMER_SUFFIX = ["Persistent=true", "", "[Install]", "WantedBy=timers.target", ""].freeze
32
+
33
+ # Render a schedule to `systemd` units.
34
+ #
35
+ # @param schedule [Wheneverd::Schedule]
36
+ # @param identifier [String] used to namespace unit file names
37
+ # @return [Array<Unit>]
38
+ def self.render(schedule, identifier:)
39
+ validate_schedule(schedule)
40
+ id = sanitize_identifier(identifier)
41
+ render_schedule(schedule, id)
42
+ end
43
+
44
+ def self.validate_schedule(schedule)
45
+ return if schedule.is_a?(Wheneverd::Schedule)
46
+
47
+ raise ArgumentError, "schedule must be a Wheneverd::Schedule (got #{schedule.class})"
48
+ end
49
+ private_class_method :validate_schedule
50
+
51
+ def self.render_schedule(schedule, id)
52
+ stable_ids = Wheneverd::Systemd::UnitNamer.stable_ids_for(schedule)
53
+ stable_id_index = 0
54
+
55
+ schedule.entries.flat_map do |entry|
56
+ entry.jobs.flat_map do |job|
57
+ stable_id = stable_ids.fetch(stable_id_index)
58
+ stable_id_index += 1
59
+ base = "wheneverd-#{id}-#{stable_id}"
60
+ render_job(base, entry.trigger, job)
61
+ end
62
+ end
63
+ end
64
+ private_class_method :render_schedule
65
+
66
+ def self.render_job(base, trigger, job)
67
+ service = build_service_unit("#{base}.service", job)
68
+ timer = build_timer_unit("#{base}.timer", trigger)
69
+ [service, timer]
70
+ end
71
+ private_class_method :render_job
72
+
73
+ def self.build_service_unit(path_basename, job)
74
+ Unit.new(
75
+ path_basename: path_basename,
76
+ kind: :service,
77
+ contents: service_contents(path_basename, job.command)
78
+ )
79
+ end
80
+ private_class_method :build_service_unit
81
+
82
+ def self.build_timer_unit(path_basename, trigger)
83
+ timer_lines = timer_lines_for(trigger)
84
+
85
+ Unit.new(
86
+ path_basename: path_basename,
87
+ kind: :timer,
88
+ contents: timer_contents(path_basename, timer_lines)
89
+ )
90
+ end
91
+ private_class_method :build_timer_unit
92
+
93
+ def self.timer_lines_for(trigger)
94
+ case trigger
95
+ when Wheneverd::Trigger::Interval
96
+ ["OnActiveSec=#{trigger.seconds}", "OnUnitActiveSec=#{trigger.seconds}"]
97
+ when Wheneverd::Trigger::Boot
98
+ ["OnBootSec=#{trigger.seconds}"]
99
+ when Wheneverd::Trigger::Calendar then calendar_timer_lines(trigger)
100
+ else raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
101
+ end
102
+ end
103
+ private_class_method :timer_lines_for
104
+
105
+ def self.calendar_timer_lines(trigger)
106
+ trigger.on_calendar.flat_map do |spec|
107
+ CalendarSpec.to_on_calendar_values(spec).map { |value| "OnCalendar=#{value}" }
108
+ end
109
+ end
110
+ private_class_method :calendar_timer_lines
111
+
112
+ def self.service_contents(path_basename, command)
113
+ ([
114
+ marker,
115
+ "[Unit]",
116
+ "Description=wheneverd job #{path_basename}",
117
+ ""
118
+ ] + SERVICE_SECTION + ["ExecStart=#{command}", ""]).join("\n")
119
+ end
120
+ private_class_method :service_contents
121
+
122
+ def self.timer_contents(path_basename, timer_lines)
123
+ ([
124
+ marker,
125
+ "[Unit]",
126
+ "Description=wheneverd timer #{path_basename}",
127
+ "",
128
+ "[Timer]"
129
+ ] + timer_lines + TIMER_SUFFIX).join("\n")
130
+ end
131
+ private_class_method :timer_contents
132
+
133
+ def self.marker
134
+ "#{MARKER_PREFIX} #{Wheneverd::VERSION}; do not edit."
135
+ end
136
+ private_class_method :marker
137
+
138
+ def self.sanitize_identifier(identifier)
139
+ raw = identifier.to_s.strip
140
+ raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
141
+
142
+ sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
143
+ if sanitized.empty?
144
+ raise InvalidIdentifierError,
145
+ "identifier must include at least one alphanumeric character"
146
+ end
147
+
148
+ sanitized
149
+ end
150
+ private_class_method :sanitize_identifier
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Wheneverd
6
+ module Systemd
7
+ # Thin wrapper around `systemctl` that raises on non-zero exit status.
8
+ class Systemctl
9
+ # Run `systemctl` and return stdout/stderr.
10
+ #
11
+ # @param args [Array<String>]
12
+ # @param user [Boolean] use `--user` (default: true)
13
+ # @return [Array(String, String)] stdout and stderr
14
+ # @raise [Wheneverd::Systemd::SystemctlError]
15
+ def self.run(*args, user: true)
16
+ cmd = ["systemctl"]
17
+ cmd << "--user" if user
18
+ cmd << "--no-pager"
19
+ cmd.concat(args.flatten.map(&:to_s))
20
+
21
+ stdout, stderr, status = Open3.capture3(*cmd)
22
+ raise SystemctlError, format_error(cmd, status, stdout, stderr) unless status.success?
23
+
24
+ [stdout, stderr]
25
+ end
26
+
27
+ def self.format_error(cmd, status, stdout, stderr)
28
+ details = []
29
+ details << "command: #{cmd.join(' ')}"
30
+ details << "status: #{status.exitstatus}"
31
+ details << "stdout: #{stdout.strip}" unless stdout.to_s.strip.empty?
32
+ details << "stderr: #{stderr.strip}" unless stderr.to_s.strip.empty?
33
+ "systemctl failed (#{details.join(', ')})"
34
+ end
35
+ private_class_method :format_error
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Parses human-friendly times into `HH:MM:SS` for systemd `OnCalendar=` specs.
6
+ module TimeParser
7
+ # @param str [String]
8
+ # @return [String] time in `HH:MM:SS` format
9
+ # @raise [Wheneverd::Systemd::InvalidTimeError]
10
+ def self.parse(str)
11
+ input = str.to_s.strip
12
+ raise InvalidTimeError, "Invalid time: empty" if input.empty?
13
+
14
+ if (match = /\A(?<h>\d{1,2}):(?<m>\d{2})(?::(?<s>\d{2}))?\z/.match(input))
15
+ return parse_24h(match)
16
+ end
17
+
18
+ if (match = /\A(?<h>\d{1,2})(?::(?<m>\d{2}))?\s*(?<ampm>am|pm)\z/i.match(input))
19
+ return parse_12h(match)
20
+ end
21
+
22
+ raise InvalidTimeError, "Invalid time: #{input.inspect}"
23
+ end
24
+
25
+ def self.parse_24h(match)
26
+ hour = Integer(match[:h], 10)
27
+ minute = Integer(match[:m], 10)
28
+ second = match[:s] ? Integer(match[:s], 10) : 0
29
+
30
+ validate_parts(hour, minute, second)
31
+
32
+ format("%<hour>02d:%<minute>02d:%<second>02d", hour: hour, minute: minute, second: second)
33
+ end
34
+ private_class_method :parse_24h
35
+
36
+ def self.parse_12h(match)
37
+ hour12 = Integer(match[:h], 10)
38
+ minute = match[:m] ? Integer(match[:m], 10) : 0
39
+ second = 0
40
+
41
+ unless hour12.between?(1, 12)
42
+ raise InvalidTimeError, "Invalid time hour: #{hour12} (expected 1..12)"
43
+ end
44
+
45
+ validate_parts(0, minute, second)
46
+
47
+ hour24 = hour24_from_12h(hour12, match[:ampm].downcase)
48
+
49
+ format("%<hour>02d:%<minute>02d:%<second>02d", hour: hour24, minute: minute, second: second)
50
+ end
51
+ private_class_method :parse_12h
52
+
53
+ def self.hour24_from_12h(hour12, ampm)
54
+ hour24 = hour12 % 12
55
+ hour24 += 12 if ampm == "pm"
56
+ hour24
57
+ end
58
+ private_class_method :hour24_from_12h
59
+
60
+ def self.validate_parts(hour, minute, second)
61
+ validate_part("hour", hour, 0..23)
62
+ validate_part("minute", minute, 0..59)
63
+ validate_part("second", second, 0..59)
64
+ end
65
+ private_class_method :validate_parts
66
+
67
+ def self.validate_part(name, value, range)
68
+ return if range.cover?(value)
69
+
70
+ raise InvalidTimeError, "Invalid time #{name}: #{value} (expected #{range})"
71
+ end
72
+ private_class_method :validate_part
73
+ end
74
+ end
75
+ end