diff --git a/flag_text.go b/flag_text.go new file mode 100644 index 0000000000..a1cc2c3636 --- /dev/null +++ b/flag_text.go @@ -0,0 +1,86 @@ +package cli + +import ( + "encoding" + "strings" +) + +// TextMarshalUnmarshaler is the interface implemented by types that can marshal +// themselves to and from a textual form. It is the value type used by TextFlag, +// mirroring the standard library's flag.TextVar, and is satisfied by types such +// as *slog.LevelVar, *net/netip.Addr, and *time.Time. +type TextMarshalUnmarshaler interface { + encoding.TextMarshaler + encoding.TextUnmarshaler +} + +type TextFlag = FlagBase[TextMarshalUnmarshaler, StringConfig, textValue] + +// -- TextMarshalUnmarshaler Value +type textValue struct { + destination TextMarshalUnmarshaler + trimSpace bool +} + +// Below functions are to satisfy the ValueCreator interface + +func (t textValue) Create(val TextMarshalUnmarshaler, p *TextMarshalUnmarshaler, c StringConfig) Value { + // Only overwrite the target when a non-nil default value is given, so that + // a Destination pointing at an existing target is preserved (unlike the + // concrete flag types, T here is an interface whose nil default would + // otherwise clobber the destination). + if val != nil { + *p = val + } + return &textValue{ + destination: *p, + trimSpace: c.TrimSpace, + } +} + +func (t textValue) ToString(val TextMarshalUnmarshaler) string { + if val == nil { + return "" + } + text, err := val.MarshalText() + if err != nil { + return "" + } + return string(text) +} + +// Below functions are to satisfy the flag.Value interface + +func (t *textValue) Set(val string) error { + if t.destination == nil { + return nil + } + if t.trimSpace { + val = strings.TrimSpace(val) + } + return t.destination.UnmarshalText([]byte(val)) +} + +func (t *textValue) Get() any { return t.destination } + +func (t *textValue) String() string { + if t.destination == nil { + return "" + } + text, err := t.destination.MarshalText() + if err != nil { + return "" + } + return string(text) +} + +// Text looks up the value of a local TextFlag, returns nil if not found +func (cmd *Command) Text(name string) TextMarshalUnmarshaler { + if v, ok := cmd.Value(name).(TextMarshalUnmarshaler); ok { + tracef("text available for flag name %[1]q with value=%[2]v (cmd=%[3]q)", name, v, cmd.Name) + return v + } + + tracef("text NOT available for flag name %[1]q (cmd=%[2]q)", name, cmd.Name) + return nil +} diff --git a/flag_text_test.go b/flag_text_test.go new file mode 100644 index 0000000000..50b12d9db7 --- /dev/null +++ b/flag_text_test.go @@ -0,0 +1,158 @@ +package cli + +import ( + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// errText is a TextMarshalUnmarshaler whose MarshalText always fails, used to +// exercise the error branches of textValue.ToString and textValue.String. +type errText struct{} + +func (errText) MarshalText() ([]byte, error) { return nil, errors.New("marshal boom") } + +func (errText) UnmarshalText([]byte) error { return nil } + +func TestTextFlagSetFromArg(t *testing.T) { + lv := &slog.LevelVar{} + cmd := &Command{ + Flags: []Flag{ + &TextFlag{Name: "level", Value: lv}, + }, + } + + require.NoError(t, cmd.Run(buildTestContext(t), []string{"", "--level", "WARN"})) + assert.Equal(t, slog.LevelWarn, lv.Level()) +} + +func TestTextFlagDefaultValue(t *testing.T) { + lv := &slog.LevelVar{} + lv.Set(slog.LevelError) + cmd := &Command{ + Flags: []Flag{ + &TextFlag{Name: "level", Value: lv}, + }, + } + + // Without the flag being passed, the value keeps its default. + require.NoError(t, cmd.Run(buildTestContext(t), []string{""})) + assert.Equal(t, slog.LevelError, lv.Level()) +} + +func TestTextFlagDestination(t *testing.T) { + lv := &slog.LevelVar{} + var dest TextMarshalUnmarshaler = lv + cmd := &Command{ + Flags: []Flag{ + &TextFlag{Name: "level", Destination: &dest}, + }, + } + + require.NoError(t, cmd.Run(buildTestContext(t), []string{"", "--level", "DEBUG"})) + assert.Equal(t, slog.LevelDebug, lv.Level()) +} + +func TestTextFlagTrimSpace(t *testing.T) { + lv := &slog.LevelVar{} + cmd := &Command{ + Flags: []Flag{ + &TextFlag{Name: "level", Value: lv, Config: StringConfig{TrimSpace: true}}, + }, + } + + require.NoError(t, cmd.Run(buildTestContext(t), []string{"", "--level", " INFO "})) + assert.Equal(t, slog.LevelInfo, lv.Level()) +} + +func TestTextFlagFromEnvSource(t *testing.T) { + t.Setenv("LOG_LEVEL", "ERROR") + lv := &slog.LevelVar{} + cmd := &Command{ + Flags: []Flag{ + &TextFlag{Name: "level", Value: lv, Sources: EnvVars("LOG_LEVEL")}, + }, + } + + require.NoError(t, cmd.Run(buildTestContext(t), []string{""})) + assert.Equal(t, slog.LevelError, lv.Level()) +} + +func TestTextFlagInvalidValue(t *testing.T) { + lv := &slog.LevelVar{} + cmd := &Command{ + Flags: []Flag{ + &TextFlag{Name: "level", Value: lv}, + }, + } + + err := cmd.Run(buildTestContext(t), []string{"", "--level", "NOPE"}) + require.Error(t, err) +} + +func TestTextFlagValueFromCommand(t *testing.T) { + lv := &slog.LevelVar{} + f := &TextFlag{Name: "level", Value: lv} + cmd := &Command{ + Flags: []Flag{f}, + } + + require.NoError(t, cmd.Set("level", "WARN")) + require.Equal(t, lv, cmd.Text(f.Name)) +} + +func TestTextFlagTextNotAvailable(t *testing.T) { + cmd := &Command{ + Flags: []Flag{ + &StringFlag{Name: "str"}, + }, + } + + // The flag exists but its value is a string, not a + // TextMarshalUnmarshaler, so Text returns nil. + require.NoError(t, cmd.Set("str", "value")) + assert.Nil(t, cmd.Text("str")) + + // An unknown flag name also returns nil. + assert.Nil(t, cmd.Text("missing")) +} + +func TestTextValueToString(t *testing.T) { + var tv textValue + + // A nil value marshals to the empty string. + assert.Equal(t, "", tv.ToString(nil)) + + // A MarshalText error is swallowed and yields the empty string. + assert.Equal(t, "", tv.ToString(errText{})) + + // A value that marshals cleanly is rendered. + lv := &slog.LevelVar{} + lv.Set(slog.LevelWarn) + assert.Equal(t, "WARN", tv.ToString(lv)) +} + +func TestTextValueSetNilDestination(t *testing.T) { + // Set is a no-op when there is no destination to unmarshal into. + tv := &textValue{} + require.NoError(t, tv.Set("anything")) +} + +func TestTextValueString(t *testing.T) { + // A nil destination stringifies to the empty string. + tv := &textValue{} + assert.Equal(t, "", tv.String()) + + // A MarshalText error is swallowed and yields the empty string. + tvErr := &textValue{destination: errText{}} + assert.Equal(t, "", tvErr.String()) + + // A destination that marshals cleanly is rendered. + lv := &slog.LevelVar{} + lv.Set(slog.LevelWarn) + tvOK := &textValue{destination: lv} + assert.Equal(t, "WARN", tvOK.String()) +} diff --git a/godoc-current.txt b/godoc-current.txt index a5382b2d87..45e60e2566 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -745,6 +745,9 @@ func (cmd *Command) StringSlice(name string) []string StringSlice looks up the value of a local StringSliceFlag, returns nil if not found +func (cmd *Command) Text(name string) TextMarshalUnmarshaler + Text looks up the value of a local TextFlag, returns nil if not found + func (cmd *Command) Timestamp(name string) time.Time Timestamp gets the timestamp from a flag name @@ -1416,6 +1419,17 @@ type SuggestCommandFunc func(commands []*Command, provided string) string type SuggestFlagFunc func(flags []Flag, provided string, hideHelp bool) string +type TextFlag = FlagBase[TextMarshalUnmarshaler, StringConfig, textValue] + +type TextMarshalUnmarshaler interface { + encoding.TextMarshaler + encoding.TextUnmarshaler +} + TextMarshalUnmarshaler is the interface implemented by types that can + marshal themselves to and from a textual form. It is the value type used by + TextFlag, mirroring the standard library's flag.TextVar, and is satisfied by + types such as *slog.LevelVar, *net/netip.Addr, and *time.Time. + type TimestampArg = ArgumentBase[time.Time, TimestampConfig, timestampValue] type TimestampArgs = ArgumentsBase[time.Time, TimestampConfig, timestampValue] diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index a5382b2d87..45e60e2566 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -745,6 +745,9 @@ func (cmd *Command) StringSlice(name string) []string StringSlice looks up the value of a local StringSliceFlag, returns nil if not found +func (cmd *Command) Text(name string) TextMarshalUnmarshaler + Text looks up the value of a local TextFlag, returns nil if not found + func (cmd *Command) Timestamp(name string) time.Time Timestamp gets the timestamp from a flag name @@ -1416,6 +1419,17 @@ type SuggestCommandFunc func(commands []*Command, provided string) string type SuggestFlagFunc func(flags []Flag, provided string, hideHelp bool) string +type TextFlag = FlagBase[TextMarshalUnmarshaler, StringConfig, textValue] + +type TextMarshalUnmarshaler interface { + encoding.TextMarshaler + encoding.TextUnmarshaler +} + TextMarshalUnmarshaler is the interface implemented by types that can + marshal themselves to and from a textual form. It is the value type used by + TextFlag, mirroring the standard library's flag.TextVar, and is satisfied by + types such as *slog.LevelVar, *net/netip.Addr, and *time.Time. + type TimestampArg = ArgumentBase[time.Time, TimestampConfig, timestampValue] type TimestampArgs = ArgumentsBase[time.Time, TimestampConfig, timestampValue]