diff --git a/cron.go b/cron.go index 60f21e0..7db53cc 100644 --- a/cron.go +++ b/cron.go @@ -2,7 +2,7 @@ package gocron import "time" -type timeUnit interface { +type TimeUnit interface { // Next returns the next iteration of a schedule and `true` when valid, // otherwise it returns a time after `next` and `false`. Next(next time.Time) (time.Time, bool) @@ -10,7 +10,7 @@ type timeUnit interface { // Schedule is a representation of a Cron expression. type Schedule struct { - timeUnits []timeUnit + timeUnits []TimeUnit } // Parse returns a schedule from the Cron expression and returns an error if the diff --git a/cron_test.go b/cron_test.go index 5b403da..1b707af 100644 --- a/cron_test.go +++ b/cron_test.go @@ -46,6 +46,7 @@ func TestSchedule_Next(t *testing.T) { {"* * * * * 5L", time.Date(2000, time.March, 31, 0, 0, 0, 0, time.UTC)}, {"* * * * * 4L", time.Date(2000, time.March, 30, 0, 0, 0, 0, time.UTC)}, {"* * * 31 * L", time.Date(2001, time.March, 31, 0, 0, 0, 0, time.UTC)}, + {"* * * ? * 0L", time.Date(2000, time.March, 26, 0, 0, 0, 0, time.UTC)}, } for _, v := range vectors { diff --git a/error.go b/error.go new file mode 100644 index 0000000..6921209 --- /dev/null +++ b/error.go @@ -0,0 +1,45 @@ +package gocron + +import ( + "errors" + "fmt" +) + +var ( + ErrMultipleNotSpecified = errors.New("only one `?` is supported") +) + +type TimeUnitError struct { + inner error + kind TimeUnitKind +} + +func newTimeUnitErr(kind TimeUnitKind, inner error) TimeUnitError { + return TimeUnitError{inner: inner, kind: kind} +} + +func (e TimeUnitError) Error() string { + return fmt.Sprintf("time unit `%s` malformed: %s", e.kind, e.inner) +} + +func (e TimeUnitError) Is(err error) bool { + tue, ok := err.(TimeUnitError) + return ok && tue.kind == e.kind && errors.Is(tue.inner, e.inner) +} + +type TimeUnitKind int + +const ( + Seconds TimeUnitKind = iota + Minutes + Hours + Days + Months + WeekDays +) + +var kinds = []string{"seconds", "minutes", "hours", "days", "months", "week days"} + +func (k TimeUnitKind) String() string { + return kinds[k] +} diff --git a/parser.go b/parser.go index aea3ccb..1d47f01 100644 --- a/parser.go +++ b/parser.go @@ -29,30 +29,34 @@ func (p Parser) Parse(expression string) (schedule Schedule, err error) { weekdays, err := p.parse(matches[5], convertWeekDay, 0, 6) if err != nil { - return schedule, err + return schedule, newTimeUnitErr(WeekDays, err) } months, err := p.parse(matches[4], convertUnit, 1, 12) if err != nil { - return schedule, err + return schedule, newTimeUnitErr(Months, err) } days, err := p.parse(matches[3], convertWithLastDayOfMonth, 1, 31) if err != nil { - return schedule, err + return schedule, newTimeUnitErr(Days, err) } hours, err := p.parse(matches[2], convertUnit, 0, 23) if err != nil { - return schedule, err + return schedule, newTimeUnitErr(Hours, err) } minutes, err := p.parse(matches[1], convertUnit, 0, 59) if err != nil { - return schedule, err + return schedule, newTimeUnitErr(Minutes, err) } seconds, err := p.parse(matches[0], convertUnit, 0, 59) if err != nil { - return schedule, err + return schedule, newTimeUnitErr(Seconds, err) } - schedule.timeUnits = []timeUnit{ + if isNotSpecified(weekdays) && isNotSpecified(days) { + err = ErrMultipleNotSpecified + } + + schedule.timeUnits = []TimeUnit{ monthTimeUnit{units: months}, dayTimeUnit{units: days}, weekdayTimeUnit{units: weekdays}, @@ -68,6 +72,10 @@ func (Parser) parse(expr string, convFn ConvertFn, min, max int) (fields []ExprF if expr == "*" { return } + if expr == "?" { + fields = append(fields, notSpecifiedExpr{}) + return + } for _, u := range strings.Split(expr, ",") { var field ExprField @@ -134,6 +142,28 @@ func parseInterval(expr string, convFn ConvertFn, min, max int) (i intervalExpr, return } +// isNotSpecified returns true if any of the field is not specified. +func isNotSpecified(fields []ExprField) bool { + for _, field := range fields { + if _, ok := field.(notSpecifiedExpr); ok { + return true + } + } + return false +} + +// notSpecifiedExpr is an expression field that represent a question mark `?`, +// or in other words a value not specified. +type notSpecifiedExpr struct{} + +func (notSpecifiedExpr) Compare(_ time.Time, other int) Ordering { + return OrderingEqual +} + +func (notSpecifiedExpr) Value(_ time.Time, other int) int { + return other +} + // unitExpr is an expression field that represents a single possible value. type unitExpr int diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..ea39d37 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,82 @@ +package gocron + +import ( + "errors" + "testing" +) + +func TestParser_Parse_refusesTwiceNotSpecified(t *testing.T) { + _, err := Parser{}.Parse("* * * ? * ?") + requireErrorIs(t, err, ErrMultipleNotSpecified) +} + +func TestParser_Parse_abortsOnMalformedSeconds(t *testing.T) { + _, err := Parser{}.Parse("a * * * * *") + + var e TimeUnitError + requireErrorAs(t, err, &e) + requireSameKind(t, e.kind, Seconds) +} + +func TestParser_Parse_abortsOnMalformedMinutes(t *testing.T) { + _, err := Parser{}.Parse("* a * * * *") + + var e TimeUnitError + requireErrorAs(t, err, &e) + requireSameKind(t, e.kind, Minutes) +} + +func TestParser_Parse_abortsOnMalformedHours(t *testing.T) { + _, err := Parser{}.Parse("* * a * * *") + + var e TimeUnitError + requireErrorAs(t, err, &e) + requireSameKind(t, e.kind, Hours) +} + +func TestParser_Parse_abortsOnMalformedDays(t *testing.T) { + _, err := Parser{}.Parse("* * * a * *") + + var e TimeUnitError + requireErrorAs(t, err, &e) + requireSameKind(t, e.kind, Days) +} + +func TestParser_Parse_abortsOnMalformedMonths(t *testing.T) { + _, err := Parser{}.Parse("* * * * a *") + + var e TimeUnitError + requireErrorAs(t, err, &e) + requireSameKind(t, e.kind, Months) +} + +func TestParser_Parse_abortsOnMalformedWeekDays(t *testing.T) { + _, err := Parser{}.Parse("* * * * * a") + + var e TimeUnitError + requireErrorAs(t, err, &e) + requireSameKind(t, e.kind, WeekDays) +} + +// --- Utilities + +func requireErrorIs(t testing.TB, err, target error) { + t.Helper() + if !errors.Is(err, target) { + t.Fatalf("expected error: %#v, found %#v", target, err) + } +} + +func requireErrorAs(t testing.TB, err error, target any) { + t.Helper() + if !errors.As(err, target) { + t.Fatalf("expected error of kind: %T, found %#v", target, err) + } +} + +func requireSameKind(t testing.TB, a, b TimeUnitKind) { + t.Helper() + if a != b { + t.Fatalf("%s != %s", a, b) + } +} diff --git a/time-unit.go b/time-unit.go index a2577de..4442d93 100644 --- a/time-unit.go +++ b/time-unit.go @@ -8,6 +8,7 @@ type secTimeUnit struct { units []ExprField } +// Next implements TimeUnit. func (s secTimeUnit) Next(next time.Time) (time.Time, bool) { if len(s.units) == 0 { // Expression is `*`. @@ -32,6 +33,7 @@ type minTimeUnit struct { fields []ExprField } +// Next implements TimeUnit. func (m minTimeUnit) Next(next time.Time) (time.Time, bool) { if len(m.fields) == 0 { // Expression is `*`. @@ -56,6 +58,7 @@ type hourTimeUnit struct { fields []ExprField } +// Next implements TimeUnit. func (h hourTimeUnit) Next(next time.Time) (time.Time, bool) { if len(h.fields) == 0 { // Expression is `*`. @@ -80,6 +83,7 @@ type dayTimeUnit struct { units []ExprField } +// Next implements TimeUnit. func (d dayTimeUnit) Next(next time.Time) (time.Time, bool) { if len(d.units) == 0 { // Expression is `*`. @@ -110,6 +114,7 @@ type monthTimeUnit struct { units []ExprField } +// Next implements TimeUnit. func (m monthTimeUnit) Next(next time.Time) (time.Time, bool) { if len(m.units) == 0 { // Expression is `*`. @@ -134,6 +139,7 @@ type weekdayTimeUnit struct { units []ExprField } +// Next implements TimeUnit. func (wd weekdayTimeUnit) Next(next time.Time) (time.Time, bool) { if len(wd.units) == 0 { return next, true