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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ # Validates `every(:monday, :tuesday, ...)` symbol lists.
6
+ module CalendarSymbolPeriodList
7
+ # @param periods [Array<Symbol>]
8
+ # @param allowed_symbols [Array<Symbol>]
9
+ # @param path [String]
10
+ # @return [Array<Symbol>] the validated input
11
+ def self.validate(periods, allowed_symbols:, path:)
12
+ validate_array!(periods, path: path)
13
+ validate_symbols!(periods, path: path)
14
+ validate_allowed_symbols!(periods, allowed_symbols: allowed_symbols, path: path)
15
+ periods
16
+ end
17
+
18
+ def self.validate_array!(periods, path:)
19
+ return if periods.is_a?(Array) && !periods.empty?
20
+
21
+ raise InvalidPeriodError.new("every() periods must be a non-empty Array", path: path)
22
+ end
23
+ private_class_method :validate_array!
24
+
25
+ def self.validate_symbols!(periods, path:)
26
+ return if periods.all?(Symbol)
27
+
28
+ raise InvalidPeriodError.new("every() periods must be Symbols", path: path)
29
+ end
30
+ private_class_method :validate_symbols!
31
+
32
+ def self.validate_allowed_symbols!(periods, allowed_symbols:, path:)
33
+ invalid = periods.reject { |sym| allowed_symbols.include?(sym) }.uniq
34
+ return if invalid.empty?
35
+
36
+ unknown = invalid.map(&:inspect).join(", ")
37
+ raise InvalidPeriodError.new("Unknown period symbol(s): #{unknown}", path: path)
38
+ end
39
+ private_class_method :validate_allowed_symbols!
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ # The evaluation context used for schedule files.
6
+ #
7
+ # The schedule file is evaluated via `instance_eval`, so methods defined here become available
8
+ # as the schedule DSL (`every`, `command`).
9
+ class Context
10
+ # @return [String] absolute schedule path
11
+ attr_reader :path
12
+
13
+ # @return [Wheneverd::Schedule] schedule being built during evaluation
14
+ attr_reader :schedule
15
+
16
+ # @param path [String]
17
+ def initialize(path:)
18
+ @path = path
19
+ @schedule = Wheneverd::Schedule.new
20
+ @current_entry = nil
21
+ @period_parser = Wheneverd::DSL::PeriodParser.new(path: path)
22
+ end
23
+
24
+ # Define a scheduled entry and evaluate its jobs block.
25
+ #
26
+ # @param periods [Array<String, Symbol, Wheneverd::Duration, Array<Symbol>>]
27
+ # @param at [String, Array<String>, nil]
28
+ # @param roles [Object] stored but currently not used for filtering
29
+ # @return [Wheneverd::Entry]
30
+ def every(*periods, at: nil, roles: nil, &block)
31
+ raise InvalidPeriodError.new("every() requires a block", path: path) unless block
32
+
33
+ raise InvalidPeriodError.new("every() requires a period", path: path) if periods.empty?
34
+
35
+ period = periods.length == 1 ? periods.first : periods
36
+ trigger = @period_parser.trigger_for(period, at: at)
37
+ entry = Wheneverd::Entry.new(trigger: trigger, roles: roles)
38
+
39
+ schedule.add_entry(entry)
40
+
41
+ with_current_entry(entry) { instance_eval(&block) }
42
+
43
+ entry
44
+ end
45
+
46
+ # Add a oneshot command job to the current `every` entry.
47
+ #
48
+ # @param command_str [String]
49
+ # @return [void]
50
+ def command(command_str)
51
+ unless @current_entry
52
+ raise LoadError.new("command() must be called inside every() block",
53
+ path: path)
54
+ end
55
+
56
+ @current_entry.add_job(Wheneverd::Job::Command.new(command: command_str))
57
+ rescue Wheneverd::InvalidCommandError => e
58
+ raise LoadError.new(e.message, path: path)
59
+ end
60
+
61
+ private
62
+
63
+ def with_current_entry(entry)
64
+ previous_entry = @current_entry
65
+ @current_entry = entry
66
+ yield
67
+ ensure
68
+ @current_entry = previous_entry
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ # Base error class for schedule DSL problems.
6
+ #
7
+ # These errors include the path to the schedule file to make CLI output more actionable.
8
+ class Error < Wheneverd::Error
9
+ # @return [String]
10
+ attr_reader :path
11
+
12
+ # @param message [String]
13
+ # @param path [String]
14
+ def initialize(message, path:)
15
+ super(message)
16
+ @path = path
17
+ end
18
+ end
19
+
20
+ # Raised when a schedule file cannot be evaluated or is invalid.
21
+ class LoadError < Error; end
22
+
23
+ # Raised when an `every(...)` period cannot be parsed or validated.
24
+ class InvalidPeriodError < Error; end
25
+
26
+ # Raised when `at:` times cannot be validated.
27
+ class InvalidAtError < Error; end
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ # Loads a schedule file into a {Wheneverd::Schedule}.
6
+ #
7
+ # This evaluates the file as Ruby in an isolated DSL context, and wraps errors with the
8
+ # schedule path for clearer CLI output.
9
+ #
10
+ # Note that schedules are arbitrary Ruby code. Do not load untrusted schedule files.
11
+ class Loader
12
+ # Load and evaluate a schedule file.
13
+ #
14
+ # @param path [String]
15
+ # @return [Wheneverd::Schedule]
16
+ def self.load_file(path)
17
+ absolute_path = File.expand_path(path.to_s)
18
+ evaluate_file(absolute_path)
19
+ rescue Wheneverd::DSL::Error => e
20
+ raise_with_path(e, absolute_path)
21
+ rescue Wheneverd::Error, StandardError => e
22
+ raise_load_error(e, absolute_path)
23
+ end
24
+
25
+ def self.evaluate_file(absolute_path)
26
+ context = Wheneverd::DSL::Context.new(path: absolute_path)
27
+ source = File.read(absolute_path)
28
+
29
+ context.instance_eval(source, absolute_path, 1)
30
+ context.schedule
31
+ end
32
+ private_class_method :evaluate_file
33
+
34
+ def self.raise_with_path(error, absolute_path)
35
+ wrapped = error.class.new("#{absolute_path}: #{error.message}", path: absolute_path)
36
+ wrapped.set_backtrace(error.backtrace)
37
+ raise wrapped
38
+ end
39
+ private_class_method :raise_with_path
40
+
41
+ def self.raise_load_error(error, absolute_path)
42
+ wrapped = LoadError.new("#{absolute_path}: #{error.message}", path: absolute_path)
43
+ wrapped.set_backtrace(error.backtrace)
44
+ raise wrapped
45
+ end
46
+ private_class_method :raise_load_error
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "calendar_symbol_period_list"
4
+
5
+ module Wheneverd
6
+ module DSL
7
+ # Converts DSL `every(...)` period values into trigger objects.
8
+ #
9
+ # Supported period forms are described in the README.
10
+ #
11
+ # Notes:
12
+ #
13
+ # - Interval strings and {Wheneverd::Duration} values produce monotonic triggers
14
+ # ({Wheneverd::Trigger::Interval}).
15
+ # - Calendar symbol periods and cron strings produce calendar triggers
16
+ # ({Wheneverd::Trigger::Calendar}).
17
+ # - The `at:` option is only valid for calendar triggers, with a convenience exception for
18
+ # `every 1.day, at: ...` which is treated as a daily calendar trigger.
19
+ class PeriodParser
20
+ DAY_SECONDS = 60 * 60 * 24
21
+ REBOOT_NOT_SUPPORTED_MESSAGE =
22
+ "The :reboot period is not supported; use an interval or calendar period instead"
23
+
24
+ CALENDAR_SYMBOLS = %i[
25
+ hour day month year weekday weekend
26
+ monday tuesday wednesday thursday friday saturday sunday
27
+ ].freeze
28
+
29
+ attr_reader :path
30
+
31
+ # @param path [String] schedule path for error reporting
32
+ def initialize(path:)
33
+ @path = path
34
+ end
35
+
36
+ # @param period [String, Symbol, Array<Symbol>, Wheneverd::Duration]
37
+ # @param at [String, Array<String>, nil]
38
+ # @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
39
+ def trigger_for(period, at:)
40
+ at_times = AtNormalizer.normalize(at, path: path)
41
+ trigger_for_period(period, at_times: at_times)
42
+ rescue Wheneverd::InvalidIntervalError => e
43
+ raise InvalidPeriodError.new(e.message, path: path)
44
+ end
45
+
46
+ private
47
+
48
+ def trigger_for_period(period, at_times:)
49
+ return duration_trigger_for(period, at_times: at_times) if period.is_a?(Wheneverd::Duration)
50
+ return array_trigger_for(period, at_times: at_times) if period.is_a?(Array)
51
+ return string_trigger_for(period, at_times: at_times) if period.is_a?(String)
52
+ return symbol_trigger_for(period, at_times: at_times) if period.is_a?(Symbol)
53
+
54
+ raise InvalidPeriodError.new("Unsupported period type: #{period.class}", path: path)
55
+ end
56
+
57
+ def duration_trigger_for(duration, at_times:)
58
+ if at_times.any?
59
+ return daily_calendar_trigger(at_times) if duration.to_i == DAY_SECONDS
60
+
61
+ raise InvalidPeriodError.new("at: is only supported with calendar periods", path: path)
62
+ end
63
+
64
+ Wheneverd::Trigger::Interval.new(seconds: duration.to_i)
65
+ end
66
+
67
+ def daily_calendar_trigger(at_times)
68
+ Wheneverd::Trigger::Calendar.new(on_calendar: build_calendar_specs("day", at_times))
69
+ end
70
+
71
+ def string_trigger_for(str, at_times:)
72
+ period_str = str.strip
73
+
74
+ return interval_trigger(period_str, at_times: at_times) if interval_string?(period_str)
75
+
76
+ return cron_trigger(period_str, at_times: at_times) if cron_string?(period_str)
77
+
78
+ raise InvalidPeriodError.new("Unrecognized period #{period_str.inspect}", path: path)
79
+ end
80
+
81
+ def interval_trigger(period_str, at_times:)
82
+ if at_times.any?
83
+ raise InvalidPeriodError.new("at: is not supported for interval periods", path: path)
84
+ end
85
+
86
+ seconds = Wheneverd::Interval.parse(period_str)
87
+ Wheneverd::Trigger::Interval.new(seconds: seconds)
88
+ end
89
+
90
+ def cron_trigger(period_str, at_times:)
91
+ if at_times.any?
92
+ raise InvalidPeriodError.new("at: is not supported for cron periods", path: path)
93
+ end
94
+
95
+ Wheneverd::Trigger::Calendar.new(on_calendar: ["cron:#{period_str}"])
96
+ end
97
+
98
+ def symbol_trigger_for(sym, at_times:)
99
+ raise InvalidPeriodError.new(REBOOT_NOT_SUPPORTED_MESSAGE, path: path) if sym == :reboot
100
+
101
+ if CALENDAR_SYMBOLS.include?(sym)
102
+ return Wheneverd::Trigger::Calendar.new(
103
+ on_calendar: build_calendar_specs(sym.to_s, at_times)
104
+ )
105
+ end
106
+
107
+ raise InvalidPeriodError.new("Unknown period symbol: #{sym.inspect}", path: path)
108
+ end
109
+
110
+ def array_trigger_for(periods, at_times:)
111
+ bases = CalendarSymbolPeriodList.validate(
112
+ periods,
113
+ allowed_symbols: CALENDAR_SYMBOLS,
114
+ path: path
115
+ ).map(&:to_s)
116
+ specs = bases.flat_map { |base| build_calendar_specs(base, at_times) }.uniq
117
+ Wheneverd::Trigger::Calendar.new(on_calendar: specs)
118
+ end
119
+
120
+ def build_calendar_specs(base, at_times)
121
+ return [base] if at_times.empty?
122
+
123
+ at_times.map { |t| "#{base}@#{t}" }
124
+ end
125
+
126
+ def interval_string?(str)
127
+ /\A-?\d+[smhdw]\z/.match?(str)
128
+ end
129
+
130
+ def cron_string?(str)
131
+ str.split(/\s+/).length == 5
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # A positive duration represented as a whole number of seconds.
5
+ #
6
+ # This type is produced by the `Numeric` helpers from {Wheneverd::CoreExt::NumericDuration}
7
+ # (for example, `5.minutes`), and is used by the DSL period parser.
8
+ class Duration
9
+ # @return [Integer] duration in seconds
10
+ attr_reader :seconds
11
+
12
+ # @param seconds [Integer] duration in seconds (must be positive)
13
+ def initialize(seconds)
14
+ unless seconds.is_a?(Integer)
15
+ raise ArgumentError, "Duration seconds must be an Integer (got #{seconds.class})"
16
+ end
17
+
18
+ raise ArgumentError, "Duration seconds must be positive (got #{seconds})" if seconds <= 0
19
+
20
+ @seconds = seconds
21
+ end
22
+
23
+ def to_i
24
+ seconds
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # A single scheduled unit of work.
5
+ #
6
+ # An entry ties together a trigger (when to run) and one or more jobs (what to run).
7
+ class Entry
8
+ # @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar, Wheneverd::Trigger::Boot]
9
+ attr_reader :trigger, :jobs, :roles
10
+
11
+ # @param trigger [Object] a trigger object describing when to run
12
+ # @param jobs [Array<Object>] job objects (usually {Wheneverd::Job::Command})
13
+ # @param roles [Object] stored but currently not used for filtering
14
+ def initialize(trigger:, jobs: [], roles: nil)
15
+ raise ArgumentError, "trigger is required" if trigger.nil?
16
+
17
+ @trigger = trigger
18
+ @jobs = jobs.dup
19
+ @roles = roles
20
+ end
21
+
22
+ # Append a job to the entry.
23
+ #
24
+ # @param job [Object]
25
+ # @return [Entry] self
26
+ def add_job(job)
27
+ jobs << job
28
+ self
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Raised when {Wheneverd::Interval.parse} cannot parse or validate an interval string.
5
+ class InvalidIntervalError < Error; end
6
+
7
+ # Raised when a {Wheneverd::Job::Command} is created with an invalid command string.
8
+ class InvalidCommandError < Error; end
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Parser for compact interval strings used by the DSL.
5
+ #
6
+ # The supported format is `"<n>s|m|h|d|w"`, for example `"5m"` or `"1h"`.
7
+ module Interval
8
+ MULTIPLIERS = {
9
+ "s" => 1,
10
+ "m" => 60,
11
+ "h" => 60 * 60,
12
+ "d" => 60 * 60 * 24,
13
+ "w" => 60 * 60 * 24 * 7
14
+ }.freeze
15
+
16
+ FORMAT = /\A(?<n>-?\d+)(?<unit>[smhdw])\z/.freeze
17
+
18
+ # Parse an interval string into seconds.
19
+ #
20
+ # @param str [String] interval like `"5m"`
21
+ # @return [Integer] seconds
22
+ # @raise [Wheneverd::InvalidIntervalError] if the input is invalid
23
+ def self.parse(str)
24
+ input = str.to_s.strip
25
+ match = FORMAT.match(input)
26
+ unless match
27
+ raise InvalidIntervalError,
28
+ "Invalid interval #{input.inspect}; expected <n>s|m|h|d|w (example: \"5m\")"
29
+ end
30
+
31
+ n = Integer(match[:n], 10)
32
+ raise InvalidIntervalError, "Interval must be positive (got #{input.inspect})" if n <= 0
33
+
34
+ n * MULTIPLIERS.fetch(match[:unit])
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Job
5
+ # A oneshot command job rendered as `ExecStart=` in a `systemd` service unit.
6
+ #
7
+ # Note that the command is inserted into `ExecStart=` as-is. If you need shell features like
8
+ # pipes, redirects, or environment variable expansion, wrap the command explicitly:
9
+ #
10
+ # @example
11
+ # command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
12
+ class Command
13
+ # @return [String]
14
+ attr_reader :command
15
+
16
+ # @param command [String] non-empty command to run
17
+ def initialize(command:)
18
+ unless command.is_a?(String)
19
+ raise InvalidCommandError, "Command must be a String (got #{command.class})"
20
+ end
21
+
22
+ command_stripped = command.strip
23
+ raise InvalidCommandError, "Command must not be empty" if command_stripped.empty?
24
+
25
+ @command = command_stripped
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # A schedule is an ordered list of {Entry} objects.
5
+ #
6
+ # Schedules are typically created by evaluating a schedule file in the DSL context.
7
+ class Schedule
8
+ # @return [Array<Entry>]
9
+ attr_reader :entries
10
+
11
+ # @param entries [Array<Entry>]
12
+ def initialize(entries: [])
13
+ @entries = entries.dup
14
+ end
15
+
16
+ # Append an entry to the schedule.
17
+ #
18
+ # @param entry [Entry]
19
+ # @return [Schedule] self
20
+ def add_entry(entry)
21
+ entries << entry
22
+ self
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Translates higher-level schedule period specs into systemd `OnCalendar=` values.
6
+ #
7
+ # The DSL produces values like `"day@4:30 am"` or `"cron:0 0 27-31 * *"`, which are normalized
8
+ # here into systemd-friendly calendar expressions.
9
+ module CalendarSpec
10
+ BASE_ALIASES = {
11
+ "hour" => "hourly",
12
+ "day" => "daily",
13
+ "month" => "monthly",
14
+ "year" => "yearly"
15
+ }.freeze
16
+
17
+ PREFIXES = {
18
+ "day" => "*-*-*",
19
+ "weekday" => "Mon..Fri *-*-*",
20
+ "weekend" => "Sat,Sun *-*-*"
21
+ }.freeze
22
+
23
+ WEEKDAYS = {
24
+ "monday" => "Mon",
25
+ "tuesday" => "Tue",
26
+ "wednesday" => "Wed",
27
+ "thursday" => "Thu",
28
+ "friday" => "Fri",
29
+ "saturday" => "Sat",
30
+ "sunday" => "Sun"
31
+ }.freeze
32
+
33
+ # Convert a calendar spec into a systemd `OnCalendar=` value.
34
+ #
35
+ # @param spec [String]
36
+ # @return [String]
37
+ # @raise [Wheneverd::Systemd::InvalidCalendarSpecError]
38
+ def self.to_on_calendar(spec)
39
+ values = to_on_calendar_values(spec)
40
+ return values.fetch(0) if values.length == 1
41
+
42
+ message =
43
+ "Invalid calendar spec: #{spec.to_s.strip.inspect} " \
44
+ "expands to multiple OnCalendar values"
45
+ raise InvalidCalendarSpecError, message
46
+ end
47
+
48
+ # Convert a calendar spec into one or more systemd `OnCalendar=` values.
49
+ #
50
+ # Some inputs (e.g., certain cron expressions) may require multiple `OnCalendar=` entries
51
+ # to preserve semantics.
52
+ #
53
+ # @param spec [String]
54
+ # @return [Array<String>]
55
+ # @raise [Wheneverd::Systemd::InvalidCalendarSpecError]
56
+ def self.to_on_calendar_values(spec)
57
+ input = spec.to_s.strip
58
+ raise InvalidCalendarSpecError, "Invalid calendar spec: empty" if input.empty?
59
+
60
+ return cron_to_on_calendar_values(input) if input.start_with?("cron:")
61
+
62
+ base, at = input.split("@", 2)
63
+ base = base.strip
64
+
65
+ raise InvalidCalendarSpecError, "Invalid calendar spec: #{input.inspect}" if base.empty?
66
+
67
+ [translate_base_with_optional_at(base, at)]
68
+ end
69
+
70
+ def self.translate_base_with_optional_at(base, at)
71
+ return base_to_systemd(base) if at.nil?
72
+
73
+ time = Wheneverd::Systemd::TimeParser.parse(at)
74
+ "#{time_prefix_for_at(base)} #{time}"
75
+ end
76
+ private_class_method :translate_base_with_optional_at
77
+
78
+ def self.base_to_systemd(base)
79
+ return BASE_ALIASES.fetch(base) if BASE_ALIASES.key?(base)
80
+
81
+ "#{time_prefix_for_midnight(base)} 00:00:00"
82
+ end
83
+ private_class_method :base_to_systemd
84
+
85
+ def self.cron_to_on_calendar_values(input)
86
+ cron = input.delete_prefix("cron:")
87
+ Wheneverd::Systemd::CronParser.to_on_calendar_values(cron)
88
+ end
89
+ private_class_method :cron_to_on_calendar_values
90
+
91
+ def self.time_prefix_for_at(base)
92
+ return PREFIXES.fetch(base) if PREFIXES.key?(base)
93
+ return "#{WEEKDAYS.fetch(base)} *-*-*" if WEEKDAYS.key?(base)
94
+
95
+ raise InvalidCalendarSpecError,
96
+ "Invalid calendar spec: #{base.inspect} does not support @time"
97
+ end
98
+ private_class_method :time_prefix_for_at
99
+
100
+ def self.time_prefix_for_midnight(base)
101
+ return PREFIXES.fetch(base) if PREFIXES.key?(base)
102
+ return "#{WEEKDAYS.fetch(base)} *-*-*" if WEEKDAYS.key?(base)
103
+
104
+ raise InvalidCalendarSpecError, "Invalid calendar spec: #{base.inspect}"
105
+ end
106
+ private_class_method :time_prefix_for_midnight
107
+ end
108
+ end
109
+ end