diff --git a/internal/ruletime/ruletime.go b/internal/ruletime/ruletime.go new file mode 100644 index 0000000..a6c932b --- /dev/null +++ b/internal/ruletime/ruletime.go @@ -0,0 +1,155 @@ +package ruletime + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +// RuleTime specifies a start and end of a time period. +type RuleTime struct { + Hour uint8 + Minute uint8 + Second uint8 + + Duration time.Duration +} + +type coversOptions struct { + loc *time.Location +} + +type CoversOption func(*coversOptions) + +// WithLocation makes Covers use the provided *time.Location +func WithLocation(loc *time.Location) CoversOption { + return func(o *coversOptions) { + o.loc = loc + } +} + +// Covers returns whether the RuleTime covers the provided time.Time. +func (rt *RuleTime) Covers(t time.Time, opts ...CoversOption) bool { + o := coversOptions{} + for _, opt := range opts { + opt(&o) + } + + if o.loc == nil { + o.loc = time.Local + } + + start := time.Date(t.Year(), t.Month(), t.Day(), int(rt.Hour), int(rt.Minute), int(rt.Second), 0, o.loc) + end := start.Add(rt.Duration) + + // wrap things around the clock + if end.Day() > start.Day() || end.Month() > start.Month() || end.Year() > start.Year() { + start = start.Add(-24 * time.Hour) + end = start.Add(rt.Duration) + if t.Sub(start) > 24*time.Hour { + t = t.Add(-24 * time.Hour) + } + } + + if t.After(start) && t.Before(end) { + return true + } + + return false +} + +// CoversNow returns whether the RuleTime covers this instant. +func (rt *RuleTime) CoversNow(opts ...CoversOption) bool { + return rt.Covers(time.Now(), opts...) +} + +func (rt *RuleTime) MarshalJSON() ([]byte, error) { + return json.Marshal(rt.String()) +} + +func (rt *RuleTime) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + + return rt.Parse(s) +} + +func (rt *RuleTime) String() string { + return fmt.Sprintf("%d:%d:%d+%s", rt.Hour, rt.Minute, rt.Second, rt.Duration.String()) +} + +// ParseRuleTime emits a RuleTime from a string in the format of `[h]h:[m]m[:[s]s]+duration` +// such as: `08:09:00+1h2m`. Time must be in 24 hour format. +func (rt *RuleTime) Parse(s string) error { + perr := func(e string) error { + return fmt.Errorf("parse rule time '%s': %s", s, e) + } + + werr := func(w error) error { + return fmt.Errorf("parse rule time '%s': %w", s, w) + } + + timeDurationParts := strings.Split(s, "+") + if len(timeDurationParts) != 2 { + return perr("needs time and duration part") + } + + timeSpec := strings.Split(timeDurationParts[0], ":") + if len(timeSpec) != 2 && len(timeSpec) != 3 { + return perr("invalid time part") + } + + for i, v := range timeSpec { + nv, err := strconv.Atoi(v) + if err != nil { + return werr(err) + } + + switch i { + case 0: + if nv > 23 || nv < 0 { + return perr("invalid hours") + } + rt.Hour = uint8(nv) + case 1: + if nv > 59 || nv < 0 { + return perr("invalid minutes") + } + rt.Minute = uint8(nv) + case 2: + if nv > 59 || nv < 0 { + return perr("invalid seconds") + } + rt.Second = uint8(nv) + } + } + + dur, err := time.ParseDuration(timeDurationParts[1]) + if err != nil { + return werr(err) + } + + if dur < 0 { + return perr("duration must be positive") + } + + if dur > 24*time.Hour { + return perr("duration too long") + } + + rt.Duration = dur + + return nil +} + +// New creates a new RuleTime with the provided value. +func New(s string) (RuleTime, error) { + rt := RuleTime{} + + return rt, rt.Parse(s) +} diff --git a/internal/ruletime/ruletime_test.go b/internal/ruletime/ruletime_test.go new file mode 100644 index 0000000..3f07866 --- /dev/null +++ b/internal/ruletime/ruletime_test.go @@ -0,0 +1,215 @@ +package ruletime_test + +import ( + "errors" + "testing" + "time" + + "dynatron.me/x/stillbox/internal/ruletime" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + ruleTime string + compare ruletime.RuleTime + expectErr error + }{ + { + name: "base case", + ruleTime: "23:45:01+2h1m", + compare: ruletime.RuleTime{ + 23, 45, 1, (2 * time.Hour) + time.Minute, + }, + }, + { + name: "no seconds", + ruleTime: "23:45+2h1m", + compare: ruletime.RuleTime{ + 23, 45, 0, (2 * time.Hour) + time.Minute, + }, + }, + { + name: "bad hour", + ruleTime: "29:45+2h1m", + expectErr: errors.New("invalid hours"), + }, + { + name: "bad minute", + ruleTime: "22:70+2h1m", + expectErr: errors.New("invalid minutes"), + }, + { + name: "bad minute 2", + ruleTime: "22:-70+2h1m", + expectErr: errors.New("invalid minutes"), + }, + { + name: "bad hour 2", + ruleTime: "-20:34+2h1m", + expectErr: errors.New("invalid hours"), + }, + { + name: "bad seconds", + ruleTime: "20:34:94+2h1m", + expectErr: errors.New("invalid seconds"), + }, + { + name: "negative duration", + ruleTime: "20:34:33+-2h1m", + expectErr: errors.New("duration must be positive"), + }, + { + name: "bad duration", + ruleTime: "20:34:04+2j", + expectErr: errors.New("in duration"), + }, + { + name: "duration too long", + ruleTime: "20:34:04+25h", + expectErr: errors.New("duration too long"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rt, err := ruletime.New(tc.ruleTime) + if tc.expectErr != nil { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectErr.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tc.compare, rt) + } + }) + } +} + +func TestCovers(t *testing.T) { + tM := func(s string) func(*time.Location) time.Time { + return func(loc *time.Location) time.Time { + tm, err := time.ParseInLocation("2006-01-02 15:04:05", "2024-11-01 "+s, loc) + if err != nil { + panic(err) + } + + return tm + } + } + + tz := func(s string) *time.Location { + l, err := time.LoadLocation(s) + if err != nil { + panic(err) + } + + return l + } + + tests := []struct { + name string + timespec string + t func(*time.Location) time.Time + opts []ruletime.CoversOption + loc *time.Location + covers bool + }{ + { + name: "base", + timespec: "8:45+1h", + t: tM("9:00:00"), + covers: true, + }, + { + name: "base false", + timespec: "8:45+1h", + t: tM("9:47:00"), + covers: false, + }, + { + name: "wrong tz", + timespec: "8:45+1h", + t: tM("9:37:00"), + loc: tz("America/New_York"), + covers: false, + opts: []ruletime.CoversOption{ruletime.WithLocation(time.UTC)}, + }, + { + name: "wrong tz 2", + timespec: "8:45+1h", + t: tM("9:07:00"), + loc: tz("America/Chicago"), + covers: false, + opts: []ruletime.CoversOption{ruletime.WithLocation(tz("America/New_York"))}, + }, + { + name: "right tz", + timespec: "8:45+1h", + t: tM("9:17:00"), + loc: tz("America/New_York"), + covers: true, + opts: []ruletime.CoversOption{ruletime.WithLocation(tz("America/New_York"))}, + }, + { + name: "past midnight", + timespec: "23:45+1h", + t: tM("0:07:00"), + loc: tz("America/Chicago"), + covers: true, + opts: []ruletime.CoversOption{ruletime.WithLocation(tz("America/Chicago"))}, + }, + { + name: "not past midnight", + timespec: "15:00+10h", + t: tM("3:07:00"), + loc: tz("America/Chicago"), + covers: false, + opts: []ruletime.CoversOption{ruletime.WithLocation(tz("America/Chicago"))}, + }, + { + name: "not past midnight 2", + timespec: "15:00+10h", + t: tM("14:07:00"), + loc: tz("America/Chicago"), + covers: false, + opts: []ruletime.CoversOption{ruletime.WithLocation(tz("America/Chicago"))}, + }, + { + name: "not past midnight 3", + timespec: "15:00+10h", + t: tM("15:07:00"), + loc: tz("America/Chicago"), + covers: true, + opts: []ruletime.CoversOption{ruletime.WithLocation(tz("America/Chicago"))}, + }, + { + name: "24h duration", + timespec: "15:00+24h", + t: tM("3:07:00"), + loc: tz("America/Chicago"), + covers: true, + opts: []ruletime.CoversOption{ruletime.WithLocation(tz("America/Chicago"))}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rt, err := ruletime.New(tc.timespec) + require.NoError(t, err) + loc := time.Local + if tc.loc != nil { + loc = tc.loc + } + + c := rt.Covers(tc.t(loc), tc.opts...) + if tc.covers { + assert.True(t, c) + } else { + assert.False(t, c) + } + }) + } +}