Module: OpenLoam::Cron

Defined in:
lib/open_loam/cron.rb

Overview

A small, dependency-free "next run" calculator for a standard 5-field cron expression ("min hour dom month dow") plus an "interval:N" (every N seconds) form — same stdlib-only spirit as OpenLoam::Totp / OpenLoam::Base32. Timezone-aware; always returns UTC. Supports *, lists (1,15), ranges (1-5), and steps (*/15, 0-30/10), and the classic cron day-of-month / day-of-week OR quirk (when BOTH are restricted, a match on either fires).

Constant Summary collapse

RANGES =
{ min: 0..59, hour: 0..23, dom: 1..31, mon: 1..12, dow: 0..6 }.freeze
MAX_HORIZON =

years to search before giving up (covers Feb-29 schedules)

4

Class Method Summary collapse

Class Method Details

.day_matches?(time, dom, dow, dom_restricted, dow_restricted) ⇒ Boolean

Returns:

  • (Boolean)


58
59
60
61
62
63
64
65
66
67
68
# File 'lib/open_loam/cron.rb', line 58

def day_matches?(time, dom, dow, dom_restricted, dow_restricted)
  if dom_restricted && dow_restricted
    dom.include?(time.day) || dow.include?(time.wday) # OR quirk
  elsif dom_restricted
    dom.include?(time.day)
  elsif dow_restricted
    dow.include?(time.wday)
  else
    true
  end
end

.next_after(expr, from:, zone: "UTC") ⇒ Object

The next run strictly after from, evaluated in zone, returned in UTC.



17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/open_loam/cron.rb', line 17

def next_after(expr, from:, zone: "UTC")
  expr = expr.to_s.strip

  if expr.start_with?("interval:")
    seconds = expr.split(":", 2).last.to_i
    raise ArgumentError, "interval must be a positive number of seconds: #{expr.inspect}" unless seconds.positive?

    return (from + seconds).utc
  end

  next_cron(expr, from, zone)
end

.next_cron(expr, from, zone) ⇒ Object

Raises:

  • (ArgumentError)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/open_loam/cron.rb', line 30

def next_cron(expr, from, zone)
  fields = expr.split(/\s+/)
  raise ArgumentError, "cron must have 5 fields (min hour dom month dow): #{expr.inspect}" unless fields.size == 5

  min  = parse_field(fields[0], RANGES[:min])
  hour = parse_field(fields[1], RANGES[:hour])
  dom  = parse_field(fields[2], RANGES[:dom])
  mon  = parse_field(fields[3], RANGES[:mon])
  dow  = parse_field(fields[4], RANGES[:dow], dow: true)
  dom_restricted = fields[2] != "*"
  dow_restricted = fields[4] != "*"

  time = from.in_time_zone(zone).change(sec: 0) + 60
  limit = time + MAX_HORIZON.years

  while time < limit
    unless mon.include?(time.month) && day_matches?(time, dom, dow, dom_restricted, dow_restricted)
      time = (time + 1.day).change(hour: 0, min: 0) # skip the whole non-matching day
      next
    end
    return time.utc if hour.include?(time.hour) && min.include?(time.min)

    time += 60
  end

  raise ArgumentError, "no cron match within #{MAX_HORIZON} years for #{expr.inspect}"
end

.parse_field(field, range, dow: false) ⇒ Object

Parse one field into the set of matching integers.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/open_loam/cron.rb', line 71

def parse_field(field, range, dow: false)
  values = Set.new

  field.to_s.split(",").each do |part|
    base, step = part.split("/", 2)
    step = step ? Integer(step) : 1
    raise ArgumentError, "bad step in #{part.inspect}" unless step.positive?

    if base == "*"
      lo, hi = range.min, range.max
    elsif base.include?("-")
      lo, hi = base.split("-", 2).map { |n| Integer(n) }
    else
      lo = hi = Integer(base)
    end

    (lo..hi).step(step) do |value|
      value = 0 if dow && value == 7 # both 0 and 7 mean Sunday
      raise ArgumentError, "value #{value} out of range for #{field.inspect}" unless range.include?(value)

      values << value
    end
  end

  values
end