lux-hammer 0.3.13 → 0.3.15

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 411174afd97cefbe9e7e3309c25233f465b9458ee0dc710e67077725658876c0
4
- data.tar.gz: f4b64f7897fd8c67412fdbe418ad51b96eeac1ebb3e87ce9df5b97651a4a9ff6
3
+ metadata.gz: a61994d8f82b575a019cfb1171935b334b840cdb5561fcc56e9ba2c141a314d2
4
+ data.tar.gz: a4d4d730c830b4d30de13dc9e0c27e9cc2fa3902c9297154d4fe81ba5365fe94
5
5
  SHA512:
6
- metadata.gz: ba1364fcff70fefa529a2e7586ee33f7826b43ee7cd3ebd5e454f0bce82131b427cf320d85d8d911edc3a11762196de7db9964c6877959548eb4c6753e5ac063
7
- data.tar.gz: 5b21c08ac766b1af02b1a0d49b7fb3129fffd94b0c13fd07c7a5995851c21447e6a437848f25ef30f364bfccb94f1bac51046630dbbd574aaa109a61a738e893
6
+ metadata.gz: 0e4c0a5f1fbf47550df2929670e647cf7e1e390e7b3aab0b71932dc5f9557295953c39cdf9fafc6ac719d53b94ed14058bd1a2bb32bcaf9bb79ff3b2198fee16
7
+ data.tar.gz: b0012fd33259c5ea63af2d3ea0040c4a618292042f842dbda523f462d90fbc9138c04217c3c86a7d52fd56a6b650249ab2dd9bb6f070e1f1a24497ab68e6dbe2
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.13
1
+ 0.3.15
data/AGENTS.md CHANGED
@@ -41,6 +41,9 @@ lib/hammer/shell.rb # ANSI/IO helpers
41
41
  lib/hammer/option.rb # One option definition
42
42
  lib/hammer/parser.rb # ARGV -> [positional, opts_hash]
43
43
  lib/hammer/command.rb # One registered command (name, opts, alts, handler)
44
+ lib/hammer/cron.rb # `cron '<expr>'` schedule parser (cron/interval/@shortcut)
45
+ lib/hammer/cron_server.rb # `h:cron` job server (scheduler, logs, state; lazy-loaded)
46
+ lib/hammer/cron_web.rb # job server web UI (stdlib TCPServer; lazy-loaded)
44
47
  lib/hammer/loader.rb # `*_hammer.rb` fragment loader (auto/glob/file)
45
48
  lib/hammer/builder.rb # Block-DSL context (Hammerfile / Hammer.run)
46
49
  lib/hammer/command_builder.rb # `task :name do ... end` context
@@ -54,6 +57,8 @@ test/option_test.rb # Option declaration / casting
54
57
  test/command_test.rb # Command data type
55
58
  test/shell_test.rb # ANSI / IO helpers
56
59
  test/cli_test.rb # `hammer` binary end-to-end
60
+ test/cron_test.rb # schedule parser (matches?/due?/next_run)
61
+ test/cron_server_test.rb # job discovery, state, rotation, web UI
57
62
  examples/Hammerfile # Reference Hammerfile
58
63
  examples/class_dsl.rb # Reference class DSL usage
59
64
  examples/block_dsl.rb # Reference block DSL usage
@@ -73,11 +78,18 @@ Inside a `task :name do ... end` block (CommandBuilder context):
73
78
  resolved against root (same lookup as `hammer`), deduped per
74
79
  top-level `start` so each prereq fires at most once. Dedupe also
75
80
  spans `+`-chained segments. Unknown prereq raises `Hammer::Error`.
81
+ * `cron '<expr>'` - schedule for the `h:cron` job server. Accepts
82
+ 5-field cron (`'*/10 * * * *'`), an interval (`'10s'`, `'10m'`,
83
+ `'2h'`, `'1d'`, counted from the last run) or `@hourly` / `@daily` /
84
+ `@weekly` / `@monthly`. Parsed eagerly (`Hammer::Cron.new`) so an invalid
85
+ expression raises `Hammer::Error` at definition time. Stored on the
86
+ Command (`cmd.cron` raw string, `cmd.cron_schedule` parsed), exported
87
+ by `h:json`, shown in per-command help.
76
88
  * `proc do |opts| ... end` - **the last expression**, becomes handler
77
89
 
78
90
  At class scope (for `def`-style commands):
79
91
 
80
- * `desc`, `example`, `opt`, `alt`, `needs` set pending state
92
+ * `desc`, `example`, `opt`, `alt`, `needs`, `cron` set pending state
81
93
  * The next `def` consumes the pending state IF `desc` was set; otherwise
82
94
  the method is treated as a plain helper
83
95
  * Methods with arity 0 are called without opts; methods that take an arg
@@ -249,7 +261,7 @@ reserved `h:` namespace.
249
261
 
250
262
  * Root: `:default` (hidden, carries `--version` / `-v`).
251
263
  * `h:` namespace: `h:help`, `h:update`, `h:agents`, `h:version`,
252
- `h:recipes`, `h:init`.
264
+ `h:recipes`, `h:init`, `h:json`, `h:cron`.
253
265
 
254
266
  `h:` is the reserved built-in namespace - keeping the tool-meta commands
255
267
  there means they never collide with a project's root tasks, so there's
@@ -279,6 +291,32 @@ Task contracts:
279
291
  forward flags through). Bare invocation lists.
280
292
  * `h:init` - writes `Hammer::STARTER_HAMMERFILE` to `./Hammerfile`;
281
293
  refuses if one exists.
294
+ * `h:json` - `puts JSON` of `root.export_spec` (tasks grouped exactly
295
+ like the bare listing via `section_for`, root group keyed `__root`).
296
+ `--all` keeps the `h:` tree, `--compact` minifies.
297
+ * `h:cron` - foreground job server for tasks with a `cron` schedule.
298
+ Lazy-requires `hammer/cron_server`. Ticks each minute (each second
299
+ when any job has a sub-minute interval); every due job
300
+ runs in a fresh subprocess (`hammer ns:task`, env `HAMMER_QUIET=1
301
+ NO_COLOR=1`, stdin closed, chdir to the Hammerfile dir), stdout+stderr
302
+ appended to `log/hammer/<slug>.log` (`:` -> `-`; rotated to `.log.1`
303
+ past 1 MB, max 2 files). Last runs persist in
304
+ `tmp/hammer/cron.state.json` (atomic tmp+rename) so restarts don't
305
+ re-fire; a live pid recorded there blocks a second instance.
306
+ Running state persists too: each active run's child pid is saved, and
307
+ on restart a still-alive orphan is restored as `running` (a watcher
308
+ polls until it exits; the overlap guard keeps skipping), while a dead
309
+ one becomes status `unknown` (exit status lost) instead of a stale
310
+ `running`. Overlapping runs of the same job are skipped, never
311
+ queued. Web UI on
312
+ `127.0.0.1` only (default port 4267, hand-rolled HTTP in
313
+ `hammer/cron_web.rb` - no webrick, keep it dependency-free): jobs
314
+ table, per-job log tail, POST run-now, `/json` status. Flags:
315
+ `--port`, `--pass` (HTTP basic auth on every route, any username,
316
+ constant-time compare; falls back to `HAMMER_CRON_PASS` env),
317
+ `--list` (print jobs + next runs, exit), `--service` (print launchd
318
+ plist / systemd unit, carries `--pass` when set, install hints on
319
+ stderr).
282
320
 
283
321
  The `:default` task and the `help` / `-h` / `--help` requests are
284
322
  invoked via `run_command(cmd, argv, full: name, quiet: true)` - the
data/README.md CHANGED
@@ -562,6 +562,13 @@ Under the `h:` namespace:
562
562
  * `h:version` - print the lux-hammer version.
563
563
  * `h:recipes` - list / install / show / edit recipes.
564
564
  * `h:init` - write a starter Hammerfile in cwd (refuses if one exists).
565
+ * `h:json` - dump the CLI definition as JSON (tasks grouped like the
566
+ bare listing, with desc/options/examples/aliases/needs/cron); `--all`
567
+ includes the `h:` tasks, `--compact` minifies. Output is plain stdout
568
+ (the run banner goes to stderr), so `hammer h:json | jq` just works.
569
+ * `h:cron` - job server: runs every task that declares a
570
+ `cron '<expr>'` schedule, with a localhost web UI for status and
571
+ logs. See [Cron / job server](#cron--job-server).
565
572
 
566
573
  Each is guarded by `unless commands.key?` within the namespace, so you
567
574
  can override one by reopening `namespace :h` in your Hammerfile.
@@ -574,6 +581,16 @@ hammer --system h:recipes # list recipes, ignoring any local Hammer
574
581
  hammer --system h:recipes --install srt ~/bin/srt
575
582
  ```
576
583
 
584
+ `--gui` opens a native macOS window for the current project: a sidebar of
585
+ your tasks (grouped like the listing), a form per task built from its
586
+ options, and a Run button that streams the task's output. It reads the
587
+ project via `h:json` and runs each task as a `hammer <path> ...`
588
+ subprocess. macOS / arm64 only.
589
+
590
+ ```sh
591
+ hammer --gui # open the runner for the nearest Hammerfile
592
+ ```
593
+
577
594
  Customize bare `hammer` by replacing `:default`:
578
595
 
579
596
  ```ruby
@@ -611,6 +628,101 @@ end
611
628
  `-h` / `--help` stay reserved on every command - you can't shadow them
612
629
  with an `opt`.
613
630
 
631
+ ## Cron / job server
632
+
633
+ Any task can declare a schedule with `cron`; `hammer h:cron` then runs
634
+ those tasks on time, like a project-local crontab with a web UI:
635
+
636
+ ```ruby
637
+ task :backup do
638
+ desc 'Snapshot the database'
639
+ cron '@daily' # 00:00 every day
640
+ proc { |_| sh 'bin/backup' }
641
+ end
642
+
643
+ namespace :cache do
644
+ task :warm do
645
+ desc 'Refresh hot caches'
646
+ cron '10m' # every 10 minutes
647
+ proc { |_| sh 'bin/warm-cache' }
648
+ end
649
+ end
650
+ ```
651
+
652
+ Accepted schedule forms:
653
+
654
+ | form | example | meaning |
655
+ |---|---|---|
656
+ | 5-field cron | `*/10 * * * *` | standard crontab (`min hour dom mon dow`) |
657
+ | interval | `10s`, `10m`, `2h`, `1d` | every N seconds/minutes/hours/days, counted from the last run |
658
+ | shortcut | `@hourly`, `@daily`, `@weekly`, `@monthly` | expands to the cron equivalent |
659
+
660
+ Careful with raw cron fields: `10 * * * *` means "minute 10 of every
661
+ hour", not "every 10 minutes" - that's `*/10 * * * *`, or just `10m`.
662
+ Cron fields support `*`, `a`, `a-b`, `*/n`, `a-b/n` and comma lists
663
+ (numeric values only). Weekday `7` equals `0` (Sunday). Intervals are
664
+ not clock-aligned, so odd periods like `90m` work; a job that never ran
665
+ fires on the server's first tick. The scheduler ticks once per minute,
666
+ or once per second when any job declares a sub-minute interval.
667
+ Invalid expressions fail at Hammerfile load, not at runtime.
668
+
669
+ Run the server:
670
+
671
+ ```sh
672
+ hammer h:cron # scheduler + web UI, Ctrl-C to stop
673
+ hammer h:cron --port=7777 # UI on another port (default 4267)
674
+ hammer h:cron --list # print jobs + next runs, then exit
675
+ ```
676
+
677
+ The web UI at `http://127.0.0.1:4267` (localhost only, never a public
678
+ interface) lists every job with its schedule, last/next run, status and
679
+ duration, shows per-job logs, and has a "run now" button for manual
680
+ triggers. `GET /json` serves the same status for scripting.
681
+
682
+ On a shared machine, protect the UI with HTTP basic auth - useful since
683
+ "run now" executes tasks:
684
+
685
+ ```sh
686
+ hammer h:cron --pass=secret # or: HAMMER_CRON_PASS=secret hammer h:cron
687
+ curl -u :secret http://127.0.0.1:4267/json # any username, password checked
688
+ ```
689
+
690
+ Prefer the `HAMMER_CRON_PASS` env var when other users can read your
691
+ process list (`ps` shows flag values). A `--service` unit generated
692
+ while `--pass` is set carries the flag along, so keep that file private.
693
+ Note this is plain HTTP - credentials are encoded, not encrypted; for
694
+ anything beyond localhost put a TLS proxy in front.
695
+
696
+ Each run executes in a fresh subprocess (`hammer ns:task` - same
697
+ isolation as the GUI runner), through the full normal pipeline: Bundler,
698
+ dotenv, `before` hooks, `needs`. Output goes to a per-job log:
699
+
700
+ ```
701
+ log/hammer/backup.log # current log (db:backup -> db-backup.log)
702
+ log/hammer/backup.log.1 # previous, rotated past 1 MB - max 2 files
703
+ tmp/hammer/cron.state.json # last runs + server pid, survives restarts
704
+ ```
705
+
706
+ You probably want `log/hammer/` and `tmp/hammer/` in `.gitignore`.
707
+
708
+ Overlap policy: if a run is still going when the next one comes due,
709
+ the new run is skipped (noted in the job log) - jobs never pile up.
710
+ Because last runs persist in the state file, a restart neither re-fires
711
+ jobs nor resets interval clocks, and a second `h:cron` for the same
712
+ project refuses to start while one is already running. Running state
713
+ survives restarts too: a job whose subprocess outlived a server crash
714
+ is picked up again as `running` (no double-start), and one that died
715
+ with the server shows `unknown` rather than a stale `running`.
716
+
717
+ To keep it running supervised, `--service` prints a ready-to-save
718
+ launchd plist (macOS) or systemd user unit (linux) - it only prints,
719
+ installing is up to you:
720
+
721
+ ```sh
722
+ hammer h:cron --service > ~/.config/systemd/user/hammer-cron.service
723
+ systemctl --user enable --now hammer-cron
724
+ ```
725
+
614
726
  ## Prereqs (`needs`)
615
727
 
616
728
  Declare commands that must run before this one (Rake-style task deps):
data/bin/hammer CHANGED
@@ -23,5 +23,15 @@ if File.exist?('Gemfile') && !defined?(Bundler)
23
23
  end
24
24
 
25
25
  require 'lux-hammer'
26
+
27
+ # `hammer --dir` prints the gem root of the lux-hammer that actually loaded
28
+ # (repo checkout or installed gem). Lets recipes locate the lib without
29
+ # hardcoding a path: require File.join(`hammer --dir`.chomp, 'lib/lux-hammer')
30
+ if ARGV.first == '--dir'
31
+ lib = $LOADED_FEATURES.grep(%r{/lux-hammer\.rb$}).last
32
+ puts File.expand_path('../..', lib) # lib/lux-hammer.rb -> gem root
33
+ exit
34
+ end
35
+
26
36
  Hammer.cli(ARGV)
27
37
 
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleName</key><string>Hammer</string>
6
+ <key>CFBundleDisplayName</key><string>Hammer</string>
7
+ <key>CFBundleIdentifier</key><string>com.lux-hammer.gui</string>
8
+ <key>CFBundleVersion</key><string>1</string>
9
+ <key>CFBundleShortVersionString</key><string>1.0</string>
10
+ <key>CFBundlePackageType</key><string>APPL</string>
11
+ <key>CFBundleExecutable</key><string>HammerGUI</string>
12
+ <key>LSMinimumSystemVersion</key><string>11.0</string>
13
+ <key>NSHighResolutionCapable</key><true/>
14
+ <key>NSPrincipalClass</key><string>NSApplication</string>
15
+ </dict>
16
+ </plist>
@@ -31,6 +31,8 @@ class Hammer
31
31
  register_version(h) unless h.commands.key?('version')
32
32
  register_recipes(h) unless h.commands.key?('recipes')
33
33
  register_init(h) unless h.commands.key?('init')
34
+ register_json(h) unless h.commands.key?('json')
35
+ register_cron(h) unless h.commands.key?('cron')
34
36
  end
35
37
 
36
38
  def register_help(klass)
@@ -112,6 +114,72 @@ class Hammer
112
114
  end
113
115
  end
114
116
 
117
+ # The job server: runs every task that declares `cron '<expr>'` on
118
+ # its schedule, in the foreground, with a localhost web UI for
119
+ # status, logs and manual triggers. Per-job logs land in log/hammer/
120
+ # (rotated at 1 MB, max 2 files), state in tmp/hammer/cron.state.json.
121
+ def register_cron(klass)
122
+ klass.class_eval do
123
+ task :cron do
124
+ desc <<~TXT
125
+ Job server: run tasks that declare a `cron '<expr>'` schedule.
126
+
127
+ Runs in the foreground (Ctrl-C to stop) with a web UI on
128
+ localhost for job status, per-job logs and manual runs. Use
129
+ --service to print a launchd plist / systemd unit and let the
130
+ OS supervise it.
131
+ TXT
132
+ example 'h:cron'
133
+ example 'h:cron --port=7777'
134
+ example 'h:cron --pass=secret'
135
+ example 'h:cron --list'
136
+ example 'h:cron --service > ~/.config/systemd/user/hammer-cron.service'
137
+ opt :port, type: :integer, default: 4267, desc: 'web UI port (binds 127.0.0.1 only)'
138
+ opt :pass, type: :string, desc: 'HTTP basic-auth password for the web UI (or HAMMER_CRON_PASS env)'
139
+ opt :list, type: :boolean, desc: 'print scheduled jobs + next runs, then exit'
140
+ opt :service, type: :boolean, desc: 'print a launchd plist (macOS) / systemd unit (linux)'
141
+ proc do |opts|
142
+ if self.class.root.instance_variable_get(:@no_hammerfile)
143
+ error "no Hammerfile found - h:cron needs a project with scheduled tasks"
144
+ end
145
+ require_relative 'cron_server'
146
+ server = Hammer::CronServer.new(self.class.root, port: opts[:port],
147
+ pass: opts[:pass] || ENV['HAMMER_CRON_PASS'])
148
+ if opts[:service]
149
+ server.print_service_unit
150
+ elsif opts[:list]
151
+ server.print_startup_summary
152
+ else
153
+ server.run!
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ def register_json(klass)
161
+ klass.class_eval do
162
+ task :json do
163
+ desc <<~TXT
164
+ Dump the CLI definition as JSON: tasks grouped exactly like
165
+ the bare-`hammer` listing, each with desc, options, examples,
166
+ aliases, needs. Consumed by the macOS GUI and any tooling
167
+ that wants the full Hammerfile spec.
168
+ TXT
169
+ example 'h:json'
170
+ example 'h:json --all # include the reserved h: tasks'
171
+ example 'h:json --compact # minified, single line'
172
+ opt :all, type: :boolean, desc: 'include reserved built-in h: tasks'
173
+ opt :compact, type: :boolean, desc: 'minified JSON (default: pretty)'
174
+ proc do |opts|
175
+ require 'json'
176
+ spec = self.class.root.export_spec(include_builtins: opts[:all])
177
+ puts opts[:compact] ? JSON.generate(spec) : JSON.pretty_generate(spec)
178
+ end
179
+ end
180
+ end
181
+ end
182
+
115
183
  # `:recipes` rolls all recipe-management actions into one task. Bare
116
184
  # invocation lists; opts pick the action and positional args carry
117
185
  # the recipe name (and optional target path for --install). Run via
@@ -1,7 +1,7 @@
1
1
  class Hammer
2
2
  # A single registered command on a Hammer class.
3
3
  class Command
4
- attr_reader :name, :desc, :options, :examples, :alts, :needs
4
+ attr_reader :name, :desc, :options, :examples, :alts, :needs, :cron
5
5
  attr_accessor :handler, :location, :prev_location
6
6
 
7
7
  def initialize(name:, desc: '', handler: nil)
@@ -35,6 +35,19 @@ class Hammer
35
35
  @needs << name.to_s
36
36
  end
37
37
 
38
+ # Store a `cron '<expr>'` schedule. The raw string is kept for
39
+ # display/export; parsing happens here so an invalid expression
40
+ # raises Hammer::Error at definition time, not when the job server
41
+ # starts. The parsed Hammer::Cron is what the scheduler consumes.
42
+ def set_cron(expr)
43
+ @cron_schedule = Hammer::Cron.new(expr)
44
+ @cron = @cron_schedule.source
45
+ end
46
+
47
+ def cron_schedule
48
+ @cron_schedule
49
+ end
50
+
38
51
  def matches?(name)
39
52
  name = name.to_s
40
53
  name == @name || @alts.include?(name)
@@ -63,6 +76,27 @@ class Hammer
63
76
  end
64
77
  end
65
78
 
79
+ # Structured form for JSON export (`h:json`). `path` is the full
80
+ # colon path supplied by the tree walk - a Command doesn't know its
81
+ # own namespace prefix. `hidden` follows the help rule: a task with
82
+ # no `desc` still dispatches but is hidden from listings.
83
+ def to_h(path = name)
84
+ {
85
+ name: name,
86
+ path: path,
87
+ desc: desc,
88
+ brief: brief,
89
+ hidden: desc.empty?,
90
+ redefined: !prev_location.nil?,
91
+ location: location,
92
+ alts: alts,
93
+ needs: needs,
94
+ cron: cron,
95
+ examples: examples,
96
+ options: options.map(&:to_h)
97
+ }
98
+ end
99
+
66
100
  private
67
101
 
68
102
  def short_flag?(switch)
@@ -26,5 +26,13 @@ class Hammer
26
26
  def needs(*names)
27
27
  names.each { |n| @cmd.add_need(n) }
28
28
  end
29
+
30
+ # Schedule this task for the `hammer h:cron` job server. Accepts a
31
+ # 5-field cron expression ('*/10 * * * *'), a simple interval ('10s',
32
+ # '10m', '2h', '1d') or a shortcut (@hourly, @daily, @weekly,
33
+ # @monthly). Validated immediately, so a typo fails at Hammerfile load.
34
+ def cron(expr)
35
+ @cmd.set_cron(expr)
36
+ end
29
37
  end
30
38
  end
@@ -0,0 +1,151 @@
1
+ class Hammer
2
+ # Schedule of a task declared with `cron '<expr>'`. Parses the
3
+ # expression once (at Hammerfile eval, so typos fail at load time) and
4
+ # answers the scheduler's questions: does this wall-clock minute match,
5
+ # is a run due given the last one, and when is the next run.
6
+ #
7
+ # Accepted forms:
8
+ #
9
+ # cron '*/10 * * * *' # standard 5-field crontab
10
+ # cron '10m' # simple interval: every 10 minutes
11
+ # cron '10s' / '2h' / '1d' # seconds / hours / days
12
+ # cron '@daily' # shortcut, expands to '0 0 * * *'
13
+ #
14
+ # Cron fields support '*', 'a', 'a-b', '*/n', 'a-b/n' and comma lists,
15
+ # numeric values only (no 'jan'/'mon' names). Weekday 7 equals 0
16
+ # (Sunday). Intervals are measured from the last run - not aligned to
17
+ # the clock - which makes odd periods like '90m' possible and keeps
18
+ # restarts from re-firing (last run persists in the state file).
19
+ class Cron
20
+ SHORTCUTS ||= {
21
+ '@hourly' => '0 * * * *',
22
+ '@daily' => '0 0 * * *',
23
+ '@weekly' => '0 0 * * 0',
24
+ '@monthly' => '0 0 1 * *'
25
+ }.freeze
26
+
27
+ # [name, min, max] per cron field, in crontab order.
28
+ FIELDS ||= [
29
+ [:minute, 0, 59], [:hour, 0, 23], [:day, 1, 31],
30
+ [:month, 1, 12], [:weekday, 0, 7]
31
+ ].freeze
32
+
33
+ INTERVAL_UNITS ||= { 's' => 1, 'm' => 60, 'h' => 3600, 'd' => 86_400 }.freeze
34
+
35
+ attr_reader :source, :interval
36
+
37
+ def initialize(expr)
38
+ @source = expr.to_s.strip
39
+
40
+ if (m = @source.match(/\A(\d+)([smhd])\z/))
41
+ @interval = m[1].to_i * INTERVAL_UNITS[m[2]]
42
+ raise Hammer::Error, "cron: interval must be > 0 in #{@source.inspect}" if @interval.zero?
43
+ return
44
+ end
45
+
46
+ if @source.start_with?('@') && !SHORTCUTS.key?(@source)
47
+ raise Hammer::Error, "cron: unknown shortcut #{@source.inspect} (valid: #{SHORTCUTS.keys.join(', ')})"
48
+ end
49
+
50
+ parts = (SHORTCUTS[@source] || @source).split(/\s+/)
51
+ unless parts.size == 5
52
+ raise Hammer::Error, "cron: #{@source.inspect} - expected 5 fields ('*/10 * * * *'), " \
53
+ "an interval ('10s', '10m', '2h', '1d') or a shortcut (#{SHORTCUTS.keys.join(', ')})"
54
+ end
55
+
56
+ @fields = FIELDS.each_with_index.map { |(name, lo, hi), i| parse_field(name, parts[i], lo, hi) }
57
+ @dom_star = parts[2] == '*'
58
+ @dow_star = parts[4] == '*'
59
+ end
60
+
61
+ def interval?
62
+ !@interval.nil?
63
+ end
64
+
65
+ # True when local wall-clock time `t` (minute resolution) matches the
66
+ # cron expression. Vixie-cron day rule: when BOTH day-of-month and
67
+ # day-of-week are restricted, matching either one is enough;
68
+ # otherwise both must match.
69
+ def matches?(t)
70
+ return false if interval?
71
+ min, hour, dom, mon, dow = @fields
72
+ return false unless min[t.min] && hour[t.hour] && mon[t.month]
73
+ dom_ok = dom[t.day]
74
+ dow_ok = dow[t.wday]
75
+ @dom_star || @dow_star ? dom_ok && dow_ok : dom_ok || dow_ok
76
+ end
77
+
78
+ # Should the scheduler fire at `tick` (a boundary-aligned Time),
79
+ # given the persisted last run? Cron mode fires once per matching
80
+ # minute; interval mode fires when a full interval has elapsed (or
81
+ # the job never ran, so a fresh job gives immediate feedback on the
82
+ # first tick).
83
+ def due?(tick, last_run)
84
+ if interval?
85
+ last_run.nil? || tick.to_i - last_run.to_i >= @interval
86
+ else
87
+ matches?(tick) && (last_run.nil? || last_run.to_i / 60 < tick.to_i / 60)
88
+ end
89
+ end
90
+
91
+ # Next fire time strictly after `from`. Cron mode walks minute by
92
+ # minute - real-world expressions match within days, and the walk is
93
+ # ~100 hash lookups per simulated minute - capped at 500 days so an
94
+ # impossible date (a Feb 30 style expression) fails loudly instead of
95
+ # spinning forever.
96
+ def next_run(from = Time.now, last_run: nil)
97
+ if interval?
98
+ return from if last_run.nil?
99
+ Time.at(last_run.to_i + @interval)
100
+ else
101
+ t = Time.at((from.to_i / 60 + 1) * 60)
102
+ (500 * 24 * 60).times do
103
+ return t if matches?(t)
104
+ t += 60
105
+ end
106
+ raise Hammer::Error, "cron: #{@source.inspect} never matches"
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ # Parse one crontab field into a {int => true} lookup hash. Each
113
+ # comma-separated item is '*', 'a', 'a-b', '*/n' or 'a-b/n'.
114
+ # Weekday 7 is folded into 0 - both mean Sunday.
115
+ def parse_field(name, part, lo, hi)
116
+ seen = {}
117
+ part.split(',', -1).each do |item|
118
+ base, step = item.split('/', 2)
119
+ step = step ? parse_int(name, step, 1, hi) : 1
120
+ a, b =
121
+ if base == '*'
122
+ [lo, hi]
123
+ elsif base.include?('-')
124
+ base.split('-', 2).map { |v| parse_int(name, v, lo, hi) }
125
+ elsif item.include?('/')
126
+ raise Hammer::Error, "cron: #{name} - step needs '*' or a range: #{item.inspect}"
127
+ else
128
+ v = parse_int(name, base, lo, hi)
129
+ [v, v]
130
+ end
131
+ raise Hammer::Error, "cron: #{name} - inverted range #{item.inspect}" if a > b
132
+ (a..b).step(step) { |v| seen[name == :weekday && v == 7 ? 0 : v] = true }
133
+ end
134
+ seen
135
+ end
136
+
137
+ # Strict integer within [lo, hi], or a Hammer::Error naming the field
138
+ # so the message points straight at the typo.
139
+ def parse_int(name, str, lo, hi)
140
+ v = begin
141
+ Integer(str, 10)
142
+ rescue ArgumentError, TypeError
143
+ nil
144
+ end
145
+ unless v && v.between?(lo, hi)
146
+ raise Hammer::Error, "cron: bad #{name} value #{str.inspect} (allowed #{lo}-#{hi})"
147
+ end
148
+ v
149
+ end
150
+ end
151
+ end