diff --git a/fs/parseduration.go b/fs/parseduration.go index 3184f6e1b..23e749446 100644 --- a/fs/parseduration.go +++ b/fs/parseduration.go @@ -76,18 +76,28 @@ var timeFormats = []string{ "2006-01-02", } -// parse the age as time before the epoch in various date formats -func parseDurationDates(age string, epoch time.Time) (t time.Duration, err error) { +// parse the date as time in various date formats +func parseTimeDates(date string) (t time.Time, err error) { var instant time.Time for _, timeFormat := range timeFormats { - instant, err = time.ParseInLocation(timeFormat, age, time.Local) + instant, err = time.ParseInLocation(timeFormat, date, time.Local) if err == nil { - return epoch.Sub(instant), nil + return instant, nil } } return t, err } +// parse the age as time before the epoch in various date formats +func parseDurationDates(age string, epoch time.Time) (d time.Duration, err error) { + instant, err := parseTimeDates(age) + if err != nil { + return d, err + } + + return epoch.Sub(instant), nil +} + // parseDurationFromNow parses a duration string. Allows ParseDuration to match the time // package and easier testing within the fs package. func parseDurationFromNow(age string, getNow func() time.Time) (d time.Duration, err error) { diff --git a/fs/parsetime.go b/fs/parsetime.go new file mode 100644 index 000000000..145cfa0de --- /dev/null +++ b/fs/parsetime.go @@ -0,0 +1,91 @@ +package fs + +import ( + "encoding/json" + "fmt" + "time" +) + +// Time is a time.Time with some more parsing options +type Time time.Time + +// For overriding in unittests. +var ( + timeNowFunc = time.Now +) + +// Turn Time into a string +func (t Time) String() string { + if !t.IsSet() { + return "off" + } + return time.Time(t).Format(time.RFC3339Nano) +} + +// IsSet returns if the time is not zero +func (t Time) IsSet() bool { + return !time.Time(t).IsZero() +} + +// ParseTime parses a time or duration string as a Time. +func ParseTime(date string) (t time.Time, err error) { + if date == "off" { + return time.Time{}, nil + } + + now := timeNowFunc() + + // Attempt to parse as a text time + t, err = parseTimeDates(date) + if err == nil { + return t, nil + } + + // Attempt to parse as a time.Duration offset from now + d, err := time.ParseDuration(date) + if err == nil { + return now.Add(-d), nil + } + + d, err = parseDurationSuffixes(date) + if err == nil { + return now.Add(-d), nil + } + + return t, err +} + +// Set a Time +func (t *Time) Set(s string) error { + parsedTime, err := ParseTime(s) + if err != nil { + return err + } + *t = Time(parsedTime) + return nil +} + +// Type of the value +func (t Time) Type() string { + return "Time" +} + +// UnmarshalJSON makes sure the value can be parsed as a string in JSON +func (t *Time) UnmarshalJSON(in []byte) error { + var s string + err := json.Unmarshal(in, &s) + if err != nil { + return err + } + + return t.Set(s) +} + +// Scan implements the fmt.Scanner interface +func (t *Time) Scan(s fmt.ScanState, ch rune) error { + token, err := s.Token(true, nil) + if err != nil { + return err + } + return t.Set(string(token)) +} diff --git a/fs/parsetime_test.go b/fs/parsetime_test.go new file mode 100644 index 000000000..5784b971a --- /dev/null +++ b/fs/parsetime_test.go @@ -0,0 +1,147 @@ +package fs + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Check it satisfies the interface +var _ flagger = (*Time)(nil) + +func TestParseTime(t *testing.T) { + now := time.Date(2020, 9, 5, 8, 15, 5, 250, time.UTC) + oldTimeNowFunc := timeNowFunc + timeNowFunc = func() time.Time { return now } + defer func() { timeNowFunc = oldTimeNowFunc }() + + for _, test := range []struct { + in string + want time.Time + err bool + }{ + {"", time.Time{}, true}, + {"1ms", now.Add(-time.Millisecond), false}, + {"1s", now.Add(-time.Second), false}, + {"1", now.Add(-time.Second), false}, + {"1m", now.Add(-time.Minute), false}, + {"1.5m", now.Add(-(3 * time.Minute) / 2), false}, + {"1h", now.Add(-time.Hour), false}, + {"1d", now.Add(-time.Hour * 24), false}, + {"1w", now.Add(-time.Hour * 24 * 7), false}, + {"1M", now.Add(-time.Hour * 24 * 30), false}, + {"1y", now.Add(-time.Hour * 24 * 365), false}, + {"1.5y", now.Add(-time.Hour * 24 * 365 * 3 / 2), false}, + {"-1.5y", now.Add(time.Hour * 24 * 365 * 3 / 2), false}, + {"-1s", now.Add(time.Second), false}, + {"-1", now.Add(time.Second), false}, + {"0", now, false}, + {"100", now.Add(-100 * time.Second), false}, + {"-100", now.Add(100 * time.Second), false}, + {"1.s", now.Add(-time.Second), false}, + {"1x", time.Time{}, true}, + {"-1x", time.Time{}, true}, + {"off", time.Time{}, false}, + {"1h2m3s", now.Add(-(time.Hour + 2*time.Minute + 3*time.Second)), false}, + {"2001-02-03", time.Date(2001, 2, 3, 0, 0, 0, 0, time.Local), false}, + {"2001-02-03 10:11:12", time.Date(2001, 2, 3, 10, 11, 12, 0, time.Local), false}, + {"2001-08-03 10:11:12", time.Date(2001, 8, 3, 10, 11, 12, 0, time.Local), false}, + {"2001-02-03T10:11:12", time.Date(2001, 2, 3, 10, 11, 12, 0, time.Local), false}, + {"2001-02-03T10:11:12.123Z", time.Date(2001, 2, 3, 10, 11, 12, 123000000, time.UTC), false}, + {"2001-02-03T10:11:12.123+00:00", time.Date(2001, 2, 3, 10, 11, 12, 123000000, time.UTC), false}, + } { + parsedTime, err := ParseTime(test.in) + if test.err { + require.Error(t, err, test.in) + } else { + require.NoError(t, err, test.in) + } + assert.True(t, test.want.Equal(parsedTime), "%v should be parsed as %v instead of %v", test.in, test.want, parsedTime) + } +} + +func TestTimeString(t *testing.T) { + now := time.Date(2020, 9, 5, 8, 15, 5, 250, time.UTC) + oldTimeNowFunc := timeNowFunc + timeNowFunc = func() time.Time { return now } + defer func() { timeNowFunc = oldTimeNowFunc }() + + for _, test := range []struct { + in time.Time + want string + }{ + {now, "2020-09-05T08:15:05.00000025Z"}, + {time.Date(2021, 8, 5, 8, 15, 5, 0, time.UTC), "2021-08-05T08:15:05Z"}, + {time.Time{}, "off"}, + } { + got := Time(test.in).String() + assert.Equal(t, test.want, got) + // Test the reverse + reverse, err := ParseTime(test.want) + assert.NoError(t, err) + assert.Equal(t, test.in, reverse) + } +} + +func TestTimeScan(t *testing.T) { + now := time.Date(2020, 9, 5, 8, 15, 5, 250, time.UTC) + oldTimeNowFunc := timeNowFunc + timeNowFunc = func() time.Time { return now } + defer func() { timeNowFunc = oldTimeNowFunc }() + + var v1, v2, v3, v4, v5 Time + n, err := fmt.Sscan(" 17m -12h 0 off 2022-03-26T17:48:19Z ", &v1, &v2, &v3, &v4, &v5) + require.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, Time(now.Add(-17*time.Minute)), v1) + assert.Equal(t, Time(now.Add(12*time.Hour)), v2) + assert.Equal(t, Time(now), v3) + assert.Equal(t, Time(time.Time{}), v4) + assert.Equal(t, Time(time.Date(2022, 03, 26, 17, 48, 19, 0, time.UTC)), v5) +} + +func TestParseTimeUnmarshalJSON(t *testing.T) { + now := time.Date(2020, 9, 5, 8, 15, 5, 250, time.UTC) + oldTimeNowFunc := timeNowFunc + timeNowFunc = func() time.Time { return now } + defer func() { timeNowFunc = oldTimeNowFunc }() + + for _, test := range []struct { + in string + want time.Time + err bool + }{ + {`""`, time.Time{}, true}, + {"0", time.Time{}, true}, + {"1", time.Time{}, true}, + {"1", time.Time{}, true}, + {`"2022-03-26T17:48:19Z"`, time.Date(2022, 03, 26, 17, 48, 19, 0, time.UTC), false}, + {`"0"`, now, false}, + {`"1ms"`, now.Add(-time.Millisecond), false}, + {`"1s"`, now.Add(-time.Second), false}, + {`"1"`, now.Add(-time.Second), false}, + {`"1m"`, now.Add(-time.Minute), false}, + {`"1h"`, now.Add(-time.Hour), false}, + {`"-1h"`, now.Add(time.Hour), false}, + {`"1d"`, now.Add(-time.Hour * 24), false}, + {`"1w"`, now.Add(-time.Hour * 24 * 7), false}, + {`"1M"`, now.Add(-time.Hour * 24 * 30), false}, + {`"1y"`, now.Add(-time.Hour * 24 * 365), false}, + {`"off"`, time.Time{}, false}, + {`"error"`, time.Time{}, true}, + {"error", time.Time{}, true}, + } { + var parsedTime Time + err := json.Unmarshal([]byte(test.in), &parsedTime) + if test.err { + require.Error(t, err, test.in) + } else { + require.NoError(t, err, test.in) + } + assert.Equal(t, Time(test.want), parsedTime, test.in) + } +}