wheneverd 0.2.1 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/FEATURE_SUMMARY.md +10 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +70 -5
  6. data/lib/wheneverd/cli/diff.rb +98 -0
  7. data/lib/wheneverd/cli/init.rb +5 -1
  8. data/lib/wheneverd/cli/status.rb +37 -0
  9. data/lib/wheneverd/cli/validate.rb +50 -0
  10. data/lib/wheneverd/cli.rb +6 -0
  11. data/lib/wheneverd/dsl/context.rb +51 -8
  12. data/lib/wheneverd/dsl/period_parser.rb +8 -107
  13. data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
  14. data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
  15. data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
  16. data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
  17. data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
  18. data/lib/wheneverd/dsl/period_strategy.rb +43 -0
  19. data/lib/wheneverd/duration.rb +1 -7
  20. data/lib/wheneverd/errors.rb +3 -0
  21. data/lib/wheneverd/interval.rb +22 -7
  22. data/lib/wheneverd/job/command.rb +88 -8
  23. data/lib/wheneverd/systemd/analyze.rb +56 -0
  24. data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
  25. data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
  26. data/lib/wheneverd/systemd/cron_parser.rb +56 -303
  27. data/lib/wheneverd/systemd/errors.rb +3 -0
  28. data/lib/wheneverd/systemd/renderer.rb +6 -64
  29. data/lib/wheneverd/systemd/unit_content_builder.rb +76 -0
  30. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  31. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  32. data/lib/wheneverd/systemd/unit_namer.rb +6 -14
  33. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  34. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  35. data/lib/wheneverd/trigger/base.rb +22 -0
  36. data/lib/wheneverd/trigger/boot.rb +8 -6
  37. data/lib/wheneverd/trigger/calendar.rb +7 -0
  38. data/lib/wheneverd/trigger/interval.rb +8 -6
  39. data/lib/wheneverd/validation.rb +89 -0
  40. data/lib/wheneverd/version.rb +1 -1
  41. data/lib/wheneverd.rb +5 -1
  42. data/test/cli_diff_test.rb +118 -0
  43. data/test/cli_init_test.rb +49 -0
  44. data/test/cli_status_test.rb +76 -0
  45. data/test/cli_validate_test.rb +81 -0
  46. data/test/domain_model_test.rb +106 -1
  47. data/test/dsl_context_shell_test.rb +23 -0
  48. data/test/dsl_loader_test.rb +23 -0
  49. data/test/job_command_test.rb +29 -0
  50. data/test/systemd_analyze_test.rb +55 -0
  51. data/test/systemd_cron_parser_test.rb +41 -25
  52. data/test/systemd_renderer_errors_test.rb +1 -1
  53. data/test/systemd_renderer_test.rb +6 -0
  54. metadata +25 -2
@@ -37,26 +37,18 @@ module Wheneverd
37
37
  private_class_method :signature
38
38
 
39
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
40
+ unless trigger.respond_to?(:signature)
48
41
  raise ArgumentError, "Unsupported trigger type: #{trigger.class}"
49
42
  end
43
+
44
+ trigger.signature
50
45
  end
51
46
  private_class_method :trigger_signature
52
47
 
53
48
  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
49
+ raise ArgumentError, "Unsupported job type: #{job.class}" unless job.respond_to?(:signature)
50
+
51
+ job.signature
60
52
  end
61
53
  private_class_method :job_signature
62
54
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Systemd
5
+ # Shared utilities for unit file path operations.
6
+ #
7
+ # These methods are used by {UnitWriter}, {UnitDeleter}, {UnitLister}, and {Renderer}
8
+ # to ensure consistent identifier sanitization, path pattern matching, and marker detection.
9
+ module UnitPathUtils
10
+ # Sanitizes an identifier for use in unit file names.
11
+ #
12
+ # Invalid characters are replaced with `-`, and consecutive/leading/trailing dashes
13
+ # are collapsed or removed.
14
+ #
15
+ # @param identifier [String]
16
+ # @return [String]
17
+ # @raise [InvalidIdentifierError] if the identifier is empty or contains no alphanumeric chars
18
+ def self.sanitize_identifier(identifier)
19
+ raw = identifier.to_s.strip
20
+ raise InvalidIdentifierError, "identifier must not be empty" if raw.empty?
21
+
22
+ sanitized = raw.gsub(/[^A-Za-z0-9_-]/, "-").gsub(/-+/, "-").gsub(/\A-|-+\z/, "")
23
+ if sanitized.empty?
24
+ raise InvalidIdentifierError,
25
+ "identifier must include at least one alphanumeric character"
26
+ end
27
+
28
+ sanitized
29
+ end
30
+
31
+ # Returns a regex pattern matching unit basenames for the given identifier.
32
+ #
33
+ # Matches both new-style (SHA-based) and legacy (eN-jN) unit names.
34
+ #
35
+ # @param identifier [String]
36
+ # @return [Regexp]
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
+
42
+ # Checks if a unit file was generated by wheneverd.
43
+ #
44
+ # Reads the first line of the file and checks for the marker prefix.
45
+ #
46
+ # @param path [String] path to the unit file
47
+ # @return [Boolean]
48
+ def self.generated_marker?(path)
49
+ first_line = File.open(path, "r") { |f| f.gets.to_s }
50
+ first_line.start_with?(Wheneverd::Systemd::Renderer::MARKER_PREFIX)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -67,7 +67,7 @@ module Wheneverd
67
67
  private_class_method :prune_stale_units
68
68
 
69
69
  def self.stale_unit_paths(dest_dir, identifier:, keep:)
70
- pattern = basename_pattern(identifier)
70
+ pattern = UnitPathUtils.basename_pattern(identifier)
71
71
  Dir.children(dest_dir).filter_map do |basename|
72
72
  next if keep.key?(basename)
73
73
 
@@ -83,36 +83,10 @@ module Wheneverd
83
83
  return false unless pattern.match?(basename)
84
84
  return false unless File.file?(path)
85
85
 
86
- generated_marker?(path)
86
+ UnitPathUtils.generated_marker?(path)
87
87
  end
88
88
  private_class_method :stale_unit_path?
89
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
90
  def self.atomic_write(dest_path, contents, dir:)
117
91
  basename = File.basename(dest_path)
118
92
  tmp = Tempfile.new([".#{basename}.", ".tmp"], dir)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module Trigger
5
+ # Base module for trigger types.
6
+ #
7
+ # All trigger types must implement:
8
+ # - `#systemd_timer_lines` - returns Array<String> of systemd [Timer] lines
9
+ # - `#signature` - returns a String signature for stable unit naming
10
+ module Base
11
+ # @return [Array<String>] systemd `[Timer]` lines for this trigger
12
+ def systemd_timer_lines
13
+ raise NotImplementedError, "#{self.class} must implement #systemd_timer_lines"
14
+ end
15
+
16
+ # @return [String] stable signature for unit naming
17
+ def signature
18
+ raise NotImplementedError, "#{self.class} must implement #signature"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -4,23 +4,25 @@ module Wheneverd
4
4
  module Trigger
5
5
  # A boot trigger, rendered as `OnBootSec=`.
6
6
  class Boot
7
+ include Base
8
+
7
9
  # @return [Integer]
8
10
  attr_reader :seconds
9
11
 
10
12
  # @param seconds [Integer] seconds after boot (must be positive)
11
13
  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
14
+ @seconds = Validation.positive_integer(seconds, name: "Boot seconds")
18
15
  end
19
16
 
20
17
  # @return [Array<String>] systemd `[Timer]` lines for this trigger
21
18
  def systemd_timer_lines
22
19
  ["OnBootSec=#{seconds}"]
23
20
  end
21
+
22
+ # @return [String] stable signature for unit naming
23
+ def signature
24
+ "boot:#{seconds}"
25
+ end
24
26
  end
25
27
  end
26
28
  end
@@ -4,6 +4,8 @@ module Wheneverd
4
4
  module Trigger
5
5
  # A calendar trigger, rendered as one or more `OnCalendar=` lines.
6
6
  class Calendar
7
+ include Base
8
+
7
9
  # @return [Array<String>] calendar specs (already in `systemd` OnCalendar format)
8
10
  attr_reader :on_calendar
9
11
 
@@ -21,6 +23,11 @@ module Wheneverd
21
23
  def systemd_timer_lines
22
24
  on_calendar.map { |spec| "OnCalendar=#{spec}" }
23
25
  end
26
+
27
+ # @return [String] stable signature for unit naming
28
+ def signature
29
+ "calendar:#{on_calendar.sort.join('|')}"
30
+ end
24
31
  end
25
32
  end
26
33
  end
@@ -8,23 +8,25 @@ module Wheneverd
8
8
  # - `OnActiveSec=` to schedule the first run relative to timer activation.
9
9
  # - `OnUnitActiveSec=` to schedule subsequent runs relative to the last run.
10
10
  class Interval
11
+ include Base
12
+
11
13
  # @return [Integer]
12
14
  attr_reader :seconds
13
15
 
14
16
  # @param seconds [Integer] seconds between runs (must be positive)
15
17
  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
18
+ @seconds = Validation.positive_integer(seconds, name: "Interval seconds")
22
19
  end
23
20
 
24
21
  # @return [Array<String>] systemd `[Timer]` lines for this trigger
25
22
  def systemd_timer_lines
26
23
  ["OnActiveSec=#{seconds}", "OnUnitActiveSec=#{seconds}"]
27
24
  end
25
+
26
+ # @return [String] stable signature for unit naming
27
+ def signature
28
+ "interval:#{seconds}"
29
+ end
28
30
  end
29
31
  end
30
32
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Common validation utilities for consistent error handling across the codebase.
5
+ #
6
+ # These validators follow a consistent pattern:
7
+ # - Return the validated value if valid
8
+ # - Raise an appropriate error with a descriptive message if invalid
9
+ #
10
+ # @example Type validation
11
+ # Validation.type(value, Integer, name: "seconds")
12
+ # # => value or raises ArgumentError
13
+ #
14
+ # @example Positive integer validation
15
+ # Validation.positive_integer(value, name: "seconds")
16
+ # # => value or raises ArgumentError
17
+ module Validation
18
+ # Validate that a value is of the expected type.
19
+ #
20
+ # @param value [Object] the value to validate
21
+ # @param expected_type [Class, Module] the expected type
22
+ # @param name [String] the parameter name for error messages
23
+ # @return [Object] the validated value
24
+ # @raise [ArgumentError] if the value is not of the expected type
25
+ def self.type(value, expected_type, name:)
26
+ return value if value.is_a?(expected_type)
27
+
28
+ raise ArgumentError,
29
+ "#{name} must be #{expected_type_name(expected_type)} (got #{value.class})"
30
+ end
31
+
32
+ # Validate that a value is a positive integer.
33
+ #
34
+ # @param value [Object] the value to validate
35
+ # @param name [String] the parameter name for error messages
36
+ # @return [Integer] the validated value
37
+ # @raise [ArgumentError] if the value is not a positive integer
38
+ def self.positive_integer(value, name:)
39
+ type(value, Integer, name: name)
40
+ return value if value.positive?
41
+
42
+ raise ArgumentError, "#{name} must be positive (got #{value})"
43
+ end
44
+
45
+ # Validate that a string is non-empty after stripping whitespace.
46
+ #
47
+ # @param value [String] the value to validate
48
+ # @param name [String] the parameter name for error messages
49
+ # @return [String] the stripped value
50
+ # @raise [ArgumentError] if the value is empty after stripping
51
+ def self.non_empty_string(value, name:)
52
+ stripped = value.to_s.strip
53
+ return stripped unless stripped.empty?
54
+
55
+ raise ArgumentError, "#{name} must not be empty"
56
+ end
57
+
58
+ # Validate that an array is non-empty.
59
+ #
60
+ # @param value [Array] the value to validate
61
+ # @param name [String] the parameter name for error messages
62
+ # @return [Array] the validated value
63
+ # @raise [ArgumentError] if the array is empty
64
+ def self.non_empty_array(value, name:)
65
+ type(value, Array, name: name)
66
+ return value unless value.empty?
67
+
68
+ raise ArgumentError, "#{name} must not be empty"
69
+ end
70
+
71
+ # Validate that a value is within a range.
72
+ #
73
+ # @param value [Comparable] the value to validate
74
+ # @param range [Range] the valid range
75
+ # @param name [String] the parameter name for error messages
76
+ # @return [Comparable] the validated value
77
+ # @raise [ArgumentError] if the value is outside the range
78
+ def self.in_range(value, range, name:)
79
+ return value if range.cover?(value)
80
+
81
+ raise ArgumentError, "#{name} must be in #{range} (got #{value})"
82
+ end
83
+
84
+ def self.expected_type_name(expected_type)
85
+ "a #{expected_type}"
86
+ end
87
+ private_class_method :expected_type_name
88
+ end
89
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Wheneverd
4
4
  # Gem version.
5
- VERSION = "0.2.1"
5
+ VERSION = "0.4.0"
6
6
  end
data/lib/wheneverd.rb CHANGED
@@ -11,14 +11,15 @@ module Wheneverd
11
11
  # - {Wheneverd::DSL::Loader} for evaluating `config/schedule.rb`
12
12
  # - {Wheneverd::Systemd::Renderer} for generating unit contents
13
13
  # - {Wheneverd::CLI} for the command-line interface
14
- class Error < StandardError; end
15
14
  end
16
15
 
17
16
  require_relative "wheneverd/errors"
17
+ require_relative "wheneverd/validation"
18
18
  require_relative "wheneverd/duration"
19
19
  require_relative "wheneverd/interval"
20
20
  require_relative "wheneverd/core_ext/numeric_duration"
21
21
  require_relative "wheneverd/job/command"
22
+ require_relative "wheneverd/trigger/base"
22
23
  require_relative "wheneverd/trigger/interval"
23
24
  require_relative "wheneverd/trigger/calendar"
24
25
  require_relative "wheneverd/trigger/boot"
@@ -30,11 +31,14 @@ require_relative "wheneverd/dsl/period_parser"
30
31
  require_relative "wheneverd/dsl/context"
31
32
  require_relative "wheneverd/dsl/loader"
32
33
  require_relative "wheneverd/systemd/errors"
34
+ require_relative "wheneverd/systemd/unit_path_utils"
33
35
  require_relative "wheneverd/systemd/time_parser"
34
36
  require_relative "wheneverd/systemd/cron_parser"
35
37
  require_relative "wheneverd/systemd/calendar_spec"
36
38
  require_relative "wheneverd/systemd/unit_namer"
39
+ require_relative "wheneverd/systemd/unit_content_builder"
37
40
  require_relative "wheneverd/systemd/renderer"
41
+ require_relative "wheneverd/systemd/analyze"
38
42
  require_relative "wheneverd/systemd/systemctl"
39
43
  require_relative "wheneverd/systemd/loginctl"
40
44
  require_relative "wheneverd/systemd/unit_writer"
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_test_helpers"
5
+
6
+ class CLIDiffTest < Minitest::Test
7
+ include CLITestHelpers
8
+
9
+ def test_exits_one_and_shows_added_diff_when_units_are_not_installed
10
+ with_inited_unit_dir("missing_units") do |unit_dir|
11
+ timer = first_timer
12
+ status, out, err = run_diff(unit_dir)
13
+ assert_diff_added(status, out, err, unit_dir, timer)
14
+ end
15
+ end
16
+
17
+ def test_exits_zero_when_no_differences
18
+ with_installed_units do |unit_dir|
19
+ status, out, err = run_diff(unit_dir)
20
+ assert_equal 0, status
21
+ assert_equal "", err
22
+ assert_equal "", out
23
+ end
24
+ end
25
+
26
+ def test_exits_one_and_shows_removed_lines_when_installed_unit_was_modified
27
+ with_installed_units do |unit_dir|
28
+ timer = first_timer
29
+ File.open(File.join(unit_dir, timer), "a") { |f| f.puts "# local edit" }
30
+ status, out, err = run_diff(unit_dir)
31
+ assert_diff_contains(status, out, err, timer, "-# local edit")
32
+ end
33
+ end
34
+
35
+ def test_exits_one_and_shows_added_lines_when_installed_unit_is_missing_lines
36
+ with_installed_units do |unit_dir|
37
+ timer = first_timer
38
+ remove_exact_line(File.join(unit_dir, timer), "Persistent=true\n")
39
+ status, out, err = run_diff(unit_dir)
40
+ assert_diff_contains(status, out, err, timer, "+Persistent=true")
41
+ end
42
+ end
43
+
44
+ def test_exits_one_and_shows_diff_for_stale_units_on_disk
45
+ with_installed_units do |unit_dir|
46
+ stale_timer = "wheneverd-demo-000000000000.timer"
47
+ stale_path = write_stale_timer(unit_dir, stale_timer)
48
+ status, out, err = run_diff(unit_dir)
49
+ assert_diff_removed(status, out, err, stale_timer, stale_path)
50
+ end
51
+ end
52
+
53
+ def test_returns_two_on_error
54
+ with_project_dir do
55
+ status, out, err = run_cli(["diff", "--schedule", "missing.rb"])
56
+ assert_equal 2, status
57
+ assert_equal "", out
58
+ assert_includes err, "Schedule file not found"
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def with_inited_unit_dir(name)
65
+ with_inited_project_dir do |project_dir|
66
+ yield File.join(project_dir, name)
67
+ end
68
+ end
69
+
70
+ def with_installed_units
71
+ with_inited_unit_dir("tmp_units") do |unit_dir|
72
+ assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
73
+ yield unit_dir
74
+ end
75
+ end
76
+
77
+ def run_diff(unit_dir)
78
+ run_cli(["diff", "--identifier", "demo", "--unit-dir", unit_dir])
79
+ end
80
+
81
+ def first_timer
82
+ expected_timer_basenames(identifier: "demo").fetch(0)
83
+ end
84
+
85
+ def remove_exact_line(path, line)
86
+ File.write(path, File.read(path).lines.reject { |l| l == line }.join)
87
+ end
88
+
89
+ def write_stale_timer(unit_dir, basename)
90
+ path = File.join(unit_dir, basename)
91
+ File.write(path, "#{Wheneverd::Systemd::Renderer::MARKER_PREFIX} test\n# stale\n")
92
+ path
93
+ end
94
+
95
+ def assert_diff_added(status, out, err, unit_dir, timer)
96
+ assert_equal 1, status
97
+ assert_equal "", err
98
+ assert_includes out, "diff --wheneverd #{timer}"
99
+ assert_includes out, "--- /dev/null"
100
+ assert_includes out, "+++ #{File.join(File.expand_path(unit_dir), timer)}"
101
+ end
102
+
103
+ def assert_diff_contains(status, out, err, timer, expected_line)
104
+ assert_equal 1, status
105
+ assert_equal "", err
106
+ assert_includes out, "diff --wheneverd #{timer}"
107
+ assert_includes out, expected_line
108
+ end
109
+
110
+ def assert_diff_removed(status, out, err, timer, path)
111
+ assert_equal 1, status
112
+ assert_equal "", err
113
+ assert_includes out, "diff --wheneverd #{timer}"
114
+ assert_includes out, "--- #{path}"
115
+ assert_includes out, "+++ /dev/null"
116
+ assert_includes out, "-# stale"
117
+ end
118
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_test_helpers"
5
+
6
+ class CLIInitTest < Minitest::Test
7
+ include CLITestHelpers
8
+
9
+ def test_writes_template_with_shell_and_argv_examples
10
+ with_project_dir do |project_dir|
11
+ status, out, err = run_cli(["init"])
12
+ assert_equal 0, status
13
+ assert_equal "", err
14
+ assert_includes out, "Wrote schedule template to"
15
+
16
+ schedule_path = File.join(project_dir, "config", "schedule.rb")
17
+ schedule = File.read(schedule_path)
18
+ assert_includes schedule, "command [\"echo\", \"hello world\"]"
19
+ assert_includes schedule, "shell \"echo hello | sed -e s/hello/hi/\""
20
+ end
21
+ end
22
+
23
+ def test_refuses_to_overwrite_without_force
24
+ with_project_dir do
25
+ assert_equal 0, run_cli(["init"]).first
26
+
27
+ status, out, err = run_cli(["init"])
28
+ assert_equal 1, status
29
+ assert_equal "", out
30
+ assert_includes err, "already exists"
31
+ assert_includes err, "--force"
32
+ end
33
+ end
34
+
35
+ def test_overwrites_with_force
36
+ with_project_dir do |project_dir|
37
+ assert_equal 0, run_cli(["init"]).first
38
+
39
+ schedule_path = File.join(project_dir, "config", "schedule.rb")
40
+ File.write(schedule_path, "# custom\n")
41
+
42
+ status, out, err = run_cli(["init", "--force"])
43
+ assert_equal 0, status
44
+ assert_equal "", err
45
+ assert_includes out, "Overwrote schedule template to"
46
+ assert_includes File.read(schedule_path), "Supported `every` period forms:"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_test_helpers"
5
+
6
+ class CLIStatusTest < Minitest::Test
7
+ include CLITestHelpers
8
+
9
+ def test_runs_list_timers_and_status_for_each_installed_timer
10
+ with_installed_units do |unit_dir|
11
+ status, _out, err, calls = run_status(unit_dir)
12
+ assert_cli_success(status, err)
13
+ assert_status_calls(calls, expected_timer_units)
14
+ end
15
+ end
16
+
17
+ def test_returns_nonzero_when_systemctl_fails
18
+ with_installed_units do |unit_dir|
19
+ status, _out, err, calls = run_status(unit_dir, exitstatus: 1, stderr: "boom\n")
20
+ assert_equal 1, status
21
+ assert_includes err, "systemctl failed"
22
+ assert_includes err, "boom"
23
+ assert_includes calls.fetch(0).fetch(0), "list-timers"
24
+ end
25
+ end
26
+
27
+ def test_exits_zero_and_does_not_call_systemctl_when_no_timers_installed
28
+ with_project_dir do |project_dir|
29
+ unit_dir = File.join(project_dir, "empty_units")
30
+ FileUtils.mkdir_p(unit_dir)
31
+ status, out, err, calls = run_status(unit_dir)
32
+ assert_cli_success(status, err)
33
+ assert_equal "", out
34
+ assert_equal [], calls
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def with_installed_units
41
+ with_inited_project_dir do |project_dir|
42
+ unit_dir = File.join(project_dir, "tmp_units")
43
+ assert_equal 0, run_cli(["write", "--identifier", "demo", "--unit-dir", unit_dir]).first
44
+ yield unit_dir
45
+ end
46
+ end
47
+
48
+ def run_status(unit_dir, **kwargs)
49
+ run_cli_with_capture3_stub(["status", "--identifier", "demo", "--unit-dir", unit_dir], **kwargs)
50
+ end
51
+
52
+ def expected_timer_units
53
+ expected_timer_basenames(identifier: "demo").sort
54
+ end
55
+
56
+ def assert_status_calls(calls, expected_timers)
57
+ assert_list_timers_call(calls, expected_timers)
58
+ assert_status_unit_calls(calls, expected_timers)
59
+ end
60
+
61
+ def assert_list_timers_call(calls, expected_timers)
62
+ assert_systemctl_call_starts_with(
63
+ calls,
64
+ 0,
65
+ SYSTEMCTL_USER_PREFIX + ["list-timers", "--all"],
66
+ includes: expected_timers
67
+ )
68
+ end
69
+
70
+ def assert_status_unit_calls(calls, expected_timers)
71
+ status_calls = calls.drop(1).map(&:first)
72
+ assert_equal expected_timers.length, status_calls.length
73
+ assert_equal expected_timers, status_calls.map(&:last).sort
74
+ status_calls.each { |args| assert_equal SYSTEMCTL_USER_PREFIX + ["status", args.last], args }
75
+ end
76
+ end