From 0ad02e0af4826c88ef9a00d569d29486593084e2 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Tue, 20 Aug 2024 03:32:54 +0100 Subject: [PATCH] feat(BREAKING): new parser for the TDV This commit adds a new parser for the internal time duration flag value (TimeDurationValue). Previously this used the parser from the time package from the standard library but this was limited to parsing units of time up to hours. The new parser allows users to specify duration in days, hours, minutes, seconds and a combination of the above. It is quite flexible in the way users format their string input. Additonal changes: - Added unit tests for the command-line parsing of the TimeDurationValue type. - Updated the unit tests for the BoolPtrValue type. - Updated documentation. PR: https://codeflow.dananglin.me.uk/apollo/enbas/pulls/55 --- .forgejo/workflows/Tests.yaml | 1 - README.md | 1 + docs/manual.md | 17 ++++-- docs/tips_and_tricks.md | 24 ++++++++ internal/flag/boolptrvalue_test.go | 77 ++++++++++++++++++------- internal/flag/timedurationvalue.go | 60 +++++++++++++++++-- internal/flag/timedurationvalue_test.go | 67 +++++++++++++++++++++ 7 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 docs/tips_and_tricks.md create mode 100644 internal/flag/timedurationvalue_test.go diff --git a/.forgejo/workflows/Tests.yaml b/.forgejo/workflows/Tests.yaml index 07be59d..6da9f3c 100644 --- a/.forgejo/workflows/Tests.yaml +++ b/.forgejo/workflows/Tests.yaml @@ -19,5 +19,4 @@ jobs: with: target: test env: - ENBAS_TEST_VERBOSE: "1" ENBAS_TEST_COVER: "1" diff --git a/README.md b/README.md index 4bf9ea0..fbebdd0 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ the `main` branch mirrored to the following forges: - **[Getting started guide](docs/getting_started.md)**: A guide to help you get started on using Enbas. - **[Configuration reference](docs/configuration.md)**: The configuration reference documentation. - **[User manual](docs/manual.md)**: The user manual. +- **[Tips and Tricks](docs/tips_and_tricks.md)**: Additional tips and tricks. ### Licensing diff --git a/docs/manual.md b/docs/manual.md index 116ddb7..4562f77 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -269,16 +269,21 @@ enbas show --type blocked ### Mute an account -``` -enbas mute --type account --account-name @name@example.social --mute-notifications --mute-duration="1h" -``` +- Mute an account indefinitely. + ``` + enbas mute --type account --account-name @name@example.social --mute-notifications" + ``` +- Mute an account for 1 and a half hours. + ``` + enbas mute --type account --account-name @name@example.social --mute-notifications --mute-duration="1 hour and 30 minutes" + ``` | flag | type | required | description | default | |------|------|----------|-------------|---------| | `type` | string | true | The resource you want to mute.
Here this should be `account`. | | | `account-name` | string | true | The name of the account to mute. | | | `mute-notifications` | boolean | false | Set to `true` to mute notifications as well as statuses. | false | -| `mute-duration` | string | false | Specify how long the account should be muted for.
Set to `0s` to mute indefinitely | 0s (indefinitely). | +| `mute-duration` | [time duration value](tips_and_tricks.md#the-time-duration-value) | false | Specify how long the account should be muted for.
Set to `0 seconds` to mute indefinitely | 0 seconds (indefinitely). | ### Unmute an account @@ -476,7 +481,7 @@ Creates a new status. --content "The age-old question: which text editor do you prefer?" \ --add-poll \ --poll-allows-multiple-choices=false \ - --poll-expires-in 168h \ + --poll-expires-in "7 days" \ --poll-option "emacs" \ --poll-option "vim/neovim" \ --poll-option "nano" \ @@ -528,7 +533,7 @@ Additional flags for polls. | `poll-allows-multiple-choices` | boolean | false | Set to `true` to allow users to make multiple choices. | false | | `poll-hides-vote-counts` | boolean | false | Set to `true` to hide the vote count until the poll is closed. | false | | `poll-option` | string | true | An option in the poll. Use this flag multiple times to set multiple options. | | -| `poll-expires-in` | string | false | The duration in which the poll is open for. | | +| `poll-expires-in` | [time duration value](tips_and_tricks.md#the-time-duration-value) | false | The duration in which the poll is open for. | | ### Delete a status diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md new file mode 100644 index 0000000..afc430a --- /dev/null +++ b/docs/tips_and_tricks.md @@ -0,0 +1,24 @@ +# Tips and Tricks + +## The time duration value + +The time duration value is a custom [flag value](https://pkg.go.dev/flag#Value) that converts a string input into a duration of time. +A typical string input would be in the form of something like `"3 days, 12 hours and 39 minutes"`. +The value can convert units in days, hours, minutes and seconds. + +To ensure that your string input is converted correctly there are simple rules to follow. + +- The input must be wrapped in quotes. +- Use `day` or `days` to convert the number of days. +- Use `hour` or `hours` to convert the number of hours. +- Use `minute` or `minutes` to convert the number of minutes. +- Use `second` or `seconds` to convert the number of seconds. +- There must be at least one space between the number and the unit of time.
+ E.g. `"7 days"` is valid, but `"7days"` is invalid. + +### Example valid string inputs + +- `"3 days"` +- `"6 hours, 45 minutes and 1 second"` +- `"1 day, 15 hours 31 minutes and 12 seconds"` +- `"(7 days) (1 hour) (21 minutes) (35 seconds)"` diff --git a/internal/flag/boolptrvalue_test.go b/internal/flag/boolptrvalue_test.go index d136f55..30bef18 100644 --- a/internal/flag/boolptrvalue_test.go +++ b/internal/flag/boolptrvalue_test.go @@ -1,6 +1,7 @@ package flag_test import ( + "flag" "slices" "testing" @@ -10,52 +11,86 @@ import ( func TestBoolPtrValue(t *testing.T) { tests := []struct { input string - want bool + want string }{ { input: "True", - want: true, + want: "true", + }, + { + input: "true", + want: "true", + }, + { + input: "1", + want: "true", + }, + { + input: "False", + want: "false", }, { input: "false", - want: false, + want: "false", + }, + { + input: "0", + want: "false", }, } - value := internalFlag.NewBoolPtrValue() - for _, test := range slices.All(tests) { - if err := value.Set(test.input); err != nil { - t.Fatalf( - "Unable to parse %s as a BoolPtrValue: %v", - test.input, - err, - ) + args := []string{"--boolean-value=" + test.input} + + t.Run("Flag parsing test: "+test.input, testBoolPtrValueParsing(args, test.want)) + } +} + +func testBoolPtrValueParsing(args []string, want string) func(t *testing.T) { + return func(t *testing.T) { + flagset := flag.NewFlagSet("test", flag.ExitOnError) + boolVal := internalFlag.NewBoolPtrValue() + + flagset.Var(&boolVal, "boolean-value", "Boolean value") + + if err := flagset.Parse(args); err != nil { + t.Fatalf("Received an error parsing the flag: %v", err) } - got := *value.Value + got := boolVal.String() - if got != test.want { + if got != want { t.Errorf( - "Unexpected bool parsed from %s: want %t, got %t", - test.input, - test.want, + "Unexpected boolean value found after parsing BoolPtrValue: want %s, got %s", + want, got, ) } else { t.Logf( - "Expected bool parsed from %s: got %t", - test.input, + "Expected boolean value found after parsing BoolPtrValue: got %s", got, ) } } } -func TestNilBoolPtrValue(t *testing.T) { - value := internalFlag.NewBoolPtrValue() +func TestNotSetBoolPtrValue(t *testing.T) { + flagset := flag.NewFlagSet("test", flag.ExitOnError) + boolVal := internalFlag.NewBoolPtrValue() + + var otherVal string + + flagset.Var(&boolVal, "boolean-value", "Boolean value") + flagset.StringVar(&otherVal, "other-value", "", "Another value") + + args := []string{"--other-value", "other-value"} + + if err := flagset.Parse(args); err != nil { + t.Fatalf("Received an error parsing the flag: %v", err) + } + want := "NOT SET" - got := value.String() + got := boolVal.String() if got != want { t.Errorf("Unexpected string returned from the nil value; want %s, got %s", want, got) diff --git a/internal/flag/timedurationvalue.go b/internal/flag/timedurationvalue.go index cdf02ac..6524372 100644 --- a/internal/flag/timedurationvalue.go +++ b/internal/flag/timedurationvalue.go @@ -2,16 +2,21 @@ package flag import ( "fmt" + "regexp" + "strconv" + "strings" "time" ) +const timeDurationRegexPattern string = `[0-9]{1,4}\s+(days?|hours?|minutes?|seconds?)` + type TimeDurationValue struct { Duration time.Duration } func NewTimeDurationValue() TimeDurationValue { return TimeDurationValue{ - Duration: 0 * time.Second, + Duration: time.Duration(0), } } @@ -19,13 +24,56 @@ func (v TimeDurationValue) String() string { return v.Duration.String() } -func (v *TimeDurationValue) Set(text string) error { - duration, err := time.ParseDuration(text) - if err != nil { - return fmt.Errorf("unable to parse the value as time duration: %w", err) +func (v *TimeDurationValue) Set(value string) error { + pattern := regexp.MustCompile(timeDurationRegexPattern) + matches := pattern.FindAllString(value, -1) + + days, hours, minutes, seconds := 0, 0, 0, 0 + + var err error + + for ind := range len(matches) { + switch { + case strings.Contains(matches[ind], "day"): + days, err = parseInt(matches[ind]) + if err != nil { + return fmt.Errorf("unable to parse the number of days from %s: %w", matches[ind], err) + } + case strings.Contains(matches[ind], "hour"): + hours, err = parseInt(matches[ind]) + if err != nil { + return fmt.Errorf("unable to parse the number of hours from %s: %w", matches[ind], err) + } + case strings.Contains(matches[ind], "minute"): + minutes, err = parseInt(matches[ind]) + if err != nil { + return fmt.Errorf("unable to parse the number of minutes from %s: %w", matches[ind], err) + } + case strings.Contains(matches[ind], "second"): + seconds, err = parseInt(matches[ind]) + if err != nil { + return fmt.Errorf("unable to parse the number of seconds from %s: %w", matches[ind], err) + } + } } - v.Duration = duration + durationValue := (days * 86400) + (hours * 3600) + (minutes * 60) + seconds + + v.Duration = time.Duration(durationValue) * time.Second return nil } + +func parseInt(text string) (int, error) { + split := strings.SplitN(text, " ", 2) + if len(split) != 2 { + return 0, fmt.Errorf("unexpected number of split for %s: want 2, got %d", text, len(split)) + } + + output, err := strconv.Atoi(split[0]) + if err != nil { + return 0, fmt.Errorf("unable to convert %s to an integer: %w", text, err) + } + + return output, nil +} diff --git a/internal/flag/timedurationvalue_test.go b/internal/flag/timedurationvalue_test.go new file mode 100644 index 0000000..95103b8 --- /dev/null +++ b/internal/flag/timedurationvalue_test.go @@ -0,0 +1,67 @@ +package flag_test + +import ( + "flag" + "slices" + "testing" + + internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag" +) + +func TestTimeDurationValue(t *testing.T) { + parsingTests := []struct { + input string + want string + }{ + { + input: `"1 day"`, + want: "24h0m0s", + }, + { + input: `"3 days, 5 hours, 39 minutes and 6 seconds"`, + want: "77h39m6s", + }, + { + input: `"1 minute and 30 seconds"`, + want: "1m30s", + }, + { + input: `"(7 seconds) (21 hours) (41 days)"`, + want: "1005h0m7s", + }, + } + + for _, test := range slices.All(parsingTests) { + args := []string{"--duration", test.input} + + t.Run("Flag parsing test: "+test.input, testTimeDurationValueParsing(args, test.want)) + } +} + +func testTimeDurationValueParsing(args []string, want string) func(t *testing.T) { + return func(t *testing.T) { + flagset := flag.NewFlagSet("test", flag.ExitOnError) + duration := internalFlag.NewTimeDurationValue() + + flagset.Var(&duration, "duration", "Duration value") + + if err := flagset.Parse(args); err != nil { + t.Fatalf("Received an error parsing the flag: %v", err) + } + + got := duration.String() + + if got != want { + t.Errorf( + "Unexpected duration parsed from the flag: want %s, got %s", + want, + got, + ) + } else { + t.Logf( + "Expected duration parsed from the flag: got %s", + got, + ) + } + } +} -- 2.45.2