Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat:(issue_1958) Add support for multiple layouts to TimestampFlag #1959

Merged
merged 19 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestArgumentsSubcommand(t *testing.T) {
Max: 1,
Destination: &tval,
Config: TimestampConfig{
Layout: time.RFC3339,
Layouts: []string{time.RFC3339},
},
},
&StringArg{
Expand Down
9 changes: 6 additions & 3 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,7 @@ func TestSetupInitializesOnlyNilWriters(t *testing.T) {
}

func TestFlagAction(t *testing.T) {
now := time.Now().UTC().Truncate(time.Minute)
testCases := []struct {
name string
args []string
Expand Down Expand Up @@ -2578,8 +2579,8 @@ func TestFlagAction(t *testing.T) {
},
{
name: "flag_timestamp",
args: []string{"app", "--f_timestamp", "2022-05-01 02:26:20"},
exp: "2022-05-01T02:26:20Z ",
args: []string{"app", "--f_timestamp", now.Format(time.DateTime)},
exp: now.UTC().Format(time.RFC3339) + " ",
},
{
name: "flag_timestamp_error",
Expand Down Expand Up @@ -2738,12 +2739,14 @@ func TestFlagAction(t *testing.T) {
&TimestampFlag{
Name: "f_timestamp",
Config: TimestampConfig{
Layout: "2006-01-02 15:04:05",
Timezone: time.UTC,
Layouts: []string{time.DateTime},
},
Action: func(_ context.Context, cmd *Command, v time.Time) error {
if v.IsZero() {
return fmt.Errorf("zero timestamp")
}

_, err := cmd.Root().Writer.Write([]byte(v.Format(time.RFC3339) + " "))
return err
},
Expand Down
15 changes: 13 additions & 2 deletions docs/v3/examples/timestamp-flag.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ import (
func main() {
cmd := &cli.Command{
Flags: []cli.Flag{
&cli.TimestampFlag{Name: "meeting", Config: cli.TimestampConfig{Layout: "2006-01-02T15:04:05"}},
&cli.TimestampFlag{
Name: "meeting",
Config: cli.TimestampConfig{
Layouts: []string{"2006-01-02T15:04:05"},
},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Printf("%s", cmd.Timestamp("meeting").String())
Expand All @@ -54,7 +59,13 @@ change behavior, a default timezone can be provided with flag definition:
```go
cmd := &cli.Command{
Flags: []cli.Flag{
&cli.TimestampFlag{Name: "meeting", Config: cli.TimestampConfig{Layout: "2006-01-02T15:04:05", Timezone: time.Local}},
&cli.TimestampFlag{
Name: "meeting",
Config: cli.TimestampConfig{
Timezone: time.Local,
AvailableLayouts: []string{"2006-01-02T15:04:05"},
},
},
},
}
```
Expand Down
243 changes: 231 additions & 12 deletions flag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package cli

import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"reflect"
"regexp"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -2259,23 +2261,23 @@ func TestTimestamp_set(t *testing.T) {
ts := timestampValue{
timestamp: nil,
hasBeenSet: false,
layout: "Jan 2, 2006 at 3:04pm (MST)",
layouts: []string{"Jan 2, 2006 at 3:04pm (MST)"},
}

time1 := "Feb 3, 2013 at 7:54pm (PST)"
require.NoError(t, ts.Set(time1), "Failed to parse time %s with layout %s", time1, ts.layout)
require.NoError(t, ts.Set(time1), "Failed to parse time %s with layouts %v", time1, ts.layouts)
require.True(t, ts.hasBeenSet, "hasBeenSet is not true after setting a time")

ts.hasBeenSet = false
ts.layout = time.RFC3339
ts.layouts = []string{time.RFC3339}
time2 := "2006-01-02T15:04:05Z"
require.NoError(t, ts.Set(time2), "Failed to parse time %s with layout %s", time2, ts.layout)
require.NoError(t, ts.Set(time2), "Failed to parse time %s with layout %v", time2, ts.layouts)
require.True(t, ts.hasBeenSet, "hasBeenSet is not true after setting a time")
}

func TestTimestampFlagApply(t *testing.T) {
func TestTimestampFlagApply_SingleFormat(t *testing.T) {
expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z")
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.RFC3339}}
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.RFC3339}}}
set := flag.NewFlagSet("test", 0)
_ = fl.Apply(set)

Expand All @@ -2284,9 +2286,226 @@ func TestTimestampFlagApply(t *testing.T) {
assert.Equal(t, expectedResult, set.Lookup("time").Value.(flag.Getter).Get())
}

func TestTimestampFlagApply_MultipleFormats(t *testing.T) {
now := time.Now().UTC()

testCases := []struct {
caseName string
layoutsPrecisions map[string]time.Duration
expRes time.Time
expErrValidation func(err error) (validation error)
}{
{
caseName: "all_valid_layouts",
layoutsPrecisions: map[string]time.Duration{
time.RFC3339: time.Second,
time.DateTime: time.Second,
time.RFC1123: time.Second,
},
expRes: now.Truncate(time.Second),
},
{
caseName: "one_invalid_layout",
layoutsPrecisions: map[string]time.Duration{
time.RFC3339: time.Second,
time.DateTime: time.Second,
"foo": 0,
},
expRes: now.Truncate(time.Second),
},
{
caseName: "multiple_invalid_layouts",
layoutsPrecisions: map[string]time.Duration{
time.RFC3339: time.Second,
"foo": 0,
time.DateTime: time.Second,
"bar": 0,
},
expRes: now.Truncate(time.Second),
},
{
caseName: "all_invalid_layouts",
layoutsPrecisions: map[string]time.Duration{
"foo": 0,
"2024-08-07 74:01:82Z-100": 0,
"25:70": 0,
"": 0,
},
expErrValidation: func(err error) error {
if err == nil {
return errors.New("got nil err")
}

found := regexp.MustCompile(`(cannot parse ".+" as ".*")|(extra text: ".+")`).Match([]byte(err.Error()))
if !found {
return fmt.Errorf("given error does not satisfy pattern: %w", err)
}

return nil
},
},
{
caseName: "empty_layout",
layoutsPrecisions: map[string]time.Duration{
"": 0,
},
expErrValidation: func(err error) error {
if err == nil {
return errors.New("got nil err")
}

found := regexp.MustCompile(`extra text: ".+"`).Match([]byte(err.Error()))
if !found {
return fmt.Errorf("given error does not satisfy pattern: %w", err)
}

return nil
},
},
{
caseName: "nil_layouts_slice",
expErrValidation: func(err error) error {
if err == nil {
return errors.New("got nil err")
}

found := regexp.MustCompile(`got nil/empty layouts slice`).Match([]byte(err.Error()))
if !found {
return fmt.Errorf("given error does not satisfy pattern: %w", err)
}

return nil
},
},
{
caseName: "empty_layouts_slice",
layoutsPrecisions: map[string]time.Duration{},
expErrValidation: func(err error) error {
if err == nil {
return errors.New("got nil err")
}

found := regexp.MustCompile(`got nil/empty layouts slice`).Match([]byte(err.Error()))
if !found {
return fmt.Errorf("given error does not satisfy pattern: %w", err)
}

return nil
},
},
}

// TODO: replace with maps.Keys() (go >= ), lo.Keys() if acceptable
getKeys := func(m map[string]time.Duration) []string {
if m == nil {
return nil
}

keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

for idx := range testCases {
testCase := testCases[idx]
t.Run(testCase.caseName, func(t *testing.T) {
// t.Parallel()
fl := TimestampFlag{
Name: "time",
Config: TimestampConfig{
Layouts: getKeys(testCase.layoutsPrecisions),
},
}

set := flag.NewFlagSet("test", 0)
_ = fl.Apply(set)

if len(testCase.layoutsPrecisions) == 0 {
err := set.Parse([]string{"--time", now.Format(time.RFC3339)})
if testCase.expErrValidation != nil {
assert.NoError(t, testCase.expErrValidation(err))
}
}

validLayouts := make([]string, 0, len(testCase.layoutsPrecisions))
invalidLayouts := make([]string, 0, len(testCase.layoutsPrecisions))

// TODO: replace with lo.Filter if acceptable
for layout, prec := range testCase.layoutsPrecisions {
v, err := time.Parse(layout, now.Format(layout))
if err != nil || prec == 0 || now.Truncate(prec).UnixNano() != v.Truncate(prec).UnixNano() {
invalidLayouts = append(invalidLayouts, layout)
continue
}
validLayouts = append(validLayouts, layout)
}

for _, layout := range validLayouts {
err := set.Parse([]string{"--time", now.Format(layout)})
assert.NoError(t, err)
if !testCase.expRes.IsZero() {
assert.Equal(t, testCase.expRes, set.Lookup("time").Value.(flag.Getter).Get())
}
}

for range invalidLayouts {
err := set.Parse([]string{"--time", now.Format(time.RFC3339)})
if testCase.expErrValidation != nil {
assert.NoError(t, testCase.expErrValidation(err))
}
}
})
}
}

func TestTimestampFlagApply_ShortenedLayouts(t *testing.T) {
now := time.Now().UTC()

shortenedLayoutsPrecisions := map[string]time.Duration{
time.Kitchen: time.Minute,
time.Stamp: time.Second,
time.StampMilli: time.Millisecond,
time.StampMicro: time.Microsecond,
time.StampNano: time.Nanosecond,
time.TimeOnly: time.Second,
"15:04": time.Minute,
}

// TODO: replace with maps.Keys() (go >= ), lo.Keys() if acceptable
getKeys := func(m map[string]time.Duration) []string {
if m == nil {
return nil
}

keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

fl := TimestampFlag{
Name: "time",
Config: TimestampConfig{
Layouts: getKeys(shortenedLayoutsPrecisions),
},
}

set := flag.NewFlagSet("test", 0)
_ = fl.Apply(set)

for layout, prec := range shortenedLayoutsPrecisions {
err := set.Parse([]string{"--time", now.Format(layout)})
assert.NoError(t, err)
assert.Equal(t, now.Truncate(prec), set.Lookup("time").Value.(flag.Getter).Get())
}
}

func TestTimestampFlagApplyValue(t *testing.T) {
expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z")
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.RFC3339}, Value: expectedResult}
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.RFC3339}}, Value: expectedResult}
set := flag.NewFlagSet("test", 0)
_ = fl.Apply(set)

Expand All @@ -2296,7 +2515,7 @@ func TestTimestampFlagApplyValue(t *testing.T) {
}

func TestTimestampFlagApply_Fail_Parse_Wrong_Layout(t *testing.T) {
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: "randomlayout"}}
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{"randomlayout"}}}
set := flag.NewFlagSet("test", 0)
set.SetOutput(io.Discard)
_ = fl.Apply(set)
Expand All @@ -2306,7 +2525,7 @@ func TestTimestampFlagApply_Fail_Parse_Wrong_Layout(t *testing.T) {
}

func TestTimestampFlagApply_Fail_Parse_Wrong_Time(t *testing.T) {
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: "Jan 2, 2006 at 3:04pm (MST)"}}
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{"Jan 2, 2006 at 3:04pm (MST)"}}}
set := flag.NewFlagSet("test", 0)
set.SetOutput(io.Discard)
_ = fl.Apply(set)
Expand All @@ -2318,7 +2537,7 @@ func TestTimestampFlagApply_Fail_Parse_Wrong_Time(t *testing.T) {
func TestTimestampFlagApply_Timezoned(t *testing.T) {
pdt := time.FixedZone("PDT", -7*60*60)
expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z")
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.ANSIC, Timezone: pdt}}
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.ANSIC}, Timezone: pdt}}
set := flag.NewFlagSet("test", 0)
_ = fl.Apply(set)

Expand Down Expand Up @@ -2519,7 +2738,7 @@ func TestFlagDefaultValueWithEnv(t *testing.T) {
},
{
name: "timestamp",
flag: &TimestampFlag{Name: "flag", Value: ts, Config: TimestampConfig{Layout: time.RFC3339}, Sources: EnvVars("tflag")},
flag: &TimestampFlag{Name: "flag", Value: ts, Config: TimestampConfig{Layouts: []string{time.RFC3339}}, Sources: EnvVars("tflag")},
toParse: []string{"--flag", "2006-11-02T15:04:05Z"},
expect: `--flag value (default: 2005-01-02 15:04:05 +0000 UTC)` + withEnvHint([]string{"tflag"}, ""),
environ: map[string]string{
Expand Down Expand Up @@ -2603,7 +2822,7 @@ func TestFlagValue(t *testing.T) {
func TestTimestampFlagApply_WithDestination(t *testing.T) {
var destination time.Time
expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z")
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.RFC3339}, Destination: &destination}
fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.RFC3339}}, Destination: &destination}
set := flag.NewFlagSet("test", 0)
_ = fl.Apply(set)

Expand Down
Loading
Loading