diff --git a/README.md b/README.md index fa8a2a0..fb79407 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,12 @@ Montrose.every(:month, mday: [2, 15], total: 10) # monthly on the first and last day of the month for 10 occurrences Montrose.monthly(mday: [1, -1], total: 10) +# monthly on the 30th unless fewer days +Montrose.monthly(mday: { default: 30, fallback: -1 }) + +# monthly on the 25th except in december +Montrose.monthly(mday: { default: 25, december: 20 }) + # every 18 months on the 10th thru 15th of the month for 10 occurrences Montrose.every(18.months, total: 10, mday: 10..15) diff --git a/lib/montrose/options.rb b/lib/montrose/options.rb index 34a9736..fc717a3 100644 --- a/lib/montrose/options.rb +++ b/lib/montrose/options.rb @@ -206,8 +206,8 @@ def day=(days) @day = Day.parse(days) end - def mday=(mdays) - @mday = MonthDay.parse(mdays) + def mday=(mday_arg) + @mday = decompose_mday_arg(mday_arg) end def yday=(ydays) @@ -370,5 +370,38 @@ def end_of_day def beginning_of_day @beginning_of_day ||= time_of_day_parse(Time.now.beginning_of_day) end + + def decompose_mday_arg(mday_arg) + case mday_arg + when Hash + return nil unless mday_arg[:default].present? + { + default: MonthDay.parse(mday_arg[:default]), + overrides: flatten_mday_arg(mday_arg), + fallback: single_day(mday_arg[:fallback]) + } + else + {default: MonthDay.parse(mday_arg), overrides: {}, fallback: nil} + end + end + + def flatten_mday_arg(mday_arg) + mday_arg.except(:default, :overrides, :fallback).each_with_object({}) do |(months, day), result| + case months + when Array + months.each { |month| result[month] = single_day(day) } + else + result[months] = single_day(day) + end + end + end + + def single_day(day_number) + return nil unless day_number + raise ConfigurationError, "mday override #{day_number} must be an integer" unless day_number.is_a?(Integer) + MonthDay.assert(day_number) + + day_number + end end end diff --git a/lib/montrose/rule/day_of_month.rb b/lib/montrose/rule/day_of_month.rb index 01da054..3c6550c 100644 --- a/lib/montrose/rule/day_of_month.rb +++ b/lib/montrose/rule/day_of_month.rb @@ -11,22 +11,46 @@ def self.apply_options(opts) # Initializes rule # - # @param [Array] days - valid days of month, i.e. [1, 2, -1] + # @param [Hash] opts `mday` valid days of month, and `skip_months` options # - def initialize(days) - @days = days + def initialize(opts) + @days = opts.fetch(:default) + @overrides = opts.fetch(:overrides, {}) + @fallback = opts.fetch(:fallback, nil) end def include?(time) - @days.include?(time.mday) || included_from_end_of_month?(time) + return override?(time) if has_override?(month_name(time)) + + @days.include?(time.mday) || included_from_end_of_month?(time) || fallback?(time) end private # matches days specified at negative numbers - def included_from_end_of_month?(time) + def included_from_end_of_month?(time, days = @days) month_days = ::Montrose::Utils.days_in_month(time.month, time.year) # given by activesupport - @days.any? { |d| month_days + d + 1 == time.mday } + days.any? { |d| month_days + d + 1 == time.mday } + end + + def has_override?(month) + @overrides.key?(month) + end + + def override?(time) + return false if @overrides.blank? + + time.day == @overrides[month_name(time)] + end + + def month_name(time) + time.strftime("%B").downcase.to_sym + end + + def fallback?(time) + return false unless @fallback.present? + + time.day == @fallback || included_from_end_of_month?(time, [@fallback]) end end end diff --git a/spec/montrose/options_spec.rb b/spec/montrose/options_spec.rb index 8770f98..e40d9a3 100644 --- a/spec/montrose/options_spec.rb +++ b/spec/montrose/options_spec.rb @@ -423,29 +423,57 @@ it "can be set" do options[:mday] = [1, 20, 31] - _(options.mday).must_equal [1, 20, 31] - _(options[:mday]).must_equal [1, 20, 31] + _(options.mday).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil}) + end + + it "can be set to a hash" do + options[:mday] = {default: [1, 20, 31]} + + _(options.mday).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil}) end it "casts to element to array" do options[:mday] = 1 - _(options.mday).must_equal [1] - _(options[:mday]).must_equal [1] + _(options.mday).must_equal({default: [1], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [1], overrides: {}, fallback: nil}) + end + + it "casts default element to array" do + options[:mday] = {default: 1} + + _(options.mday).must_equal({default: [1], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [1], overrides: {}, fallback: nil}) end it "allows negative numbers" do - options[:yday] = [-1] + options[:mday] = [-1] - _(options.yday).must_equal [-1] - _(options[:yday]).must_equal [-1] + _(options.mday).must_equal({default: [-1], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [-1], overrides: {}, fallback: nil}) + end + + it "allows default negative numbers" do + options[:mday] = {default: [-1]} + + _(options.mday).must_equal({default: [-1], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [-1], overrides: {}, fallback: nil}) end it "casts range to array" do options[:mday] = 6..8 - _(options.mday).must_equal [6, 7, 8] - _(options[:mday]).must_equal [6, 7, 8] + _(options.mday).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil}) + end + + it "casts default range to array" do + options[:mday] = {default: 6..8} + + _(options.mday).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil}) + _(options[:mday]).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil}) end it "casts nil to empty array" do @@ -455,9 +483,64 @@ _(options[:day]).must_be_nil end + it "casts default nil to empty array" do + options[:mday] = {default: nil} + + _(options.mday).must_be_nil + _(options[:mday]).must_be_nil + end + it "raises for out of range" do _(-> { options[:mday] = [1, 100] }).must_raise end + + it "raises for default out of range" do + _(-> { options[:mday] = {default: [1, 100]} }).must_raise + end + + it "raises for array override" do + _(-> { options[:mday] = {default: 31, february: [28, 29]} }).must_raise + end + + it "raises for array fallback" do + _(-> { options[:mday] = {default: 31, fallback: [28, 29]} }).must_raise + end + + it "allows negative overrides" do + options[:mday] = {default: 31, february: -1} + + _(options.mday).must_equal({default: [31], overrides: {february: -1}, fallback: nil}) + _(options[:mday]).must_equal({default: [31], overrides: {february: -1}, fallback: nil}) + end + + it "allows negative fallback" do + options[:mday] = {default: 31, fallback: -1} + + _(options.mday).must_equal({default: [31], overrides: {}, fallback: -1}) + _(options[:mday]).must_equal({default: [31], overrides: {}, fallback: -1}) + end + + it "raises for override out of range" do + _(-> { options[:mday] = {default: 31, february: 100} }).must_raise + end + + it "raises for fallback out of range" do + _(-> { options[:mday] = {default: 31, fallback: 100} }).must_raise + end + + it "collects overrides" do + options[:mday] = {default: 31, september: 30, february: 28} + + _(options.mday).must_equal({default: [31], overrides: {september: 30, february: 28}, fallback: nil}) + _(options[:mday]).must_equal({default: [31], overrides: {september: 30, february: 28}, fallback: nil}) + end + + it "flattens overrides" do + options[:mday] = {:default => 31, [:september, :april, :june, :november] => 30, :february => 28} + + _(options.mday).must_equal({default: [31], overrides: {september: 30, april: 30, june: 30, november: 30, february: 28}, fallback: nil}) + _(options[:mday]).must_equal({default: [31], overrides: {september: 30, april: 30, june: 30, november: 30, february: 28}, fallback: nil}) + end end describe "#yday" do @@ -767,7 +850,7 @@ options[:on] = {friday: 13} _(options[:day]).must_equal [5] - _(options[:mday]).must_equal [13] + _(options[:mday]).must_equal({default: [13], overrides: {}, fallback: nil}) _(options[:on]).must_equal(friday: 13) end @@ -776,7 +859,7 @@ options[:on] = {tuesday: 2..8} _(options[:day]).must_equal [2] - _(options[:mday]).must_equal((2..8).to_a) + _(options[:mday]).must_equal({default: (2..8).to_a, overrides: {}, fallback: nil}) _(options[:month]).must_equal [11] end @@ -784,7 +867,7 @@ options[:on] = {january: 31} _(options[:month]).must_equal [1] - _(options[:mday]).must_equal [31] + _(options[:mday]).must_equal({default: [31], overrides: {}, fallback: nil}) end it { _(-> { options[:on] = -3 }).must_raise Montrose::ConfigurationError } diff --git a/spec/montrose/rule/day_of_month_spec.rb b/spec/montrose/rule/day_of_month_spec.rb index ff24faa..16c1457 100644 --- a/spec/montrose/rule/day_of_month_spec.rb +++ b/spec/montrose/rule/day_of_month_spec.rb @@ -3,7 +3,9 @@ require "spec_helper" describe Montrose::Rule::DayOfMonth do - let(:rule) { Montrose::Rule::DayOfMonth.new([1, 10, -1]) } + let(:rule) { Montrose::Rule::DayOfMonth.new(default: [1, 10, -1], overrides: {}, fallback: nil) } + let(:fallback_rule) { Montrose::Rule::DayOfMonth.new(default: [31], overrides: {}, fallback: -1) } + let(:override_rule) { Montrose::Rule::DayOfMonth.new(default: [15], overrides: {february: 28, september: 30, november: 30, april: 30}, fallback: nil) } describe "#include?" do it { assert rule.include?(Time.local(2016, 1, 1)) } @@ -14,6 +16,22 @@ it { refute rule.include?(Time.local(2015, 1, 2)) } it { refute rule.include?(Time.local(2015, 1, 30)) } it { refute rule.include?(Time.local(2015, 2, 27)) } + + it { assert fallback_rule.include?(Time.local(2016, 1, 31)) } + it { assert fallback_rule.include?(Time.local(2015, 2, 28)) } + it { assert fallback_rule.include?(Time.local(2016, 2, 29)) } + it { assert fallback_rule.include?(Time.local(2016, 4, 30)) } + it { refute fallback_rule.include?(Time.local(2016, 1, 30)) } + + it { assert override_rule.include?(Time.local(2016, 1, 15)) } + it { assert override_rule.include?(Time.local(2016, 2, 28)) } + it { assert override_rule.include?(Time.local(2016, 9, 30)) } + it { assert override_rule.include?(Time.local(2016, 11, 30)) } + it { assert override_rule.include?(Time.local(2016, 4, 30)) } + it { refute override_rule.include?(Time.local(2016, 9, 15)) } + it { refute override_rule.include?(Time.local(2016, 11, 15)) } + it { refute override_rule.include?(Time.local(2016, 4, 15)) } + it { refute override_rule.include?(Time.local(2016, 2, 15)) } end describe "#continue?" do