diff --git a/flags/flags_test.go b/flags/flags_test.go new file mode 100644 index 0000000..e1b7394 --- /dev/null +++ b/flags/flags_test.go @@ -0,0 +1,14 @@ +// Common test helpers +package flags + +func equal(a []int, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/flags/intlist.go b/flags/intlist.go index 01288ac..f8a9e2c 100644 --- a/flags/intlist.go +++ b/flags/intlist.go @@ -40,6 +40,7 @@ func (l *intlist) Set(s string) error { } // Intlist defines a flag for a comma-separated list of integers. +// min and max restrict the size of the integer values. // Call the returned function after flag.Parse to get the value. func Intlist(name, usage string, min, max int) func() []int { l := &intlist{min: min, max: max} diff --git a/flags/intlist_test.go b/flags/intlist_test.go index 126653c..754e014 100644 --- a/flags/intlist_test.go +++ b/flags/intlist_test.go @@ -44,15 +44,3 @@ Got %v`, i, tt.text, tt.parsed, l.list) t.Errorf("Non empty String() output: %s", (&intlist{}).String()) } } - -func equal(a []int, b []int) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/flags/monthlist.go b/flags/monthlist.go index 22c2bed..66ad7dd 100644 --- a/flags/monthlist.go +++ b/flags/monthlist.go @@ -39,9 +39,9 @@ func (l *monthlist) Set(s string) error { // Monthlist defines a flag for a comma-separated list of months. // Valid values are between 1 and 12. // Call the returned function after flag.Parse to get the value. -func Monthlist(name, usage string) func() []time.Month { +func Monthlist(name string) func() []time.Month { l := &monthlist{} - flag.Var(l, name, usage) + flag.Var(l, name, "1 to 12") return func() []time.Month { return l.list } diff --git a/flags/weekdaylist.go b/flags/weekdaylist.go index df51aa6..264cbe7 100644 --- a/flags/weekdaylist.go +++ b/flags/weekdaylist.go @@ -53,9 +53,9 @@ func (l *weekdaylist) Set(s string) error { // Weekdaylist defines a flag for a comma-separated list of week days. // Valid values are mo, tu, we, th, fr, sa, su. // Call the returned function after flag.Parse to get the value. -func Weekdaylist(name, usage string) func() []time.Weekday { +func Weekdaylist(name string) func() []time.Weekday { l := &weekdaylist{} - flag.Var(l, name, usage) + flag.Var(l, name, "mo,tu,we,th,fr,sa,su") return func() []time.Weekday { return l.list } diff --git a/flags/yearlist.go b/flags/yearlist.go new file mode 100644 index 0000000..20d9a50 --- /dev/null +++ b/flags/yearlist.go @@ -0,0 +1,42 @@ +package flags + +import ( + "flag" + "fmt" + "strconv" + "strings" +) + +type yearlist struct { + list []int +} + +func (l *yearlist) String() string { + s := make([]string, len(l.list)) + for i := range l.list { + s[i] = strconv.Itoa(l.list[i]) + } + return strings.Join(s, ",") +} + +func (l *yearlist) Set(s string) error { + parts := strings.Split(s, ",") + for i, p := range parts { + x, err := strconv.Atoi(p) + if err != nil { + return fmt.Errorf("no integer at index %d: %s", i, p) + } + l.list = append(l.list, x) + } + return nil +} + +// Yearlist defines a flag for a comma-separated list of integers. +// Call the returned function after flag.Parse to get the value. +func Yearlist(name string) func() []int { + l := &yearlist{} + flag.Var(l, name, "list of years") + return func() []int { + return l.list + } +} diff --git a/flags/yearlist_test.go b/flags/yearlist_test.go new file mode 100644 index 0000000..476c65c --- /dev/null +++ b/flags/yearlist_test.go @@ -0,0 +1,44 @@ +package flags + +import "testing" + +func TestYearlist(t *testing.T) { + tests := []struct { + text string + parsed []int + invalid bool + }{ + {text: "0,2000", parsed: []int{0, 2000}}, + {text: "2017", parsed: []int{2017}}, + {text: "1999,57", parsed: []int{1999, 57}}, + {text: "-2000,2000", parsed: []int{-2000, 2000}}, + {text: "", invalid: true}, + {text: "-1, 2", invalid: true}, + {text: "hello", invalid: true}, + } + + for i, tt := range tests { + l := yearlist{} + if err := l.Set(tt.text); err != nil { + if !tt.invalid { + t.Errorf("parsing %s failed unexpectedly: %v", tt.text, err) + } + continue + } + if tt.invalid { + t.Errorf("parsing %s should have failed", tt.text) + continue + } + if !equal(l.list, tt.parsed) { + t.Errorf(` +%d. +Input: %s +Expected: %v +Got %v`, i, tt.text, tt.parsed, l.list) + } + } + + if (&yearlist{}).String() != "" { + t.Errorf("Non empty String() output: %s", (&yearlist{}).String()) + } +} diff --git a/main.go b/main.go index 80a31a5..cc145ec 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,10 @@ All flags are optional and can be used in any combination. The condition flags take one or more value each. Values are separated by comma. +Note that conditions match not the current, but the next possible match. +When the current date is March 2017 +and you run 'sleepto -month 3' the execution time is March 1, 2018. + A command can be specified optionally. All arguments following the command are passed to it. @@ -52,8 +56,9 @@ func main() { var ( silent = flag.Bool("silent", false, "Suppress all output") versionFlag = flag.Bool("version", false, "Print binary version") - month = flags.Monthlist("month", "1 to 12") - weekday = flags.Weekdaylist("weekday", "mo,tu,we,th,fr,sa,su") + year = flags.Yearlist("year") + month = flags.Monthlist("month") + weekday = flags.Weekdaylist("weekday") day = flags.Intlist("day", "1 to 31", 1, 31) hour = flags.Intlist("hour", "0 to 23", 0, 23) minute = flags.Intlist("minute", "0 to 59", 0, 59) @@ -75,6 +80,7 @@ func main() { now := time.Now() next := match.Next(now, match.Condition{ + Year: year(), Month: month(), Weekday: weekday(), Day: day(), @@ -88,6 +94,11 @@ func main() { flag.Usage() os.Exit(1) } + // No matching conditions + if next.IsZero() { + fmt.Fprintf(os.Stderr, "year must be > current year (%d)", now.Year()) + os.Exit(1) + } if !*silent { fmt.Fprintf(os.Stderr, "sleeping until: %s\n", next.Format(time.RFC1123)) diff --git a/main_test.go b/main_test.go index 5d047e8..e23bff7 100644 --- a/main_test.go +++ b/main_test.go @@ -62,6 +62,28 @@ func TestEcho(t *testing.T) { } } +func TestInvalid(t *testing.T) { + done := make(chan struct{}) + + // Run binary + go func() { + now := time.Now() + y := strconv.Itoa(now.Year()) + cmd := exec.Command(tmpbin, "-year", y) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + equal(t, "exit status 1", err.Error(), "exit code") + equal(t, "", string(out), "stdout") + equal(t, "year must be > current year ("+y+")", stderr.String(), "stderr") + close(done) + }() + + if err := timing(done, 0, 1); err != nil { + t.Error(err) + } +} + func TestAlarm(t *testing.T) { done := make(chan struct{}) m := strconv.Itoa(int(time.Now().Month())) diff --git a/match/next.go b/match/next.go index 1a7266f..70eaa5f 100644 --- a/match/next.go +++ b/match/next.go @@ -9,6 +9,7 @@ import "time" // For each field one value of the list has // to match to find a match for the condition. type Condition struct { + Year []int Month []time.Month Day []int // 1 to 31 Weekday []time.Weekday @@ -18,7 +19,13 @@ type Condition struct { } // Next finds the next time the passed condition matches. +// Returns an empty time.Time when no possible match can be found. +// This can only happen when there is no future year condition. +// Use .IsZero() to test if result is empty. func Next(start time.Time, c Condition) time.Time { + if noMatch(start, c) { + return time.Time{} + } t := setBase(start, c) // Stop when when no condition if t.Equal(start) { @@ -29,16 +36,18 @@ func Next(start time.Time, c Condition) time.Time { // Adjust biggest unit first. for { switch { + case wrong(c.Year, t.Year()): + t = addYear(t) case wrongMonth(c.Month, t.Month()): - t = t.AddDate(0, 1, 1-t.Day()).Truncate(time.Hour * 24) + t = addMonth(t) case wrong(c.Day, t.Day()) || wrongWeekday(c.Weekday, t.Weekday()): - t = t.AddDate(0, 0, 1).Truncate(time.Hour * 24) + t = addDay(t) case wrong(c.Hour, t.Hour()): - t = t.Add(time.Hour).Truncate(time.Hour) + t = addHour(t) case wrong(c.Minute, t.Minute()): - t = t.Add(time.Minute).Truncate(time.Minute) + t = addMinute(t) case wrong(c.Second, t.Second()): - t = t.Add(time.Second).Truncate(time.Second) + t = addSecond(t) default: // Found matching time. return t @@ -51,20 +60,31 @@ func Next(start time.Time, c Condition) time.Time { func setBase(t time.Time, c Condition) time.Time { switch { case len(c.Second) > 0: - return t.Add(time.Second).Truncate(time.Second) + return addSecond(t) case len(c.Minute) > 0: - return t.Add(time.Minute).Truncate(time.Minute) + return addMinute(t) case len(c.Hour) > 0: - return t.Add(time.Hour).Truncate(time.Hour) + return addHour(t) case len(c.Day) > 0 || len(c.Weekday) > 0: - return t.AddDate(0, 0, 1).Truncate(time.Hour * 24) + return addDay(t) case len(c.Month) > 0: - return t.AddDate(0, 1, 1-t.Day()).Truncate(time.Hour * 24) + return addMonth(t) + case len(c.Year) > 0: + return addYear(t) default: return t } } +func noMatch(t time.Time, c Condition) bool { + for _, y := range c.Year { + if y <= t.Year() { + return true + } + } + return false +} + func wrong(xs []int, x int) bool { if len(xs) == 0 { return false @@ -92,3 +112,27 @@ func wrongWeekday(ds []time.Weekday, d time.Weekday) bool { } return wrong(xs, int(d)) } + +func addYear(t time.Time) time.Time { + return t.AddDate(1, 1-int(t.Month()), 1-t.Day()).Truncate(time.Hour * 24) +} + +func addMonth(t time.Time) time.Time { + return t.AddDate(0, 1, 1-t.Day()).Truncate(time.Hour * 24) +} + +func addDay(t time.Time) time.Time { + return t.AddDate(0, 0, 1).Truncate(time.Hour * 24) +} + +func addHour(t time.Time) time.Time { + return t.Add(time.Hour).Truncate(time.Hour) +} + +func addMinute(t time.Time) time.Time { + return t.Add(time.Minute).Truncate(time.Minute) +} + +func addSecond(t time.Time) time.Time { + return t.Add(time.Second).Truncate(time.Second) +} diff --git a/match/next_test.go b/match/next_test.go index 8f6c864..edb56fe 100644 --- a/match/next_test.go +++ b/match/next_test.go @@ -20,6 +20,24 @@ func TestNext(t *testing.T) { time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), }, + { + match.Condition{ + Year: []int{2020}, + Minute: []int{55, 13}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Date(2020, 1, 1, 0, 13, 0, 0, time.UTC), + }, + + { + match.Condition{ + Year: []int{2014, 2016}, + Month: []time.Month{1, 6}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Time{}, + }, + { match.Condition{ Month: []time.Month{1, 6}, @@ -56,6 +74,52 @@ func TestNext(t *testing.T) { time.Date(2018, 1, 15, 13, 13, 1, 0, time.UTC), }, + { + match.Condition{ + Year: []int{2019, 2020}, + Month: []time.Month{1, 6}, + Day: []int{30, 15}, + Weekday: []time.Weekday{time.Monday, time.Tuesday}, + Hour: []int{13}, + Minute: []int{55, 13}, + Second: []int{44, 1, 13}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Date(2019, 1, 15, 13, 13, 1, 0, time.UTC), + }, + + { + match.Condition{ + Year: []int{2025}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + + { + match.Condition{ + Year: []int{2017}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Time{}, + }, + + { + match.Condition{ + Year: []int{1990}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Time{}, + }, + + { + match.Condition{ + Month: []time.Month{time.February}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Date(2018, 2, 1, 0, 0, 0, 0, time.UTC), + }, + { match.Condition{ Month: []time.Month{time.March}, @@ -74,18 +138,26 @@ func TestNext(t *testing.T) { { match.Condition{ - Day: []int{3, 1}, + Day: []int{4}, }, time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), - time.Date(2017, 3, 1, 0, 0, 0, 0, time.UTC), + time.Date(2017, 3, 4, 0, 0, 0, 0, time.UTC), }, { match.Condition{ Weekday: []time.Weekday{time.Friday}, }, + time.Date(2017, 2, 10, 10, 7, 5, 8, time.UTC), + time.Date(2017, 2, 17, 0, 0, 0, 0, time.UTC), + }, + + { + match.Condition{ + Hour: []int{10}, + }, time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), - time.Date(2017, 2, 10, 0, 0, 0, 0, time.UTC), + time.Date(2017, 2, 5, 10, 0, 0, 0, time.UTC), }, { @@ -96,6 +168,14 @@ func TestNext(t *testing.T) { time.Date(2017, 2, 4, 18, 0, 0, 0, time.UTC), }, + { + match.Condition{ + Minute: []int{7}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Date(2017, 2, 4, 11, 7, 0, 0, time.UTC), + }, + { match.Condition{ Minute: []int{55, 13}, @@ -104,6 +184,14 @@ func TestNext(t *testing.T) { time.Date(2017, 2, 4, 10, 13, 0, 0, time.UTC), }, + { + match.Condition{ + Second: []int{5}, + }, + time.Date(2017, 2, 4, 10, 7, 5, 8, time.UTC), + time.Date(2017, 2, 4, 10, 8, 5, 0, time.UTC), + }, + { match.Condition{ Second: []int{3, 4}, diff --git a/readme.md b/readme.md index c747a21..6769a73 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,10 @@ Thanks to [runwhen](http://code.dogmap.org/runwhen/) for inspiration. The condition flags take one or more value each. Values are separated by comma. + Note that conditions match not the current, but the next possible match. + When the current date is March 2017 + and you run 'sleepto -month 3' the execution time is March 1, 2018. + A command can be specified optionally. All arguments following the command are passed to it. @@ -46,21 +50,23 @@ Thanks to [runwhen](http://code.dogmap.org/runwhen/) for inspiration. Flags: -day value - 1 to 31 + 1 to 31 -hour value - 0 to 23 + 0 to 23 -minute value - 0 to 59 + 0 to 59 -month value - 1 to 12 + 1 to 12 -second value - 0 to 59 + 0 to 59 -silent - Suppress all output + Suppress all output -version - Print binary version + Print binary version -weekday value - mo,tu,we,th,fr,sa,su + mo,tu,we,th,fr,sa,su + -year value + list of years For more visit: https://qvl.io/sleepto