lib/rufus/sc/cronline.rb



#--
# Copyright (c) 2006-2011, John Mettraux, jmettraux@gmail.com
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Made in Japan.
#++

require 'tzinfo'


module Rufus

  #
  # A 'cron line' is a line in the sense of a crontab
  # (man 5 crontab) file line.
  #
  class CronLine

    # The string used for creating this cronline instance.
    #
    attr_reader :original

    attr_reader :seconds
    attr_reader :minutes
    attr_reader :hours
    attr_reader :days
    attr_reader :months
    attr_reader :weekdays
    attr_reader :monthdays
    attr_reader :timezone

    def initialize(line)

      super()

      @original = line

      items = line.split

      @timezone = (TZInfo::Timezone.get(items.last) rescue nil)
      items.pop if @timezone

      raise ArgumentError.new(
        "not a valid cronline : '#{line}'"
      ) unless items.length == 5 or items.length == 6

      offset = items.length - 5

      @seconds = offset == 1 ? parse_item(items[0], 0, 59) : [ 0 ]
      @minutes = parse_item(items[0 + offset], 0, 59)
      @hours = parse_item(items[1 + offset], 0, 24)
      @days = parse_item(items[2 + offset], 1, 31)
      @months = parse_item(items[3 + offset], 1, 12)
      @weekdays, @monthdays = parse_weekdays(items[4 + offset])
    end

    # Returns true if the given time matches this cron line.
    #
    def matches?(time)

      time = Time.at(time) unless time.kind_of?(Time)

      time = @timezone.utc_to_local(time.getutc) if @timezone

      return false unless sub_match?(time.sec, @seconds)
      return false unless sub_match?(time.min, @minutes)
      return false unless sub_match?(time.hour, @hours)
      return false unless date_match?(time)
      true
    end

    # Returns the next time that this cron line is supposed to 'fire'
    #
    # This is raw, 3 secs to iterate over 1 year on my macbook :( brutal.
    # (Well, I was wrong, takes 0.001 sec on 1.8.7 and 1.9.1)
    #
    # This method accepts an optional Time parameter. It's the starting point
    # for the 'search'. By default, it's Time.now
    #
    # Note that the time instance returned will be in the same time zone that
    # the given start point Time (thus a result in the local time zone will
    # be passed if no start time is specified (search start time set to
    # Time.now))
    #
    #   Rufus::CronLine.new('30 7 * * *').next_time(
    #     Time.mktime(2008, 10, 24, 7, 29))
    #   #=> Fri Oct 24 07:30:00 -0500 2008
    #
    #   Rufus::CronLine.new('30 7 * * *').next_time(
    #     Time.utc(2008, 10, 24, 7, 29))
    #   #=> Fri Oct 24 07:30:00 UTC 2008
    #
    #   Rufus::CronLine.new('30 7 * * *').next_time(
    #     Time.utc(2008, 10, 24, 7, 29)).localtime
    #   #=> Fri Oct 24 02:30:00 -0500 2008
    #
    # (Thanks to K Liu for the note and the examples)
    #
    def next_time(now=Time.now)

      time = @timezone ? @timezone.utc_to_local(now.getutc) : now

      time = time - time.usec * 1e-6 + 1
        # little adjustment before starting

      loop do

        unless date_match?(time)
          time += (24 - time.hour) * 3600 - time.min * 60 - time.sec
          next
        end
        unless sub_match?(time.hour, @hours)
          time += (60 - time.min) * 60 - time.sec
          next
        end
        unless sub_match?(time.min, @minutes)
          time += 60 - time.sec
          next
        end
        unless sub_match?(time.sec, @seconds)
          time += 1
          next
        end

        break
      end

      if @timezone
        time = @timezone.local_to_utc(time)
        time = time.getlocal unless now.utc?
      end

      time
    end

    # Returns an array of 6 arrays (seconds, minutes, hours, days,
    # months, weekdays).
    # This method is used by the cronline unit tests.
    #
    def to_array

      [
        @seconds,
        @minutes,
        @hours,
        @days,
        @months,
        @weekdays,
        @monthdays,
        @timezone ? @timezone.name : nil
      ]
    end

    private

    WEEKDAYS = %w[ sun mon tue wed thu fri sat ]

    def parse_weekdays(item)

      return nil if item == '*'

      items = item.downcase.split(',')

      weekdays = nil
      monthdays = nil

      items.each do |it|

        if it.match(/#[12345]$/)

          raise ArgumentError.new(
            "ranges are not supported for monthdays (#{it})"
          ) if it.index('-')

          (monthdays ||= []) << it
        else

          WEEKDAYS.each_with_index { |a, i| it.gsub!(/#{a}/, i.to_s) }

          its = it.index('-') ? parse_range(it, 0, 7) : [ Integer(it) ]
          its = its.collect { |i| i == 7 ? 0 : i }

          (weekdays ||= []).concat(its)
        end
      end

      weekdays = weekdays.uniq if weekdays

      [ weekdays, monthdays ]
    end

    def parse_item(item, min, max)

      return nil if item == '*'
      return parse_list(item, min, max) if item.index(',')
      return parse_range(item, min, max) if item.index('*') or item.index('-')

      i = Integer(item)

      i = min if i < min
      i = max if i > max

      [ i ]
    end

    def parse_list(item, min, max)

      item.split(',').inject([]) { |r, i|
        r.push(parse_range(i, min, max))
      }.flatten
    end

    def parse_range(item, min, max)

      i = item.index('-')
      j = item.index('/')

      return item.to_i if (not i and not j)

      inc = j ? Integer(item[j+1..-1]) : 1

      istart = -1
      iend = -1

      if i

        istart = Integer(item[0..i - 1])

        if j
          iend = Integer(item[i + 1..j - 1])
        else
          iend = Integer(item[i + 1..-1])
        end

      else # case */x

        istart = min
        iend = max
      end

      istart = min if istart < min
      iend = max if iend > max

      result = []

      value = istart
      loop do
        result << value
        value = value + inc
        break if value > iend
      end

      result
    end

    def sub_match?(value, values)

      values.nil? || values.include?(value)
    end

    def monthday_match(monthday, monthdays)

      return true if monthdays == nil
      return true if monthdays.include?(monthday)
    end

    def date_match?(date)

      return false unless sub_match?(date.day, @days)
      return false unless sub_match?(date.month, @months)
      return false unless sub_match?(date.wday, @weekdays)
      return false unless sub_match?(CronLine.monthday(date), @monthdays)
      true
    end

    DAY_IN_SECONDS = 7 * 24 * 3600

    def self.monthday(date)

      count = 1
      date2 = date.dup

      loop do
        date2 = date2 - DAY_IN_SECONDS
        break if date2.month != date.month
        count = count + 1
      end

      "#{WEEKDAYS[date.wday]}##{count}"
    end
  end
end