do_run 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.
data/lib/do/config.rb ADDED
@@ -0,0 +1,163 @@
1
+ require 'toml-rb'
2
+ require_relative 'task'
3
+ require_relative 'error'
4
+
5
+ module Do
6
+ # Loads and represents the declarative TOML configuration.
7
+ #
8
+ # The TOML file is the source of truth. Everything else `do` produces is a
9
+ # derived artifact. This class only parses the file; validation is performed
10
+ # by {Validator}.
11
+ class Config
12
+ KNOWN_TASK_FIELDS = %w[
13
+ command schedule time day working_directory environment enabled
14
+ ].freeze
15
+
16
+ # Default location when no path is given.
17
+ def self.default_path
18
+ dir = ENV.fetch('XDG_CONFIG_HOME', nil)
19
+ dir = File.join(Dir.home, '.config') if dir.nil? || dir.empty?
20
+ File.join(dir, 'do', 'config.toml')
21
+ end
22
+
23
+ # @return [Array<String>] validation errors found while parsing.
24
+ def self.parse(raw_text, source: 'config.toml')
25
+ parsed = TomlRB.parse(raw_text)
26
+ new(parsed, source: source)
27
+ rescue TomlRB::ParseError, Psych::SyntaxError => e
28
+ raise ValidationError, ["Malformed TOML in #{source}: #{e.message}"]
29
+ rescue StandardError => e
30
+ raise ValidationError, ["Failed to parse #{source}: #{e.message}"]
31
+ end
32
+
33
+ def self.load(path = default_path)
34
+ raise Error, "Configuration file does not exist: #{path}" unless File.exist?(path)
35
+
36
+ raw = File.read(path)
37
+ config = parse(raw, source: path)
38
+ config.path = path
39
+ config
40
+ end
41
+
42
+ attr_reader :raw, :tasks
43
+ attr_accessor :path
44
+
45
+ def initialize(raw, source: nil)
46
+ @raw = raw || {}
47
+ @source = source
48
+ @tasks = build_tasks(@raw['tasks'])
49
+ end
50
+
51
+ def task(name)
52
+ @tasks.find { |t| t.name == name }
53
+ end
54
+
55
+ def [](key)
56
+ @raw[key]
57
+ end
58
+
59
+ # Edit a scalar field inside a task's table without re-serializing the
60
+ # whole file (preserving user comments/formatting elsewhere). Used by
61
+ # `enable`/`disable`.
62
+ def set_task_field(task_name, field, toml_value)
63
+ text = File.read(@path)
64
+ idx = block_start_index(text, task_name)
65
+ return edit_field_anywhere(text, task_name, field, toml_value) if idx.nil?
66
+
67
+ lines = text.lines
68
+ block_end = block_end_index(lines, idx)
69
+ match = (idx...block_end).find { |i| lines[i] =~ /\A#{Regexp.escape(field)}\s*=/ }
70
+ if match
71
+ lines[match] = "#{field} = #{toml_value}\n"
72
+ else
73
+ lines.insert(idx + 1, "#{field} = #{toml_value}\n")
74
+ end
75
+ File.write(@path, lines.join)
76
+ end
77
+
78
+ # Remove a task's table from the file. Used by `do remove`. Also removes
79
+ # any nested tables under the same task (e.g. `[tasks.name.environment]`).
80
+ def remove_task(task_name)
81
+ text = File.read(@path)
82
+ idx = block_start_index(text, task_name)
83
+ raise Error, "task '#{task_name}' not present in configuration" if idx.nil?
84
+
85
+ lines = text.lines
86
+ block_end = task_block_end(lines, idx, task_name)
87
+ removed = lines[0...idx] + lines[block_end..]
88
+ # Collapse leftover blank lines around the removed block (best effort).
89
+ File.write(@path, removed.join.gsub(/\n{3,}/, "\n\n"))
90
+ end
91
+
92
+ private
93
+
94
+ # Continue past nested tables that belong to the same task.
95
+ def task_block_end(lines, idx, task_name)
96
+ i = idx + 1
97
+ while i < lines.length
98
+ line = lines[i]
99
+ break if line.start_with?('[') && !line.start_with?("[tasks.#{task_name}.") &&
100
+ !line.start_with?("[tasks.\"#{task_name}\".")
101
+
102
+ i += 1
103
+ end
104
+ i
105
+ end
106
+
107
+ def block_start_index(text, task_name)
108
+ re = /\[tasks\.#{Regexp.escape(task_name)}\]\s*\z|\[tasks\."#{Regexp.escape(task_name)}"\]\s*\z/
109
+ text.lines.index { |l| l.start_with?('[tasks.') && l =~ re }
110
+ end
111
+
112
+ def block_end_index(lines, idx)
113
+ i = idx + 1
114
+ i += 1 while i < lines.length && !lines[i].start_with?('[')
115
+ i
116
+ end
117
+
118
+ # Fallback: file may have been edited/scrambled; attempt a field swap
119
+ # outside a knowing block. Keeps commands non-destructive toward comments.
120
+ def edit_field_anywhere(text, task_name, field, toml_value)
121
+ header = "[tasks.#{task_name}]"
122
+ return unless text.include?(header)
123
+
124
+ _ = text
125
+ hmm = text.gsub(/^(\s*)#{Regexp.escape(field)}\s*=.*/, " #{field} = #{toml_value}")
126
+ File.write(@path, hmm) if hmm != text
127
+ end
128
+
129
+ def build_tasks(raw_tasks)
130
+ return [] if raw_tasks.nil?
131
+
132
+ unless raw_tasks.is_a?(Hash)
133
+ raise ValidationError, ["'tasks' must be a table of task definitions in #{@source}"]
134
+ end
135
+
136
+ raw_tasks.map do |name, fields|
137
+ fields ||= {}
138
+ unless fields.is_a?(Hash)
139
+ raise ValidationError, ["task '#{name}' definitions must be tables in #{@source}"]
140
+ end
141
+
142
+ env = fields['environment']
143
+ unless env.nil? || env.is_a?(Hash)
144
+ raise ValidationError, ["environment for task '#{name}' must be a table in #{@source}"]
145
+ end
146
+
147
+ Task.new(
148
+ name: name.to_s,
149
+ command: fields['command'],
150
+ schedule: fields['schedule'],
151
+ time: fields['time'],
152
+ day: fields['day'],
153
+ working_directory: fields['working_directory'],
154
+ environment: env,
155
+ enabled: fields['enabled'],
156
+ raw: fields
157
+ )
158
+ end
159
+ rescue TypeError => e
160
+ raise ValidationError, ["Invalid configuration structure: #{e.message}"]
161
+ end
162
+ end
163
+ end
data/lib/do/error.rb ADDED
@@ -0,0 +1,14 @@
1
+ module Do
2
+ # Top-level error for all normal user-facing failures.
3
+ class Error < StandardError
4
+ end
5
+
6
+ class ValidationError < Error
7
+ attr_reader :errors
8
+
9
+ def initialize(errors)
10
+ @errors = Array(errors)
11
+ super(@errors.join("\n"))
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,36 @@
1
+ require 'shellwords'
2
+ require_relative 'error'
3
+
4
+ module Do
5
+ # Executes a task's command as an argument list, without a shell.
6
+ #
7
+ # The `command` string is split into argv tokens lexically (respecting
8
+ # quoting but performing no variable expansion, `~` expansion, or globbing).
9
+ # This is the safe, declarative default described in the spec. Tasks that
10
+ # genuinely need shell features must request them explicitly, e.g. by
11
+ # prefixing with `bash -lc '...'`.
12
+ class Executor
13
+ attr_reader :systemd
14
+
15
+ def initialize(systemd: Systemd.new)
16
+ @systemd = systemd
17
+ end
18
+
19
+ # Run the task immediately and return its exit status. The timer schedule
20
+ # is never altered by manual execution.
21
+ #
22
+ # @return [Integer] process exit status
23
+ def run(task)
24
+ argv = Shellwords.split(task.command)
25
+ raise Error, "task '#{task.name}' has an empty command" if argv.empty?
26
+
27
+ env_overrides = task.environment || {}
28
+ options = {}
29
+ options[:chdir] = File.expand_path(task.working_directory) if task.working_directory
30
+
31
+ pid = Process.spawn(env_overrides, *argv, options)
32
+ _pid, status = Process.wait2(pid)
33
+ status.exitstatus || 0
34
+ end
35
+ end
36
+ end
data/lib/do/manager.rb ADDED
@@ -0,0 +1,114 @@
1
+ require 'fileutils'
2
+ require_relative 'systemd'
3
+ require_relative 'validator'
4
+ require_relative 'error'
5
+
6
+ module Do
7
+ # Orchestrates the lifecycle of generated units against the systemd user
8
+ # manager. This is the layer that turns config into reality: writing unit
9
+ # files, detecting and removing stale `do`-managed units, reloading systemd,
10
+ # and enabling/disabling schedulers.
11
+ class Manager
12
+ attr_reader :systemd
13
+
14
+ def initialize(systemd: Systemd.new, config: nil)
15
+ @systemd = systemd
16
+ @config = config
17
+ end
18
+
19
+ attr_writer :config
20
+
21
+ # Apply the configuration to the user systemd manager.
22
+ #
23
+ # Never overwrites units without the `do` marker. Refuses to run when the
24
+ # configuration is invalid.
25
+ def reload(config = @config)
26
+ Validator.validate!(config)
27
+ dir = @systemd.unit_dir
28
+ FileUtils.mkdir_p(dir)
29
+
30
+ expected = {}
31
+ config.tasks.each do |task|
32
+ next unless task.scheduled?
33
+
34
+ expected[task.service_unit] =
35
+ UnitGenerator.service_content(task, source_path: config.path)
36
+ expected[task.timer_unit] = UnitGenerator.timer_content(task, source_path: config.path)
37
+ end
38
+
39
+ write_units(dir, expected)
40
+ remove_stale_units(dir, expected.keys)
41
+ @systemd.daemon_reload
42
+ apply_enabled(config)
43
+ :ok
44
+ end
45
+
46
+ # Remove a task's generated units (if any) and reload. Does not touch the
47
+ # TOML configuration.
48
+ def unschedule(task, _config = @config)
49
+ remove_units_for(task)
50
+ @systemd.daemon_reload
51
+ :ok
52
+ end
53
+
54
+ # Remove all units a task owns, regardless of whether the task is
55
+ # scheduled. Used by `do remove`.
56
+ def remove_units_for(task)
57
+ [task.service_unit, task.timer_unit].each do |unit|
58
+ path = File.join(@systemd.unit_dir, unit)
59
+ FileUtils.rm_f(path) if File.exist?(path) && managed?(path)
60
+ @systemd.disable(unit) if @systemd.exists?(unit)
61
+ end
62
+ @systemd.daemon_reload
63
+ :ok
64
+ end
65
+
66
+ private
67
+
68
+ def write_units(dir, expected)
69
+ expected.each do |unit, content|
70
+ path = File.join(dir, unit)
71
+ existing = File.exist?(path) ? File.read(path) : nil
72
+ File.write(path, content) if existing != content
73
+ end
74
+ end
75
+
76
+ # Remove `do`-managed units that are no longer expected (deleted task or
77
+ # a schedule was removed). Only files carrying the managed marker are
78
+ # considered; user-created units are never touched.
79
+ def remove_stale_units(dir, expected)
80
+ stale = Dir.glob(File.join(dir, 'do-*.{service,timer}')).select do |path|
81
+ basename = File.basename(path)
82
+ !expected.include?(basename) && managed?(path)
83
+ end
84
+ stale.each do |path|
85
+ FileUtils.rm_f(path)
86
+ disable_if_known(File.basename(path))
87
+ end
88
+ end
89
+
90
+ def disable_if_known(unit)
91
+ @systemd.disable(unit) if @systemd.exists?(unit)
92
+ end
93
+
94
+ def managed?(path)
95
+ File.read(path).lines.any? { |l| l.include?('Managed by do') }
96
+ rescue Errno::ENOENT
97
+ false
98
+ end
99
+
100
+ def apply_enabled(config)
101
+ config.tasks.each do |task|
102
+ next unless task.scheduled?
103
+
104
+ if task.enabled?
105
+ @systemd.enable(task.timer_unit)
106
+ @systemd.start(task.timer_unit)
107
+ else
108
+ @systemd.disable(task.timer_unit)
109
+ @systemd.stop(task.timer_unit) if @systemd.active?(task.timer_unit)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,93 @@
1
+ require_relative 'error'
2
+
3
+ module Do
4
+ # Translates a task's declarative schedule into a systemd calendar
5
+ # expression ({#on_calendar}) suitable for use in an `OnCalendar=` directive.
6
+ #
7
+ # `do` does not implement its own timer loop; it only maps a small, fixed set
8
+ # of schedule shapes onto systemd's native calendar syntax.
9
+ class Scheduler
10
+ TIME_RE = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
11
+
12
+ # @return [String, nil] the systemd OnCalendar expression, or nil when the
13
+ # task is manual-only.
14
+ def self.on_calendar(task)
15
+ case task.schedule
16
+ when nil, '' then nil
17
+ when 'once' then once_calendar
18
+ when 'hourly' then hourly_calendar(task)
19
+ when 'daily' then daily_calendar(task)
20
+ when 'weekly' then weekly_calendar(task)
21
+ when 'monthly' then monthly_calendar(task)
22
+ else
23
+ raise Error, "unsupported schedule '#{task.schedule}'"
24
+ end
25
+ end
26
+
27
+ def self.once_calendar
28
+ # OnActiveSec=0 fires the service exactly once, when the timer is
29
+ # activated. The reload/enable workflow activates the timer, so `once`
30
+ # means "run once now when scheduled", without repeating.
31
+ 'OnActiveSec=0'
32
+ end
33
+
34
+ def self.hourly_calendar(task)
35
+ _, min = split_time(task, fallback: [0, 0])
36
+ "*-*-* *:#{pad(min)}:00"
37
+ end
38
+
39
+ def self.daily_calendar(task)
40
+ hour, min = split_time(task, fallback: [0, 0])
41
+ "*-*-* #{pad(hour)}:#{pad(min)}:00"
42
+ end
43
+
44
+ def self.weekly_calendar(task)
45
+ hour, min = split_time(task, fallback: [0, 0])
46
+ dow = weekday_short(task)
47
+ "#{dow} *-*-* #{pad(hour)}:#{pad(min)}:00"
48
+ end
49
+
50
+ def self.monthly_calendar(task)
51
+ hour, min = split_time(task, fallback: [0, 0])
52
+ dom = monthly_day(task)
53
+ "*-*-#{dom} #{pad(hour)}:#{pad(min)}:00"
54
+ end
55
+
56
+ def self.weekday_short(task)
57
+ day = task.day&.to_s&.downcase
58
+ Task::WEEKDAY_NUMBERS[day] or
59
+ raise Error, "invalid day '#{task.day}'; expected a day name like 'monday'"
60
+ day[0, 3].capitalize
61
+ end
62
+
63
+ def self.weekday_number(task)
64
+ day = task.day&.to_s&.downcase
65
+ Task::WEEKDAY_NUMBERS[day] or
66
+ raise Error, "invalid day '#{task.day}'; expected a day name like 'monday'"
67
+ end
68
+
69
+ def self.monthly_day(task)
70
+ day = task.day&.to_s
71
+ if day.nil? || day.empty?
72
+ '1'
73
+ elsif day =~ /\A([1-9]|[12]\d|3[01])\z/
74
+ day
75
+ else
76
+ raise Error, "invalid monthly day '#{task.day}'; expected a day-of-month 1-31"
77
+ end
78
+ end
79
+
80
+ def self.split_time(task, fallback:)
81
+ return fallback if task.time.nil? || task.time.empty?
82
+
83
+ m = TIME_RE.match(task.time.to_s)
84
+ raise Error, "invalid time '#{task.time}'; expected HH:MM" unless m
85
+
86
+ [m[1].to_i, m[2].to_i]
87
+ end
88
+
89
+ def self.pad(value)
90
+ value.to_s.rjust(2, '0')
91
+ end
92
+ end
93
+ end
data/lib/do/systemd.rb ADDED
@@ -0,0 +1,214 @@
1
+ require_relative 'error'
2
+ require_relative 'scheduler'
3
+
4
+ module Do
5
+ # Generates the systemd user unit files that `do` manages, and wraps the
6
+ # `systemctl --user` / `journalctl --user` commands used to control them.
7
+ #
8
+ # Generated unit files carry a clear "Managed by do" marker so they can be
9
+ # safely detected, updated, and removed later. Units not carrying this marker
10
+ # are never touched by `do`.
11
+ module UnitGenerator
12
+ MANAGED_MARKER = '# Managed by do. Do not edit by hand.'.freeze
13
+
14
+ module_function
15
+
16
+ def service_content(task, source_path: nil)
17
+ env_lines = task.environment.map { |k, v| "Environment=#{k}=#{v}" }
18
+ [
19
+ managed_header(source_path),
20
+ '',
21
+ '[Unit]',
22
+ "Description=do task: #{task.name}",
23
+ '',
24
+ '[Service]',
25
+ 'Type=oneshot',
26
+ "ExecStart=#{task.command}"
27
+ ].concat(
28
+ if task.working_directory
29
+ ["WorkingDirectory=#{task.working_directory}"]
30
+ else
31
+ []
32
+ end
33
+ ).concat(
34
+ env_lines,
35
+ ['', '[Install]', 'WantedBy=default.target']
36
+ ).join("\n") + "\n"
37
+ end
38
+
39
+ def timer_content(task, source_path: nil)
40
+ on_calendar = Scheduler.on_calendar(task)
41
+ calendar_line =
42
+ if on_calendar&.start_with?('OnActiveSec=')
43
+ on_calendar
44
+ else
45
+ "OnCalendar=#{on_calendar}"
46
+ end
47
+ [
48
+ managed_header(source_path),
49
+ '',
50
+ '[Unit]',
51
+ "Description=do task: #{task.name} (timer)",
52
+ '',
53
+ '[Timer]',
54
+ calendar_line
55
+ ].concat(
56
+ if task.schedule == 'once'
57
+ []
58
+ else
59
+ ['Persistent=true']
60
+ end
61
+ ).push(
62
+ '', '[Install]', 'WantedBy=timers.target'
63
+ ).join("\n") + "\n"
64
+ end
65
+
66
+ def managed_header(source_path)
67
+ lines = [MANAGED_MARKER]
68
+ lines << "# Source: #{source_path}" if source_path
69
+ lines.join("\n")
70
+ end
71
+ end
72
+
73
+ # Wraps systemd user session commands. All calls run through the current
74
+ # user's systemd user manager; `do` never touches system-wide units.
75
+ class Systemd
76
+ attr_reader :systemctl_bin, :journalctl_bin
77
+
78
+ def initialize(systemctl_bin: ENV.fetch('SYSTEMCTL', 'systemctl'),
79
+ journalctl_bin: ENV.fetch('JOURNALCTL', 'journalctl'))
80
+ @systemctl_bin = systemctl_bin
81
+ @journalctl_bin = journalctl_bin
82
+ end
83
+
84
+ def user_available?
85
+ shell_system([@systemctl_bin, '--user', 'is-system-running'],
86
+ out: File::NULL, err: File::NULL)
87
+ end
88
+
89
+ def unit_dir
90
+ dir = ENV.fetch('XDG_CONFIG_HOME', nil)
91
+ dir = File.join(Dir.home, '.config') if dir.nil? || dir.empty?
92
+ File.join(dir, 'systemd', 'user')
93
+ end
94
+
95
+ def exists?(unit)
96
+ run(['show', '-p', 'Id', '--no-pager', unit]).success?
97
+ end
98
+
99
+ def enabled?(unit)
100
+ run(['is-enabled', '--no-pager', unit]).success?
101
+ end
102
+
103
+ def active?(unit)
104
+ run(['is-active', '--no-pager', unit]).success?
105
+ end
106
+
107
+ def enable(unit)
108
+ run(['enable', '--now', unit])
109
+ end
110
+
111
+ def disable(unit)
112
+ run(['disable', '--now', unit])
113
+ end
114
+
115
+ def start(unit)
116
+ run(['start', unit])
117
+ end
118
+
119
+ def stop(unit)
120
+ run(['stop', unit])
121
+ end
122
+
123
+ def daemon_reload
124
+ run(['daemon-reload'])
125
+ end
126
+
127
+ def status(unit, extra: '--no-pager')
128
+ run(['status', unit, extra])
129
+ end
130
+
131
+ # @return [Time, nil] formatted next-elapsed time from a timer unit.
132
+ def next_time(unit)
133
+ require 'time'
134
+ value = show_prop(unit, 'NextElapseUSecRealtime')
135
+ return nil if value.nil? || value.empty?
136
+
137
+ Time.parse(value).localtime
138
+ rescue ArgumentError
139
+ nil
140
+ end
141
+
142
+ # @return [Time, nil] when the service last transitioned to active.
143
+ def last_active(unit)
144
+ value = show_prop(unit, 'ActiveEnterTimestamp')
145
+ value.nil? || value.empty? ? nil : Time.parse(value).localtime
146
+ end
147
+
148
+ # @return [Integer, nil] exit status of the last run of a service.
149
+ def last_exit_status(unit)
150
+ value = show_prop(unit, 'ExecMainStatus')
151
+ value.nil? || value.empty? ? nil : value.to_i
152
+ end
153
+
154
+ def active_state(unit)
155
+ show_prop(unit, 'ActiveState')
156
+ end
157
+
158
+ # @return [String, nil] a whitespace-trimmed value for +prop+.
159
+ def show_prop(unit, prop)
160
+ require 'time'
161
+ out = run(['show', '-p', prop, '--no-pager', unit]).out
162
+ m = out.match(/^#{Regexp.escape(prop)}=(.+)$/)
163
+ m ? m[1].strip : nil
164
+ end
165
+
166
+ def logs(unit, follow: false, tail: nil)
167
+ args = ['-u', unit, '--no-pager']
168
+ args << '-f' if follow
169
+ args << "-n #{tail}" if tail
170
+ system(*[@journalctl_bin, '--user', *args].flatten)
171
+ end
172
+
173
+ # Run a systemctl --user command, returning an Open3 result-like struct
174
+ # exposing #success?, #out, #err, #exitstatus.
175
+ def run(args)
176
+ require 'open3'
177
+ @out, @err, @status = Open3.capture3(@systemctl_bin, '--user', *args)
178
+ OpenStructish.new(@status.exitstatus, @out, @err, @status.success?)
179
+ end
180
+
181
+ private
182
+
183
+ def shell_system(args, **opts)
184
+ SystemRaw.new(args, opts)
185
+ end
186
+
187
+ # Minimal result holder so tests and callers share one shape.
188
+ class OpenStructish
189
+ attr_reader :exitstatus, :out, :err
190
+
191
+ def initialize(exitstatus, out, err, success)
192
+ @exitstatus = exitstatus
193
+ @out = out
194
+ @err = err
195
+ @success = success
196
+ end
197
+
198
+ def success?
199
+ @success
200
+ end
201
+ end
202
+
203
+ class SystemRaw
204
+ def initialize(args, opts)
205
+ @args = args
206
+ @ok = system(*args, **opts)
207
+ end
208
+
209
+ def success?
210
+ @ok
211
+ end
212
+ end
213
+ end
214
+ end
data/lib/do/task.rb ADDED
@@ -0,0 +1,53 @@
1
+ module Do
2
+ # A single declared task parsed from the TOML configuration.
3
+ #
4
+ # Tasks are plain Ruby objects; the {Validator} is responsible for deciding
5
+ # whether their field values are acceptable. This class only carries data
6
+ # and exposes a few derived helpers.
7
+ class Task
8
+ SCHEDULES = %w[once hourly daily weekly monthly].freeze
9
+ WEEKDAY_NAMES = %w[monday tuesday wednesday thursday friday saturday sunday].freeze
10
+ WEEKDAY_NUMBERS = {
11
+ 'monday' => 1, 'tuesday' => 2, 'wednesday' => 3, 'thursday' => 4,
12
+ 'friday' => 5, 'saturday' => 6, 'sunday' => 7
13
+ }.freeze
14
+
15
+ attr_reader :name, :command, :schedule, :time, :day,
16
+ :working_directory, :environment, :raw
17
+
18
+ def initialize(name:, command:, schedule: nil, time: nil, day: nil,
19
+ working_directory: nil, environment: {}, enabled: nil, raw: {})
20
+ @name = name
21
+ @command = command
22
+ @schedule = schedule
23
+ @time = time
24
+ @day = day
25
+ @working_directory = working_directory
26
+ @environment = environment || {}
27
+ @enabled = enabled
28
+ @raw = raw
29
+ end
30
+
31
+ # `enabled` is true by default for scheduled tasks and false for
32
+ # manual-only (unscheduled) tasks.
33
+ def enabled?
34
+ return scheduled? if @enabled.nil?
35
+ return @enabled == 'true' || @enabled == true if @enabled.is_a?(String)
36
+
37
+ !!@enabled
38
+ end
39
+
40
+ def scheduled?
41
+ !@schedule.nil? && @schedule != ''
42
+ end
43
+
44
+ # Return the service unit name systemd will use for this task.
45
+ def service_unit
46
+ "do-#{name}.service"
47
+ end
48
+
49
+ def timer_unit
50
+ "do-#{name}.timer"
51
+ end
52
+ end
53
+ end