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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Wheneverd
6
+ module Systemd
7
+ # Deletes previously generated unit files for a given identifier.
8
+ class UnitDeleter
9
+ DEFAULT_UNIT_DIR = UnitWriter::DEFAULT_UNIT_DIR
10
+
11
+ # @param identifier [String]
12
+ # @param unit_dir [String]
13
+ # @param dry_run [Boolean] return paths without deleting
14
+ # @return [Array<String>] deleted paths
15
+ def self.delete(identifier:, unit_dir: DEFAULT_UNIT_DIR, dry_run: false)
16
+ dest_dir = File.expand_path(unit_dir.to_s)
17
+ return [] unless Dir.exist?(dest_dir)
18
+
19
+ deleted = deletable_paths(dest_dir, basename_pattern(identifier))
20
+ deleted.each { |path| FileUtils.rm_f(path) unless dry_run }
21
+ deleted
22
+ end
23
+
24
+ def self.deletable_paths(dest_dir, pattern)
25
+ Dir.children(dest_dir).sort.filter_map do |basename|
26
+ next unless pattern.match?(basename)
27
+
28
+ path = File.join(dest_dir, basename)
29
+ next unless File.file?(path)
30
+ next unless generated_marker?(path)
31
+
32
+ path
33
+ end
34
+ end
35
+ private_class_method :deletable_paths
36
+
37
+ def self.basename_pattern(identifier)
38
+ id = sanitize_identifier(identifier)
39
+ /\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
40
+ end
41
+ private_class_method :basename_pattern
42
+
43
+ def self.generated_marker?(path)
44
+ first_line = File.open(path, "r") { |f| f.gets.to_s }
45
+ first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
46
+ end
47
+ private_class_method :generated_marker?
48
+
49
+ def self.sanitize_identifier(identifier)
50
+ raw = identifier.to_s.strip
51
+ raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
52
+
53
+ sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
54
+ if sanitized.empty?
55
+ raise InvalidIdentifierError,
56
+ "identifier must include at least one alphanumeric character"
57
+ end
58
+
59
+ sanitized
60
+ end
61
+ private_class_method :sanitize_identifier
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Lists previously generated unit file paths for a given identifier.
6
+ class UnitLister
7
+ DEFAULT_UNIT_DIR = UnitWriter::DEFAULT_UNIT_DIR
8
+
9
+ # @param identifier [String]
10
+ # @param unit_dir [String]
11
+ # @return [Array<String>] unit file paths
12
+ def self.list(identifier:, unit_dir: DEFAULT_UNIT_DIR)
13
+ dest_dir = File.expand_path(unit_dir.to_s)
14
+ return [] unless Dir.exist?(dest_dir)
15
+
16
+ unit_paths(dest_dir, basename_pattern(identifier))
17
+ end
18
+
19
+ def self.unit_paths(dest_dir, pattern)
20
+ Dir.children(dest_dir).sort.filter_map do |basename|
21
+ next unless pattern.match?(basename)
22
+
23
+ path = File.join(dest_dir, basename)
24
+ next unless File.file?(path)
25
+ next unless generated_marker?(path)
26
+
27
+ path
28
+ end
29
+ end
30
+ private_class_method :unit_paths
31
+
32
+ def self.basename_pattern(identifier)
33
+ id = sanitize_identifier(identifier)
34
+ /\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
35
+ end
36
+ private_class_method :basename_pattern
37
+
38
+ def self.generated_marker?(path)
39
+ first_line = File.open(path, "r") { |f| f.gets.to_s }
40
+ first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
41
+ end
42
+ private_class_method :generated_marker?
43
+
44
+ def self.sanitize_identifier(identifier)
45
+ raw = identifier.to_s.strip
46
+ raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
47
+
48
+ sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
49
+ if sanitized.empty?
50
+ raise InvalidIdentifierError,
51
+ "identifier must include at least one alphanumeric character"
52
+ end
53
+
54
+ sanitized
55
+ end
56
+ private_class_method :sanitize_identifier
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Wheneverd
6
+ module Systemd
7
+ # Computes stable unit IDs for jobs so units keep names across schedule reordering.
8
+ class UnitNamer
9
+ def self.stable_ids_for(schedule)
10
+ signatures = signatures_for(schedule)
11
+ counts_by_signature = signatures.tally
12
+ occurrences_by_signature = Hash.new(0)
13
+
14
+ signatures.map do |sig|
15
+ disambiguate(stable_id_for(sig), sig, counts_by_signature, occurrences_by_signature)
16
+ end
17
+ end
18
+
19
+ def self.signatures_for(schedule)
20
+ schedule.entries.flat_map do |entry|
21
+ entry.jobs.map { |job| signature(entry.trigger, job) }
22
+ end
23
+ end
24
+ private_class_method :signatures_for
25
+
26
+ def self.disambiguate(stable, signature, counts_by_signature, occurrences_by_signature)
27
+ return stable if counts_by_signature.fetch(signature) == 1
28
+
29
+ occurrences_by_signature[signature] += 1
30
+ "#{stable}-#{occurrences_by_signature.fetch(signature)}"
31
+ end
32
+ private_class_method :disambiguate
33
+
34
+ def self.signature(trigger, job)
35
+ [trigger_signature(trigger), job_signature(job)].join("\n")
36
+ end
37
+ private_class_method :signature
38
+
39
+ def self.trigger_signature(trigger)
40
+ case trigger
41
+ when Wheneverd::Trigger::Interval
42
+ "interval:#{trigger.seconds}"
43
+ when Wheneverd::Trigger::Boot
44
+ "boot:#{trigger.seconds}"
45
+ when Wheneverd::Trigger::Calendar
46
+ "calendar:#{trigger.on_calendar.sort.join('|')}"
47
+ else
48
+ raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
49
+ end
50
+ end
51
+ private_class_method :trigger_signature
52
+
53
+ def self.job_signature(job)
54
+ case job
55
+ when Wheneverd::Job::Command
56
+ "command:#{job.command}"
57
+ else
58
+ raise ArgumentError, "Unsupported job type: #{job.class}"
59
+ end
60
+ end
61
+ private_class_method :job_signature
62
+
63
+ def self.stable_id_for(signature)
64
+ Digest::SHA256.hexdigest(signature).slice(0, 12)
65
+ end
66
+ private_class_method :stable_id_for
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module Wheneverd
7
+ module Systemd
8
+ # Writes rendered systemd units to a target directory (defaulting to the user unit dir).
9
+ class UnitWriter
10
+ DEFAULT_UNIT_DIR = File.join(Dir.home, ".config", "systemd", "user").freeze
11
+
12
+ # @param units [Array<Wheneverd::Systemd::Unit>]
13
+ # @param identifier [String, nil] required when pruning
14
+ # @param unit_dir [String]
15
+ # @param dry_run [Boolean] return paths without writing
16
+ # @param prune [Boolean] delete previously generated units for `identifier` not in `units`
17
+ # @return [Array<String>] destination paths
18
+ def self.write(units, unit_dir: DEFAULT_UNIT_DIR, dry_run: false, prune: false,
19
+ identifier: nil)
20
+ validate_units!(units)
21
+ dest_dir = File.expand_path(unit_dir.to_s)
22
+ paths = destination_paths(units, dest_dir)
23
+ return paths if dry_run
24
+
25
+ write_units(units, paths, dest_dir, prune: prune, identifier: identifier)
26
+ paths
27
+ end
28
+
29
+ def self.validate_units!(units)
30
+ raise ArgumentError, "units must be an Array" unless units.is_a?(Array)
31
+ end
32
+ private_class_method :validate_units!
33
+
34
+ def self.destination_paths(units, dest_dir)
35
+ units.map { |unit| File.join(dest_dir, unit.path_basename) }
36
+ end
37
+ private_class_method :destination_paths
38
+
39
+ def self.write_units(units, paths, dest_dir, prune:, identifier:)
40
+ FileUtils.mkdir_p(dest_dir)
41
+ if prune
42
+ prune_stale_units(
43
+ dest_dir,
44
+ identifier: identifier,
45
+ keep_basenames: units.map(&:path_basename)
46
+ )
47
+ end
48
+ write_unit_files(units, paths, dest_dir)
49
+ end
50
+ private_class_method :write_units
51
+
52
+ def self.write_unit_files(units, paths, dest_dir)
53
+ units.each_with_index do |unit, idx|
54
+ atomic_write(paths.fetch(idx), unit.contents.to_s, dir: dest_dir)
55
+ end
56
+ end
57
+ private_class_method :write_unit_files
58
+
59
+ def self.prune_stale_units(dest_dir, identifier:, keep_basenames:)
60
+ raise ArgumentError, "identifier is required when prune is true" if identifier.nil?
61
+
62
+ keep = keep_basenames.to_h { |basename| [basename, true] }
63
+ stale_unit_paths(dest_dir, identifier: identifier, keep: keep).each do |path|
64
+ FileUtils.rm_f(path)
65
+ end
66
+ end
67
+ private_class_method :prune_stale_units
68
+
69
+ def self.stale_unit_paths(dest_dir, identifier:, keep:)
70
+ pattern = basename_pattern(identifier)
71
+ Dir.children(dest_dir).filter_map do |basename|
72
+ next if keep.key?(basename)
73
+
74
+ path = File.join(dest_dir, basename)
75
+ next unless stale_unit_path?(basename, path, pattern)
76
+
77
+ path
78
+ end
79
+ end
80
+ private_class_method :stale_unit_paths
81
+
82
+ def self.stale_unit_path?(basename, path, pattern)
83
+ return false unless pattern.match?(basename)
84
+ return false unless File.file?(path)
85
+
86
+ generated_marker?(path)
87
+ end
88
+ private_class_method :stale_unit_path?
89
+
90
+ def self.basename_pattern(identifier)
91
+ id = sanitize_identifier(identifier)
92
+ /\Awheneverd-#{Regexp.escape(id)}-(?:[0-9a-f]{12}(?:-\d+)?|e\d+-j\d+)\.(service|timer)\z/
93
+ end
94
+ private_class_method :basename_pattern
95
+
96
+ def self.generated_marker?(path)
97
+ first_line = File.open(path, "r") { |f| f.gets.to_s }
98
+ first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
99
+ end
100
+ private_class_method :generated_marker?
101
+
102
+ def self.sanitize_identifier(identifier)
103
+ raw = identifier.to_s.strip
104
+ raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
105
+
106
+ sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
107
+ if sanitized.empty?
108
+ raise InvalidIdentifierError,
109
+ "identifier must include at least one alphanumeric character"
110
+ end
111
+
112
+ sanitized
113
+ end
114
+ private_class_method :sanitize_identifier
115
+
116
+ def self.atomic_write(dest_path, contents, dir:)
117
+ basename = File.basename(dest_path)
118
+ tmp = Tempfile.new([".#{basename}.", ".tmp"], dir)
119
+ tmp.write(contents)
120
+ tmp.flush
121
+ tmp.fsync
122
+ tmp.close
123
+
124
+ File.rename(tmp.path, dest_path)
125
+ ensure
126
+ tmp&.close
127
+ tmp&.unlink
128
+ end
129
+ private_class_method :atomic_write
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Trigger
5
+ # A boot trigger, rendered as `OnBootSec=`.
6
+ class Boot
7
+ # @return [Integer]
8
+ attr_reader :seconds
9
+
10
+ # @param seconds [Integer] seconds after boot (must be positive)
11
+ def initialize(seconds:)
12
+ unless seconds.is_a?(Integer)
13
+ raise ArgumentError, "Boot seconds must be an Integer (got #{seconds.class})"
14
+ end
15
+ raise ArgumentError, "Boot seconds must be positive (got #{seconds})" if seconds <= 0
16
+
17
+ @seconds = seconds
18
+ end
19
+
20
+ # @return [Array<String>] systemd `[Timer]` lines for this trigger
21
+ def systemd_timer_lines
22
+ ["OnBootSec=#{seconds}"]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Trigger
5
+ # A calendar trigger, rendered as one or more `OnCalendar=` lines.
6
+ class Calendar
7
+ # @return [Array<String>] calendar specs (already in `systemd` OnCalendar format)
8
+ attr_reader :on_calendar
9
+
10
+ # @param on_calendar [Array<String>] non-empty calendar specs
11
+ def initialize(on_calendar:)
12
+ unless on_calendar.is_a?(Array) && !on_calendar.empty? &&
13
+ on_calendar.all? { |v| v.is_a?(String) && !v.strip.empty? }
14
+ raise ArgumentError, "on_calendar must be a non-empty Array of non-empty Strings"
15
+ end
16
+
17
+ @on_calendar = on_calendar.map(&:strip)
18
+ end
19
+
20
+ # @return [Array<String>] systemd `[Timer]` lines for this trigger
21
+ def systemd_timer_lines
22
+ on_calendar.map { |spec| "OnCalendar=#{spec}" }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Trigger
5
+ # A monotonic interval trigger for `systemd` timers.
6
+ #
7
+ # We emit both:
8
+ # - `OnActiveSec=` to schedule the first run relative to timer activation.
9
+ # - `OnUnitActiveSec=` to schedule subsequent runs relative to the last run.
10
+ class Interval
11
+ # @return [Integer]
12
+ attr_reader :seconds
13
+
14
+ # @param seconds [Integer] seconds between runs (must be positive)
15
+ def initialize(seconds:)
16
+ unless seconds.is_a?(Integer)
17
+ raise ArgumentError, "Interval seconds must be an Integer (got #{seconds.class})"
18
+ end
19
+ raise ArgumentError, "Interval seconds must be positive (got #{seconds})" if seconds <= 0
20
+
21
+ @seconds = seconds
22
+ end
23
+
24
+ # @return [Array<String>] systemd `[Timer]` lines for this trigger
25
+ def systemd_timer_lines
26
+ ["OnActiveSec=#{seconds}", "OnUnitActiveSec=#{seconds}"]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Gem version.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/wheneverd.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wheneverd/version"
4
+
5
+ module Wheneverd
6
+ # Top-level namespace for `wheneverd`.
7
+ #
8
+ # This gem loads a Ruby schedule DSL (similar to the `whenever` gem), then renders the result into
9
+ # `systemd` timer + service units. The main entrypoints are:
10
+ #
11
+ # - {Wheneverd::DSL::Loader} for evaluating `config/schedule.rb`
12
+ # - {Wheneverd::Systemd::Renderer} for generating unit contents
13
+ # - {Wheneverd::CLI} for the command-line interface
14
+ class Error < StandardError; end
15
+ end
16
+
17
+ require_relative "wheneverd/errors"
18
+ require_relative "wheneverd/duration"
19
+ require_relative "wheneverd/interval"
20
+ require_relative "wheneverd/core_ext/numeric_duration"
21
+ require_relative "wheneverd/job/command"
22
+ require_relative "wheneverd/trigger/interval"
23
+ require_relative "wheneverd/trigger/calendar"
24
+ require_relative "wheneverd/trigger/boot"
25
+ require_relative "wheneverd/entry"
26
+ require_relative "wheneverd/schedule"
27
+ require_relative "wheneverd/dsl/errors"
28
+ require_relative "wheneverd/dsl/at_normalizer"
29
+ require_relative "wheneverd/dsl/period_parser"
30
+ require_relative "wheneverd/dsl/context"
31
+ require_relative "wheneverd/dsl/loader"
32
+ require_relative "wheneverd/systemd/errors"
33
+ require_relative "wheneverd/systemd/time_parser"
34
+ require_relative "wheneverd/systemd/cron_parser"
35
+ require_relative "wheneverd/systemd/calendar_spec"
36
+ require_relative "wheneverd/systemd/unit_namer"
37
+ require_relative "wheneverd/systemd/renderer"
38
+ require_relative "wheneverd/systemd/systemctl"
39
+ require_relative "wheneverd/systemd/unit_writer"
40
+ require_relative "wheneverd/systemd/unit_deleter"
41
+ require_relative "wheneverd/systemd/unit_lister"
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_test_helpers"
5
+
6
+ class CLIActivateSuccessTest < Minitest::Test
7
+ include CLITestHelpers
8
+
9
+ def test_exits_zero
10
+ with_inited_project_dir do
11
+ status, _out, _err, _calls = run_activate_with_capture3_stub
12
+ assert_equal 0, status
13
+ end
14
+ end
15
+
16
+ def test_prints_timer_units
17
+ with_inited_project_dir do
18
+ status, out, err, _calls = run_activate_with_capture3_stub
19
+ assert_cli_success(status, err)
20
+ assert_includes out, expected_timer_basenames.fetch(0)
21
+ end
22
+ end
23
+
24
+ def test_runs_daemon_reload
25
+ with_inited_project_dir do
26
+ status, _out, err, calls = run_activate_with_capture3_stub
27
+ assert_cli_success(status, err)
28
+ assert_systemctl_call(calls, 0, SYSTEMCTL_USER_PREFIX + ["daemon-reload"])
29
+ end
30
+ end
31
+
32
+ def test_runs_enable_now
33
+ with_inited_project_dir do
34
+ status, _out, err, calls = run_activate_with_capture3_stub
35
+ assert_cli_success(status, err)
36
+ assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["enable", "--now"],
37
+ includes: expected_timer_basenames)
38
+ end
39
+ end
40
+ end
41
+
42
+ class CLIActivateScheduleMissingTest < Minitest::Test
43
+ include CLITestHelpers
44
+
45
+ def test_exits_one
46
+ with_project_dir do
47
+ status, _out, _err = run_cli(["activate"])
48
+ assert_equal 1, status
49
+ end
50
+ end
51
+
52
+ def test_prints_error_message
53
+ with_project_dir do
54
+ _status, out, err = run_cli(["activate"])
55
+ assert_equal "", out
56
+ assert_includes err, "Schedule file not found"
57
+ end
58
+ end
59
+ end
60
+
61
+ class CLIActivateEmptyScheduleTest < Minitest::Test
62
+ include CLITestHelpers
63
+
64
+ def test_makes_no_systemctl_calls
65
+ with_project_dir do
66
+ write_empty_schedule
67
+ status, _out, err, calls = run_activate_with_capture3_stub
68
+ assert_cli_success(status, err)
69
+ assert_equal [], calls
70
+ end
71
+ end
72
+
73
+ def test_prints_nothing
74
+ with_project_dir do
75
+ write_empty_schedule
76
+ status, out, err, _calls = run_activate_with_capture3_stub
77
+ assert_cli_success(status, err)
78
+ assert_equal "", out
79
+ end
80
+ end
81
+ end
82
+
83
+ class CLIActivateSystemctlFailureTest < Minitest::Test
84
+ include CLITestHelpers
85
+
86
+ def test_exits_one
87
+ with_inited_project_dir do
88
+ status, _out, _err, _calls = run_activate_with_capture3_stub(exitstatus: 1,
89
+ stderr: "no bus\n")
90
+ assert_equal 1, status
91
+ end
92
+ end
93
+
94
+ def test_prints_systemctl_failed
95
+ with_inited_project_dir do
96
+ _status, _out, err, _calls = run_activate_with_capture3_stub(exitstatus: 1,
97
+ stderr: "no bus\n")
98
+ assert_includes err, "systemctl failed"
99
+ end
100
+ end
101
+
102
+ def test_only_calls_daemon_reload
103
+ with_inited_project_dir do
104
+ _status, _out, _err, calls = run_activate_with_capture3_stub(exitstatus: 1,
105
+ stderr: "no bus\n")
106
+ assert_equal 1, calls.length
107
+ assert_systemctl_call(calls, 0, SYSTEMCTL_USER_PREFIX + ["daemon-reload"])
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_test_helpers"
5
+
6
+ class CLICurrentInstalledUnitsTest < Minitest::Test
7
+ include CLITestHelpers
8
+
9
+ def test_exits_zero
10
+ with_inited_project_dir do |project_dir|
11
+ unit_dir = File.join(project_dir, "tmp_units")
12
+ assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
13
+ status, _out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
14
+ assert_cli_success(status, err)
15
+ end
16
+ end
17
+
18
+ def test_includes_header_for_generated_units
19
+ with_inited_project_dir do |project_dir|
20
+ unit_dir = File.join(project_dir, "tmp_units")
21
+ assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
22
+ status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
23
+ assert_cli_success(status, err)
24
+ assert_includes out, "# #{File.join(unit_dir, expected_timer_basenames.fetch(0))}"
25
+ end
26
+ end
27
+
28
+ def test_includes_generated_marker
29
+ with_inited_project_dir do |project_dir|
30
+ unit_dir = File.join(project_dir, "tmp_units")
31
+ assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
32
+ status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
33
+ assert_cli_success(status, err)
34
+ assert_includes out, Wheneverd::Systemd::Renderer::MARKER_PREFIX
35
+ end
36
+ end
37
+
38
+ def test_excludes_units_without_generated_marker
39
+ with_inited_project_dir do |project_dir|
40
+ unit_dir = File.join(project_dir, "tmp_units")
41
+ assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
42
+ File.write(File.join(unit_dir, "wheneverd-demo-000000000000.timer"), "# not generated\n")
43
+ status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
44
+ assert_cli_success(status, err)
45
+ refute_includes out, "wheneverd-demo-000000000000.timer"
46
+ end
47
+ end
48
+ end
49
+
50
+ class CLICurrentInvalidIdentifierTest < Minitest::Test
51
+ include CLITestHelpers
52
+
53
+ def test_exits_one
54
+ with_project_dir do |project_dir|
55
+ unit_dir = File.join(project_dir, "tmp_units")
56
+ FileUtils.mkdir_p(unit_dir)
57
+ status, _out, _err = run_cli(["current", "--identifier", "!!!", "--unit-dir", unit_dir])
58
+ assert_equal 1, status
59
+ end
60
+ end
61
+
62
+ def test_prints_error_message
63
+ with_project_dir do |project_dir|
64
+ unit_dir = File.join(project_dir, "tmp_units")
65
+ FileUtils.mkdir_p(unit_dir)
66
+ _status, out, err = run_cli(["current", "--identifier", "!!!", "--unit-dir", unit_dir])
67
+ assert_equal "", out
68
+ assert_includes err, "identifier must include at least one alphanumeric character"
69
+ end
70
+ end
71
+ end
72
+
73
+ class CLICurrentEmptyUnitDirTest < Minitest::Test
74
+ include CLITestHelpers
75
+
76
+ def test_prints_nothing_when_unit_dir_missing
77
+ with_project_dir do |project_dir|
78
+ unit_dir = File.join(project_dir, "missing_units")
79
+ status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
80
+ assert_cli_success(status, err)
81
+ assert_equal "", out
82
+ end
83
+ end
84
+
85
+ def test_prints_nothing_when_no_units_installed
86
+ with_project_dir do |project_dir|
87
+ unit_dir = File.join(project_dir, "empty_units")
88
+ FileUtils.mkdir_p(unit_dir)
89
+ status, out, err = run_cli(["current", "--identifier", "demo", "--unit-dir", unit_dir])
90
+ assert_cli_success(status, err)
91
+ assert_equal "", out
92
+ end
93
+ end
94
+ end