Repository: scaleway/scaleway-cli
Pull request #1366 (2020-08-31 12:44:30)

feat(core): add support for relative date parsing




Patch:
diff --git a/go.mod b/go.mod
index b6a1e66d..88acafad 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/getsentry/raven-go v0.2.0
 	github.com/gorilla/websocket v1.4.2
 	github.com/hashicorp/go-version v1.2.0
+	github.com/karrick/tparse v2.4.2+incompatible
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/mattn/go-colorable v0.1.4
 	github.com/mattn/go-isatty v0.0.11
diff --git a/go.sum b/go.sum
index b803e8ca..fe218792 100644
--- a/go.sum
+++ b/go.sum
@@ -39,6 +39,9 @@ github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/karrick/tparse v1.0.0 h1:qVJoscl1sG/UodDmNjjY6cSIun7s541PNNE42dqstfg=
+github.com/karrick/tparse v2.4.2+incompatible h1:+cW306qKAzrASC5XieHkgN7/vPaGKIuK62Q7nI7DIRc=
+github.com/karrick/tparse v2.4.2+incompatible/go.mod h1:ASPA+vrIcN1uEW6BZg8vfWbzm69ODPSYZPU6qJyfdK0=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
diff --git a/internal/args/errors.go b/internal/args/errors.go
index a2b2d688..83407fa9 100644
--- a/internal/args/errors.go
+++ b/internal/args/errors.go
@@ -187,3 +187,21 @@ func missingIndices(index, length int) string {
 	}
 	return strings.Join(s, ",")
 }
+
+type CannotParseDateError struct {
+	ArgValue               string
+	AbsoluteTimeParseError error
+	RelativeTimeParseError error
+}
+
+func (e *CannotParseDateError) Error() string {
+	return fmt.Sprintf(`date parsing error: could not parse %s`, e.ArgValue)
+}
+
+type CannotParseBoolError struct {
+	Value string
+}
+
+func (e *CannotParseBoolError) Error() string {
+	return "invalid boolean value"
+}
diff --git a/internal/args/unmarshal.go b/internal/args/unmarshal.go
index ac66084d..655fb210 100644
--- a/internal/args/unmarshal.go
+++ b/internal/args/unmarshal.go
@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"github.com/dustin/go-humanize"
+	"github.com/karrick/tparse"
 	"github.com/scaleway/scaleway-sdk-go/scw"
 	"github.com/scaleway/scaleway-sdk-go/strcase"
 	"github.com/scaleway/scaleway-sdk-go/validation"
@@ -25,6 +26,8 @@ type Unmarshaler interface {
 
 type UnmarshalFunc func(value string, dest interface{}) error
 
+var TestForceNow *time.Time
+
 var unmarshalFuncs = map[reflect.Type]UnmarshalFunc{
 	reflect.TypeOf((*scw.Size)(nil)).Elem(): func(value string, dest interface{}) error {
 		// Only support G, GB for now (case insensitive).
@@ -61,13 +64,32 @@ var unmarshalFuncs = map[reflect.Type]UnmarshalFunc{
 
 	reflect.TypeOf((*time.Time)(nil)).Elem(): func(value string, dest interface{}) error {
 		// Handle absolute time
-		t, err := time.Parse(time.RFC3339, value)
-		if err != nil {
-			return err
+		absoluteTimeParsed, absoluteErr := time.Parse(time.RFC3339, value)
+		if absoluteErr == nil {
+			*(dest.(*time.Time)) = absoluteTimeParsed
+			return nil
 		}
 
-		*(dest.(*time.Time)) = t
-		return nil
+		// Handle relative time
+		if value[0] != '+' && value[0] != '-' {
+			value = "+" + value
+		}
+		m := map[string]time.Time{
+			"t": time.Now(),
+		}
+		if TestForceNow != nil {
+			m["t"] = *TestForceNow
+		}
+		relativeTimeParsed, relativeErr := tparse.ParseWithMap(time.RFC3339, "t"+value, m)
+		if relativeErr == nil {
+			*(dest.(*time.Time)) = relativeTimeParsed
+			return nil
+		}
+		return &CannotParseDateError{
+			ArgValue:               value,
+			AbsoluteTimeParseError: absoluteErr,
+			RelativeTimeParseError: relativeErr,
+		}
 	},
 }
 
@@ -370,7 +392,7 @@ func unmarshalScalar(value string, dest reflect.Value) error {
 		case "false":
 			dest.SetBool(false)
 		default:
-			return fmt.Errorf("invalid boolean value")
+			return &CannotParseBoolError{Value: value}
 		}
 		return nil
 	case reflect.String:
diff --git a/internal/core/cobra_utils.go b/internal/core/cobra_utils.go
index 0a833760..372b6fe1 100644
--- a/internal/core/cobra_utils.go
+++ b/internal/core/cobra_utils.go
@@ -177,14 +177,27 @@ func handleUnmarshalErrors(cmd *Command, unmarshalErr *args.UnmarshalArgError) e
 
 	switch e := wrappedErr.(type) {
 	case *args.CannotUnmarshalError:
-		hint := ""
-		if _, ok := e.Dest.(*bool); ok {
-			hint = "Possible values: true, false"
-		}
-
-		return &CliError{
-			Err:  fmt.Errorf("invalid value for '%s' argument: %s", unmarshalErr.ArgName, e.Err),
-			Hint: hint,
+		switch e.Err.(type) {
+		case *args.CannotParseBoolError:
+			return &CliError{
+				Err:     fmt.Errorf(""),
+				Message: fmt.Sprintf("invalid value for '%s' argument: invalid boolean value", unmarshalErr.ArgName),
+				Hint:    "Possible values: true, false",
+			}
+		case *args.CannotParseDateError:
+			dateErr := e.Err.(*args.CannotParseDateError)
+			return &CliError{
+				Err:     fmt.Errorf("date parsing error: %s", dateErr.ArgValue),
+				Message: fmt.Sprintf("could not parse %s as either an absolute time (RFC3339) nor a relative time (+/-)RFC3339", dateErr.ArgValue),
+				Details: fmt.Sprintf(`Absolute time error: %s
+Relative time error: %s
+`, dateErr.AbsoluteTimeParseError, dateErr.RelativeTimeParseError),
+				Hint: "Run `scw help date` to learn more about date parsing",
+			}
+		default:
+			return &CliError{
+				Err: fmt.Errorf("invalid value for '%s' argument: %s", unmarshalErr.ArgName, e.Err),
+			}
 		}
 
 	case *args.UnknownArgError, *args.InvalidArgNameError:
diff --git a/internal/namespaces/help/custom.go b/internal/namespaces/help/custom.go
index b53b988e..90aa5919 100644
--- a/internal/namespaces/help/custom.go
+++ b/internal/namespaces/help/custom.go
@@ -11,6 +11,7 @@ func GetCommands() *core.Commands {
 	return core.NewCommands(
 		helpRoot(),
 		newHelpCommand("output", shortOutput, longOutput),
+		newHelpCommand("date", shortDate, longDate),
 	)
 }
 
diff --git a/internal/namespaces/help/date.go b/internal/namespaces/help/date.go
new file mode 100644
index 00000000..ade5d52d
--- /dev/null
+++ b/internal/namespaces/help/date.go
@@ -0,0 +1,37 @@
+package help
+
+const (
+	shortDate = "Get help about how date parsing works in the CLI"
+	longDate  = `Date parsing
+
+You have two ways for managing date in the CLI: Absolute and Relative
+
+- Absolute time
+
+  Absolute time refers to a specific and absolute point in time.
+  CLI uses RFC3339 to parse those time and pass a time.Time go structure to the underlying functions.
+
+	Example: "2006-01-02T15:04:05Z07:00"
+
+- Relative time
+
+  Relative time refers to a time calculated from adding a given duration to the time when a command is launched.
+
+	Example:
+	- +1d4m => current time plus 1 day and 4 minutes
+	- -1d4m => current time minus 1 day and 4 minutes
+
+- Units of time
+
+	Nanosecond: ns
+	Microsecond: us, µs (U+00B5 = micro symbol), μs (U+03BC = Greek letter mu)
+	Millisecond: ms
+	Second: s, sec, second, seconds
+	Minute: m, min, minute, minutes
+	Hour: h, hr, hour, hours
+	Day: d, day, days
+	Week: w, wk, week, weeks
+	Month: mo, mon, month, months
+	Year: y, yr, year, years
+`
+)


Test patch:
diff --git a/cmd/scw/testdata/test-all-usage-help-date-usage.golden b/cmd/scw/testdata/test-all-usage-help-date-usage.golden
new file mode 100644
index 00000000..ee1b5365
--- /dev/null
+++ b/cmd/scw/testdata/test-all-usage-help-date-usage.golden
@@ -0,0 +1,45 @@
+🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
+🟥🟥🟥 STDERR️️ 🟥🟥🟥️
+Date parsing
+
+You have two ways for managing date in the CLI: Absolute and Relative
+
+- Absolute time
+
+  Absolute time refers to a specific and absolute point in time.
+  CLI uses RFC3339 to parse those time and pass a time.Time go structure to the underlying functions.
+
+	Example: "2006-01-02T15:04:05Z07:00"
+
+- Relative time
+
+  Relative time refers to a time calculated from adding a given duration to the time when a command is launched.
+
+	Example:
+	- +1d4m => current time plus 1 day and 4 minutes
+	- -1d4m => current time minus 1 day and 4 minutes
+
+- Units of time
+
+	Nanosecond: ns
+	Microsecond: us, µs (U+00B5 = micro symbol), μs (U+03BC = Greek letter mu)
+	Millisecond: ms
+	Second: s, sec, second, seconds
+	Minute: m, min, minute, minutes
+	Hour: h, hr, hour, hours
+	Day: d, day, days
+	Week: w, wk, week, weeks
+	Month: mo, mon, month, months
+	Year: y, yr, year, years
+
+USAGE:
+  scw help date
+
+FLAGS:
+  -h, --help   help for date
+
+GLOBAL FLAGS:
+  -c, --config string    The path to the config file
+  -D, --debug            Enable debug mode
+  -o, --output string    Output format: json or human, see 'scw help output' for more info (default "human")
+  -p, --profile string   The config profile to use
diff --git a/cmd/scw/testdata/test-all-usage-help-usage.golden b/cmd/scw/testdata/test-all-usage-help-usage.golden
index 03bb08b7..c1cd9e4c 100644
--- a/cmd/scw/testdata/test-all-usage-help-usage.golden
+++ b/cmd/scw/testdata/test-all-usage-help-usage.golden
@@ -6,6 +6,7 @@ USAGE:
   scw help <command>
 
 AVAILABLE COMMANDS:
+  date        Get help about how date parsing works in the CLI
   output      Get help about how the CLI output works
 
 FLAGS:
diff --git a/internal/args/unmarshal_test.go b/internal/args/unmarshal_test.go
index 62ebfdcf..59da4273 100644
--- a/internal/args/unmarshal_test.go
+++ b/internal/args/unmarshal_test.go
@@ -10,6 +10,10 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func init() {
+	TestForceNow = scw.TimePtr(time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))
+}
+
 func TestUnmarshalStruct(t *testing.T) {
 	type TestCase struct {
 		args     []string
@@ -247,6 +251,32 @@ func TestUnmarshalStruct(t *testing.T) {
 		},
 	}))
 
+	t.Run("Relative date positive", run(TestCase{
+		args: []string{
+			"time=+1m1s",
+		},
+		expected: &WellKnownTypes{
+			Time: time.Date(1970, 01, 01, 0, 1, 1, 0, time.UTC),
+		},
+	}))
+
+	t.Run("Relative date negative", run(TestCase{
+		args: []string{
+			"time=-1m1s",
+		},
+		expected: &WellKnownTypes{
+			Time: time.Date(1969, 12, 31, 23, 58, 59, 0, time.UTC),
+		},
+	}))
+
+	t.Run("Unknown relative date markers", run(TestCase{
+		data: &time.Time{},
+		args: []string{
+			"time=-1R",
+		},
+		error: `cannot unmarshal arg 'time=-1R': cannot set nested field for unmarshalable type time.Time`,
+	}))
+
 	t.Run("nested-basic", run(TestCase{
 		args: []string{
 			"basic.string=test",
diff --git a/internal/core/cobra_utils_test.go b/internal/core/cobra_utils_test.go
index dcdf6ce5..958dc876 100644
--- a/internal/core/cobra_utils_test.go
+++ b/internal/core/cobra_utils_test.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"reflect"
 	"testing"
+	"time"
 
 	"github.com/scaleway/scaleway-cli/internal/args"
 )
@@ -14,6 +15,10 @@ type testType struct {
 	Tag    string
 }
 
+type testDate struct {
+	Date *time.Time
+}
+
 func testGetCommands() *Commands {
 	return NewCommands(
 		&Command{
@@ -64,6 +69,16 @@ func testGetCommands() *Commands {
 				return res, nil
 			},
 		},
+		&Command{
+			Namespace:            "test",
+			Resource:             "date",
+			ArgsType:             reflect.TypeOf(testDate{}),
+			AllowAnonymousClient: true,
+			Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) {
+				a := argsI.(*testDate)
+				return a.Date, nil
+			},
+		},
 	)
 }
 
@@ -91,6 +106,22 @@ func Test_handleUnmarshalErrors(t *testing.T) {
 			}),
 		),
 	}))
+
+	t.Run("relative date", Test(&TestConfig{
+		Commands: testGetCommands(),
+		Cmd:      "scw test date date=+3R",
+		Check: TestCheckCombine(
+			TestCheckExitCode(1),
+			TestCheckError(&CliError{
+				Message: "could not parse +3R as either an absolute time (RFC3339) nor a relative time (+/-)RFC3339",
+				Details: `Absolute time error: parsing time "+3R" as "2006-01-02T15:04:05Z07:00": cannot parse "+3R" as "2006"
+Relative time error: unknown unit in duration: "R"
+`,
+				Err:  fmt.Errorf("date parsing error: +3R"),
+				Hint: "Run `scw help date` to learn more about date parsing",
+			}),
+		),
+	}))
 }
 
 func Test_RawArgs(t *testing.T) {
diff --git a/internal/core/testing.go b/internal/core/testing.go
index 9692fe5d..e9ee9a6a 100644
--- a/internal/core/testing.go
+++ b/internal/core/testing.go
@@ -21,6 +21,7 @@ import (
 	"github.com/dnaeon/go-vcr/cassette"
 	"github.com/dnaeon/go-vcr/recorder"
 	"github.com/hashicorp/go-version"
+	args "github.com/scaleway/scaleway-cli/internal/args"
 	"github.com/scaleway/scaleway-cli/internal/human"
 	"github.com/scaleway/scaleway-cli/internal/interactive"
 	"github.com/scaleway/scaleway-sdk-go/api/test/v1"
@@ -284,6 +285,9 @@ func Test(config *TestConfig) func(t *testing.T) {
 			testLogger.level = logger.LogLevelDebug
 		}
 
+		// We need to set up this variable to ensure that relative date parsing stay consistent
+		args.TestForceNow = scw.TimePtr(time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))
+
 		// Because human marshal of date is relative (e.g 3 minutes ago) we must make sure it stay consistent for golden to works.
 		// Here we return a constant string. We may need to find a better place to put this.
 		human.RegisterMarshalerFunc(time.Time{}, func(i interface{}, opt *human.MarshalOpt) (string, error) {