diff --git a/configurator_test.go b/configurator_test.go index 588ef3f..df743de 100644 --- a/configurator_test.go +++ b/configurator_test.go @@ -742,6 +742,135 @@ func TestHonorRequired(t *testing.T) { } } +type prefixedConfig struct { + prefix []string + value any +} + +func (p *prefixedConfig) WalkPrefix() []string { return p.prefix } +func (p *prefixedConfig) WalkValue() any { return p.value } + +type outerPrefixedConfig struct { + Direct string `mock:"direct"` + Nested *prefixedConfig +} + +func TestPrefixedPopulate(t *testing.T) { + for _, tc := range []struct { + name string + have any + provider provider.Provider + want any + }{ + { + name: "single segment prefix", + have: &prefixedConfig{ + prefix: []string{"ns"}, + value: &basicStruct1{}, + }, + provider: &mockProvider{st: map[string]string{"ns.Fiz": "bar"}}, + want: &basicStruct1{Fiz: "bar"}, + }, + { + name: "multi segment prefix", + have: &prefixedConfig{ + prefix: []string{"foo", "bar"}, + value: &basicStruct1{}, + }, + provider: &mockProvider{st: map[string]string{"foo.bar.Fiz": "baz"}}, + want: &basicStruct1{Fiz: "baz"}, + }, + { + name: "prefix with tagged field", + have: &prefixedConfig{ + prefix: []string{"ns"}, + value: &basicStructBool{}, + }, + provider: &mockProvider{st: map[string]string{"ns.fzz": "true"}}, + want: &basicStructBool{Bool: true}, + }, + { + name: "prefix with nested struct", + have: &prefixedConfig{ + prefix: []string{"pfx"}, + value: &nestedStruct{}, + }, + provider: &mockProvider{st: map[string]string{"pfx.nested.inner": "42"}}, + want: func() *nestedStruct { + var v int64 = 42 + ns := &nestedStruct{} + + ns.Nested.Inner = &v + + return ns + }(), + }, + { + name: "empty prefix behaves like normal populate", + have: &prefixedConfig{ + prefix: nil, + value: &basicStruct1{}, + }, + provider: &mockProvider{st: map[string]string{"Fiz": "val"}}, + want: &basicStruct1{Fiz: "val"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + c := NewConfiguratorWithOptions(WithProviders(tc.provider)) + + err := c.Populate(context.Background(), tc.have) + + require.NoError(t, err) + assert.Equal(t, tc.want, tc.have.(*prefixedConfig).value) + }) + } +} + +func TestNestedPrefixedPopulate(t *testing.T) { + for _, tc := range []struct { + name string + have *outerPrefixedConfig + provider provider.Provider + wantOuter string + wantInner *basicStruct1 + }{ + { + name: "nested prefixed field", + have: &outerPrefixedConfig{ + Nested: &prefixedConfig{ + prefix: []string{"ns"}, + value: &basicStruct1{}, + }, + }, + provider: &mockProvider{st: map[string]string{"direct": "top", "Nested.ns.Fiz": "deep"}}, + wantOuter: "top", + wantInner: &basicStruct1{Fiz: "deep"}, + }, + { + name: "nested prefixed with multi-segment prefix", + have: &outerPrefixedConfig{ + Nested: &prefixedConfig{ + prefix: []string{"a", "b"}, + value: &basicStruct1{}, + }, + }, + provider: &mockProvider{st: map[string]string{"Nested.a.b.Fiz": "val"}}, + wantOuter: "", + wantInner: &basicStruct1{Fiz: "val"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + c := NewConfiguratorWithOptions(WithProviders(tc.provider)) + + err := c.Populate(context.Background(), tc.have) + + require.NoError(t, err) + assert.Equal(t, tc.wantOuter, tc.have.Direct) + assert.Equal(t, tc.wantInner, tc.have.Nested.value) + }) + } +} + func ExampleNewDefaultConfigurator() { os.Setenv("FOO", "bar") cfg := struct { diff --git a/internal/help/writer.go b/internal/help/writer.go index decd312..1c07215 100644 --- a/internal/help/writer.go +++ b/internal/help/writer.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "reflect" "strings" "github.com/upfluence/cfg/internal/reflectutil" @@ -26,8 +27,34 @@ var ( flags.NewDefaultProvider(), }, } + + helperType = reflect.TypeFor[helper]() ) +type helper interface { + Help() string +} + +func fieldHelp(f *walker.Field) string { + fv := reflectutil.IndirectedValue(f.Value).FieldByName(f.Field.Name) + + if fv.CanAddr() && fv.Addr().Type().Implements(helperType) { + if h := fv.Addr().Interface().(helper).Help(); h != "" { + return h + } + } else if fv.Type().Implements(helperType) && fv.CanInterface() { + if h := fv.Interface().(helper).Help(); h != "" { + return h + } + } + + if h, ok := f.Field.Tag.Lookup("help"); ok { + return h + } + + return "" +} + type Writer struct { Providers []provider.Provider Factory setter.Factory @@ -69,12 +96,12 @@ func (w *Writer) writeConfig(out io.Writer, in interface{}) (int, error) { b.WriteString(": ") b.WriteString(s.String()) - if h, ok := f.Field.Tag.Lookup("help"); ok { + if h := fieldHelp(f); h != "" { b.WriteString(" ") b.WriteString(h) } - defaultValue := w.fieldDefault(f) + defaultValue := fieldDefault(f) providedKeys, tagDefault := w.providerKeys(f) if tagDefault != "" { @@ -106,7 +133,7 @@ func (w *Writer) writeConfig(out io.Writer, in interface{}) (int, error) { ) } -func (w *Writer) fieldDefault(f *walker.Field) string { +func fieldDefault(f *walker.Field) string { fv := reflectutil.IndirectedValue(f.Value).FieldByName(f.Field.Name) if reflectutil.IsZero(fv) { diff --git a/internal/help/writer_test.go b/internal/help/writer_test.go index 4660719..c2f95a9 100644 --- a/internal/help/writer_test.go +++ b/internal/help/writer_test.go @@ -40,6 +40,22 @@ type dbConfig struct { Port int `default:"5432" env:"PORT" flag:"port"` } +type helpString string + +func (h helpString) Help() string { return string(h) } + +type helperFieldConfig struct { + Dynamic helpString `env:"-" flag:"dyn"` +} + +type helperOverridesTagConfig struct { + Dynamic helpString `env:"-" flag:"dyn" help:"from tag"` +} + +type helperEmptyFallsBackConfig struct { + Dynamic helpString `env:"-" flag:"dyn" help:"from tag"` +} + func TestPrintDefaults(t *testing.T) { for _, tt := range []struct { name string @@ -85,6 +101,24 @@ func TestPrintDefaults(t *testing.T) { "\t- DB.Host: string (default: localhost) (env: DB_HOST, flag: --db.host)\n" + "\t- DB.Port: integer (default: 5432) (env: DB_PORT, flag: --db.port)\n", }, + { + name: "Help() method provides help text", + in: &helperFieldConfig{Dynamic: "dynamic help"}, + out: "Arguments:\n" + + "\t- Dynamic: string dynamic help (default: dynamic help) (flag: --dyn)\n", + }, + { + name: "Help() method overrides struct tag", + in: &helperOverridesTagConfig{Dynamic: "from method"}, + out: "Arguments:\n" + + "\t- Dynamic: string from method (default: from method) (flag: --dyn)\n", + }, + { + name: "empty Help() falls back to struct tag", + in: &helperEmptyFallsBackConfig{}, + out: "Arguments:\n" + + "\t- Dynamic: string from tag (flag: --dyn)\n", + }, } { t.Run(tt.name, func(t *testing.T) { var b bytes.Buffer diff --git a/internal/setter/setter.go b/internal/setter/setter.go index cfe6123..9737b49 100644 --- a/internal/setter/setter.go +++ b/internal/setter/setter.go @@ -18,9 +18,9 @@ import ( var ( durationType = reflect.TypeOf(time.Duration(0)) timeType = reflect.TypeOf(time.Time{}) - valueType = reflect.TypeOf((*Value)(nil)).Elem() - textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() - jsonUnmarshalerType = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() + valueType = reflect.TypeFor[Value]() + textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + jsonUnmarshalerType = reflect.TypeFor[json.Unmarshaler]() durationParser = &staticParser{t: "duration", fn: parseDuration} boolParser = &staticParser{t: "bool", fn: parseBool} diff --git a/internal/walker/walker.go b/internal/walker/walker.go index 0a169db..b583088 100644 --- a/internal/walker/walker.go +++ b/internal/walker/walker.go @@ -21,7 +21,39 @@ type Field struct { type WalkFunc func(*Field) error -func Walk(in interface{}, fn WalkFunc) error { +// Prefixed is an optional interface that a value passed to Walk can +// implement to inject dynamic key prefix segments. When Walk receives a +// Prefixed value it builds a synthetic ancestor chain from the prefix +// segments and walks the inner value returned by WalkValue. +type Prefixed interface { + WalkPrefix() []string + WalkValue() any +} + +func Walk(in any, fn WalkFunc) error { + return walkValue(in, fn, nil) +} + +func walkValue(in any, fn WalkFunc, ancestor *Field) error { + if p, ok := in.(Prefixed); ok { + return walkPrefixed(p, fn, ancestor) + } + + return walkStruct(in, fn, ancestor) +} + +func walkPrefixed(p Prefixed, fn WalkFunc, ancestor *Field) error { + for _, seg := range p.WalkPrefix() { + ancestor = &Field{ + Field: reflect.StructField{Name: seg}, + Ancestor: ancestor, + } + } + + return walkValue(p.WalkValue(), fn, ancestor) +} + +func walkStruct(in any, fn WalkFunc, ancestor *Field) error { if in == nil { return ErrShouldBeAStructPtr } @@ -40,7 +72,7 @@ func Walk(in interface{}, fn WalkFunc) error { return ErrShouldBeAStructPtr } - return walk(inv, fn, nil) + return walk(inv, fn, ancestor) } func indirectedType(t reflect.Type) reflect.Type { @@ -67,6 +99,16 @@ func addressValue(v reflect.Value) reflect.Value { return v.Addr() } +func walkField(nv reflect.Value, fn WalkFunc, f *Field) error { + if nv.CanInterface() { + if p, ok := nv.Interface().(Prefixed); ok { + return walkPrefixed(p, fn, f) + } + } + + return walk(nv, fn, f) +} + func walk(v reflect.Value, fn WalkFunc, a *Field) error { vit := indirectedType(v.Type()) @@ -106,7 +148,7 @@ func walk(v reflect.Value, fn WalkFunc, a *Field) error { nv.Set(reflect.New(sf.Type.Elem())) } - if err := walk(nv, fn, &f); err != nil { + if err := walkField(nv, fn, &f); err != nil { return err } diff --git a/internal/walker/walker_test.go b/internal/walker/walker_test.go index a7a730c..26e0ba4 100644 --- a/internal/walker/walker_test.go +++ b/internal/walker/walker_test.go @@ -33,6 +33,18 @@ type foo struct { buz } +type prefixed struct { + prefix []string + value any +} + +func (p *prefixed) WalkPrefix() []string { return p.prefix } +func (p *prefixed) WalkValue() any { return p.value } + +type outerWithPrefixed struct { + Nested *prefixed +} + func TestWalk(t *testing.T) { var ( castedBazNil *baz @@ -89,6 +101,60 @@ func TestWalk(t *testing.T) { }, errfn: errtest.NoError(), }, + { + name: "prefixed nil value", + in: &prefixed{prefix: []string{"x"}, value: nil}, + outfn: func(t *testing.T, vs []string) { + assert.Empty(t, vs) + }, + errfn: errtest.ErrorEqual(ErrShouldBeAStructPtr), + }, + { + name: "prefixed single segment", + in: &prefixed{prefix: []string{"pfx"}, value: &buz{}}, + outfn: func(t *testing.T, vs []string) { + assert.Equal(t, []string{"Foo.pfx"}, vs) + }, + errfn: errtest.NoError(), + }, + { + name: "prefixed multiple segments", + in: &prefixed{prefix: []string{"a", "b"}, value: &buz{}}, + outfn: func(t *testing.T, vs []string) { + assert.Equal(t, []string{"Foo.b.a"}, vs) + }, + errfn: errtest.NoError(), + }, + { + name: "prefixed nested struct", + in: &prefixed{prefix: []string{"ns"}, value: &baz{}}, + outfn: func(t *testing.T, vs []string) { + assert.Equal( + t, + []string{"Struct.ns", "Foo.Struct.ns", "StructPtr.ns", "Foo.StructPtr.ns"}, + vs, + ) + }, + errfn: errtest.NoError(), + }, + { + name: "prefixed empty prefix", + in: &prefixed{prefix: nil, value: &buz{}}, + outfn: func(t *testing.T, vs []string) { + assert.Equal(t, []string{"Foo"}, vs) + }, + errfn: errtest.NoError(), + }, + { + name: "nested prefixed field", + in: &outerWithPrefixed{ + Nested: &prefixed{prefix: []string{"dyn"}, value: &buz{}}, + }, + outfn: func(t *testing.T, vs []string) { + assert.Equal(t, []string{"Nested", "Foo.dyn.Nested"}, vs) + }, + errfn: errtest.NoError(), + }, } { t.Run(tt.name, func(t *testing.T) { var vs []string diff --git a/x/cli/app.go b/x/cli/app.go index 76fc572..b54931f 100644 --- a/x/cli/app.go +++ b/x/cli/app.go @@ -19,7 +19,12 @@ type App struct { name string args []string - cmd Command + + stdin io.Reader + stdout io.Writer + stderr io.Writer + + cmd Command } func NewApp(opts ...Option) *App { @@ -33,6 +38,9 @@ func NewApp(opts ...Option) *App { ps: o.ps, name: o.name, args: o.args, + stdin: o.stdin, + stdout: o.stdout, + stderr: o.stderr, opts: o.opts, newFunc: o.newFunc, cmd: o.command(), @@ -106,7 +114,7 @@ func (a *App) commandContext() CommandContext { ) return newCommandContext( - a.name, + a, cmds, args, a.newFunc(append(a.opts, cfg.WithProviders(ps...))...), diff --git a/x/cli/command_context.go b/x/cli/command_context.go index 5620bf9..af23689 100644 --- a/x/cli/command_context.go +++ b/x/cli/command_context.go @@ -31,18 +31,18 @@ type CommandContext struct { wd string } -func newCommandContext(name string, cmds []string, args map[string]string, c cfg.Configurator) CommandContext { +func newCommandContext(a *App, cmds []string, args map[string]string, c cfg.Configurator) CommandContext { var wd, _ = os.Getwd() return CommandContext{ Configurator: c, Args: cmds, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - Logger: newLogger(os.Stdout, os.Stderr, record.Notice), + Stdin: a.stdin, + Stdout: a.stdout, + Stderr: a.stderr, + Logger: newLogger(a.stdout, a.stderr, record.Notice), args: args, - appName: name, + appName: a.name, wd: wd, env: os.Environ(), } diff --git a/x/cli/command_context_test.go b/x/cli/command_context_test.go index 78bbb34..1d2ff69 100644 --- a/x/cli/command_context_test.go +++ b/x/cli/command_context_test.go @@ -15,7 +15,7 @@ func TestSubCommand(t *testing.T) { var ( buf bytes.Buffer - cctx = newCommandContext("", nil, nil, nil) + cctx = newCommandContext(&App{stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr}, nil, nil, nil) ) cctx.Stdout = &buf diff --git a/x/cli/option.go b/x/cli/option.go index 20c8cf1..dcfcc36 100644 --- a/x/cli/option.go +++ b/x/cli/option.go @@ -1,6 +1,7 @@ package cli import ( + "io" "os" "github.com/upfluence/cfg" @@ -21,6 +22,10 @@ func WithCommand(cmd Command) Option { return func(o *options) { o.cmd = cmd } } +func WithArgs(args []string) Option { + return func(o *options) { o.args = args } +} + func WithConfiguratorOptions(opts ...cfg.Option) Option { return func(o *options) { o.opts = append(o.opts, opts...) } } @@ -29,12 +34,28 @@ func WithNewConfiguratorFunc(fn NewConfiguratorFunc) Option { return func(o *options) { o.newFunc = fn } } +func WithStdin(r io.Reader) Option { + return func(o *options) { o.stdin = r } +} + +func WithStdout(w io.Writer) Option { + return func(o *options) { o.stdout = w } +} + +func WithStderr(w io.Writer) Option { + return func(o *options) { o.stderr = w } +} + type options struct { name string args []string version string + stdin io.Reader + stdout io.Writer + stderr io.Writer + cmd Command ps []provider.Provider opts []cfg.Option @@ -46,6 +67,9 @@ func defaultOptions() *options { name: os.Args[0], args: os.Args[1:], version: Version, + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, ps: []provider.Provider{dflt.Provider{}, env.NewDefaultProvider()}, newFunc: cfg.NewConfiguratorWithOptions, opts: []cfg.Option{cfg.HonorRequired}, diff --git a/x/cli/output/command.go b/x/cli/output/command.go new file mode 100644 index 0000000..00d9e36 --- /dev/null +++ b/x/cli/output/command.go @@ -0,0 +1,180 @@ +package output + +import ( + "context" + "fmt" + "io" + "slices" + "sort" + "strings" + + "github.com/upfluence/errors" + + "github.com/upfluence/cfg" + "github.com/upfluence/cfg/x/cli" + "github.com/upfluence/cfg/x/cli/output/printer" + "github.com/upfluence/cfg/x/cli/output/printer/json" + "github.com/upfluence/cfg/x/cli/output/printer/yaml" +) + +type Command[T any] interface { + WriteSynopsis(io.Writer, cli.IntrospectionOptions) (int, error) + WriteHelp(io.Writer, cli.IntrospectionOptions) (int, error) + + Run(context.Context, cli.CommandContext) (T, error) +} + +type outputFormat struct { + keys []string + selected string +} + +func (of outputFormat) String() string { return of.selected } + +func (of *outputFormat) Parse(s string) error { + of.selected = s + + return nil +} + +func (of outputFormat) Help() string { + return fmt.Sprintf("Output format (formats: [%s])", strings.Join(of.keys, " ")) +} + +type outputConfig struct { + OutputFormat outputFormat `flag:"o,output"` +} + +func WrapCommand[T any](cmd Command[T], defaultPrinter printer.Printer[T], additionalPrinters ...printer.Printer[T]) cli.Command { + printers := make(map[string]printer.Printer[T], 1+len(additionalPrinters)) + printers[defaultPrinter.Key()] = defaultPrinter + + for _, p := range additionalPrinters { + printers[p.Key()] = p + } + + keys := make([]string, 0, len(printers)) + + for k := range printers { + keys = append(keys, k) + } + + sort.Strings(keys) + + return &wrappedCommand[T]{ + cmd: cmd, + printers: printers, + outputConfig: outputConfig{ + OutputFormat: outputFormat{ + keys: keys, + selected: defaultPrinter.Key(), + }, + }, + } +} + +func WrapDefaultCommand[T any](cmd Command[T], additionalPrinters ...printer.Printer[T]) cli.Command { + return WrapCommand( + cmd, + printer.WrapAnyPrinter[T](yaml.Printer), + append( + []printer.Printer[T]{ + printer.WrapAnyPrinter[T](json.Printer), + }, + additionalPrinters..., + )..., + ) +} + +type prefixedConfig struct { + prefix string + value any +} + +func (p *prefixedConfig) WalkPrefix() []string { return []string{"output", p.prefix} } +func (p *prefixedConfig) WalkValue() any { return p.value } + +type prefixedConfigurator struct { + inner cfg.Configurator + prefix string +} + +func (pc *prefixedConfigurator) Populate(ctx context.Context, in any) error { + return pc.inner.Populate(ctx, &prefixedConfig{prefix: pc.prefix, value: in}) //nolint:wrapcheck +} + +func (pc *prefixedConfigurator) WithOptions(opts ...cfg.Option) cfg.Configurator { + return &prefixedConfigurator{ + inner: pc.inner.WithOptions(opts...), + prefix: pc.prefix, + } +} + +type wrappedCommand[T any] struct { + cmd Command[T] + + outputConfig outputConfig + printers map[string]printer.Printer[T] +} + +func (wc *wrappedCommand[T]) wrapIntrospectionOptions(opts cli.IntrospectionOptions) cli.IntrospectionOptions { + opts.Definitions = append( + slices.Clone(opts.Definitions), + cli.CommandDefinition{ + Configs: []any{&wc.outputConfig}, + }, + ) + + for _, p := range wc.printers { + def := p.CommandDefinition() + key := p.Key() + + for i, c := range def.Configs { + def.Configs[i] = &prefixedConfig{prefix: key, value: c} + } + + opts.Definitions = append(opts.Definitions, def) + } + + return opts +} + +func (wc *wrappedCommand[T]) WriteSynopsis(w io.Writer, opts cli.IntrospectionOptions) (int, error) { + return wc.cmd.WriteSynopsis(w, wc.wrapIntrospectionOptions(opts)) //nolint:wrapcheck +} + +func (wc *wrappedCommand[T]) WriteHelp(w io.Writer, opts cli.IntrospectionOptions) (int, error) { + return wc.cmd.WriteHelp(w, wc.wrapIntrospectionOptions(opts)) //nolint:wrapcheck +} + +func (wc *wrappedCommand[T]) Run(ctx context.Context, cctx cli.CommandContext) error { + var oc = outputConfig{ + OutputFormat: outputFormat{ + keys: wc.outputConfig.OutputFormat.keys, + selected: wc.outputConfig.OutputFormat.selected, + }, + } + + if err := cctx.Configurator.Populate(ctx, &oc); err != nil { + return errors.Wrap(err, "populate output config") + } + + p, ok := wc.printers[oc.OutputFormat.selected] + + if !ok { + return fmt.Errorf("unknown output format: %q", oc.OutputFormat.selected) + } + + v, err := wc.cmd.Run(ctx, cctx) + + if err != nil { + return err //nolint:wrapcheck + } + + cctx.Configurator = &prefixedConfigurator{ + inner: cctx.Configurator, + prefix: oc.OutputFormat.selected, + } + + return p.Print(ctx, cctx, v) //nolint:wrapcheck +} diff --git a/x/cli/output/command_test.go b/x/cli/output/command_test.go new file mode 100644 index 0000000..723ac78 --- /dev/null +++ b/x/cli/output/command_test.go @@ -0,0 +1,156 @@ +package output_test + +import ( + "bytes" + "context" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upfluence/cfg/x/cli" + "github.com/upfluence/cfg/x/cli/output" + "github.com/upfluence/cfg/x/cli/output/printer" + pjson "github.com/upfluence/cfg/x/cli/output/printer/json" + pyaml "github.com/upfluence/cfg/x/cli/output/printer/yaml" +) + +type testConfig struct { + Foo string `flag:"foo,f"` + Bar string `flag:"bar,b"` +} + +type testResult struct { + Message string `json:"message" yaml:"message"` + Foo string `json:"foo" yaml:"foo"` +} + +func defaultStaticCommand() output.StaticCommand[testResult] { + return output.DefaultStaticCommand( + func(_ context.Context, _ cli.CommandContext, c testConfig) (testResult, error) { + return testResult{Message: "ok", Foo: c.Foo}, nil + }, + ) +} + +func canonicalString(v string) string { + return regexp.MustCompile(`\s+`).ReplaceAllString(v, " ") +} + +func TestRun(t *testing.T) { + for _, tc := range []struct { + name string + haveArgs []string + haveCmd cli.Command + wantCode int + wantOut string + wantErr string + }{ + { + name: "help shows output flag and json-indent", + haveArgs: []string{"-h"}, + haveCmd: output.WrapDefaultCommand[testResult]( + output.DefaultStaticCommand( + func(_ context.Context, _ cli.CommandContext, _ testConfig) (testResult, error) { + return testResult{}, nil + }, + output.WithShortHelp[testConfig, testResult]("test command"), + ), + ), + wantErr: `Description: + +Usage: +test-app [-o, --output] [--output.json.indent] [--foo, -f] [--bar, -b] +Arguments: +- OutputFormat: output.outputFormat Output format (formats: [json yaml]) (default: yaml) (env: OUTPUTFORMAT, flag: -o, --output) +- output.json.Indent: bool Indent JSON output (env: OUTPUT_JSON_INDENT, flag: --output.json.indent) +- Foo: string (env: FOO, flag: --foo, -f) +- Bar: string (env: BAR, flag: --bar, -b) `, + }, + { + name: "help with custom printers", + haveArgs: []string{"-h"}, + haveCmd: output.WrapCommand[testResult]( + output.DefaultStaticCommand( + func(_ context.Context, _ cli.CommandContext, _ testConfig) (testResult, error) { + return testResult{}, nil + }, + ), + printer.WrapAnyPrinter[testResult](pjson.Printer), + ), + wantErr: `Description: + +Usage: +test-app [-o, --output] [--output.json.indent] [--foo, -f] [--bar, -b] +Arguments: +- OutputFormat: output.outputFormat Output format (formats: [json]) (default: json) (env: OUTPUTFORMAT, flag: -o, --output) +- output.json.Indent: bool Indent JSON output (env: OUTPUT_JSON_INDENT, flag: --output.json.indent) +- Foo: string (env: FOO, flag: --foo, -f) +- Bar: string (env: BAR, flag: --bar, -b) `, + }, + { + name: "default format is yaml", + haveArgs: []string{"--foo", "hello"}, + haveCmd: output.WrapDefaultCommand[testResult](defaultStaticCommand()), + wantOut: "message: ok\nfoo: hello\n", + }, + { + name: "json format", + haveArgs: []string{"--foo", "hello", "-o", "json"}, + haveCmd: output.WrapDefaultCommand[testResult](defaultStaticCommand()), + wantOut: "{\"message\":\"ok\",\"foo\":\"hello\"}\n", + }, + { + name: "json format with indent", + haveArgs: []string{"--foo", "bar", "-o", "json", "--output.json.indent"}, + haveCmd: output.WrapDefaultCommand[testResult](defaultStaticCommand()), + wantOut: "{\n \"message\": \"ok\",\n \"foo\": \"bar\"\n}\n", + }, + { + name: "explicit yaml format", + haveArgs: []string{"--foo", "baz", "-o", "yaml"}, + haveCmd: output.WrapDefaultCommand[testResult](defaultStaticCommand()), + wantOut: "message: ok\nfoo: baz\n", + }, + { + name: "unknown format returns error", + haveArgs: []string{"-o", "xml"}, + haveCmd: output.WrapDefaultCommand[testResult]( + output.DefaultStaticCommand( + func(_ context.Context, _ cli.CommandContext, _ testConfig) (testResult, error) { + return testResult{}, nil + }, + ), + ), + wantCode: 1, + wantErr: `unknown output format: "xml"`, + }, + { + name: "WrapCommand with single printer", + haveArgs: []string{"--foo", "val"}, + haveCmd: output.WrapCommand[testResult]( + defaultStaticCommand(), + printer.WrapAnyPrinter[testResult](pyaml.Printer), + ), + wantOut: "message: ok\nfoo: val\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var outBuf, errBuf bytes.Buffer + + a := cli.NewApp( + cli.WithName("test-app"), + cli.WithCommand(tc.haveCmd), + cli.WithArgs(tc.haveArgs), + cli.WithStdout(&outBuf), + cli.WithStderr(&errBuf), + ) + + msg, code := a.Execute(context.Background()) + + assert.Equal(t, tc.wantCode, code) + assert.Equal(t, tc.wantOut, outBuf.String()) + assert.Equal(t, canonicalString(tc.wantErr), canonicalString(errBuf.String()+msg)) + }) + } +} diff --git a/x/cli/output/printer/json/printer.go b/x/cli/output/printer/json/printer.go new file mode 100644 index 0000000..0ad03ef --- /dev/null +++ b/x/cli/output/printer/json/printer.go @@ -0,0 +1,45 @@ +package json + +import ( + "context" + "encoding/json" + + "github.com/upfluence/errors" + + "github.com/upfluence/cfg/x/cli" + "github.com/upfluence/cfg/x/cli/output/printer" +) + +const key = "json" + +var Printer printer.AnyPrinter = anyPrinter{} + +type config struct { + Indent bool `flag:"indent" help:"Indent JSON output"` +} + +type anyPrinter struct{} + +func (anyPrinter) Key() string { return key } + +func (anyPrinter) CommandDefinition() cli.CommandDefinition { + return cli.CommandDefinition{ + Configs: []any{&config{}}, + } +} + +func (anyPrinter) Print(ctx context.Context, cctx cli.CommandContext, v any) error { + var cfg config + + if err := cctx.Configurator.Populate(ctx, &cfg); err != nil { + return errors.Wrap(err, "populate json config") + } + + enc := json.NewEncoder(cctx.Stdout) + + if cfg.Indent { + enc.SetIndent("", " ") + } + + return enc.Encode(v) //nolint:wrapcheck +} diff --git a/x/cli/output/printer/printer.go b/x/cli/output/printer/printer.go new file mode 100644 index 0000000..b44c99d --- /dev/null +++ b/x/cli/output/printer/printer.go @@ -0,0 +1,27 @@ +package printer + +import ( + "context" + + "github.com/upfluence/cfg/x/cli" +) + +type Printer[T any] interface { + Key() string + CommandDefinition() cli.CommandDefinition + Print(context.Context, cli.CommandContext, T) error +} + +type AnyPrinter = Printer[any] + +func WrapAnyPrinter[T any](ap AnyPrinter) Printer[T] { + return &wrappedAnyPrinter[T]{AnyPrinter: ap} +} + +type wrappedAnyPrinter[T any] struct { + AnyPrinter +} + +func (wap *wrappedAnyPrinter[T]) Print(ctx context.Context, cctx cli.CommandContext, v T) error { + return wap.AnyPrinter.Print(ctx, cctx, v) //nolint:wrapcheck +} diff --git a/x/cli/output/printer/table/csv.go b/x/cli/output/printer/table/csv.go new file mode 100644 index 0000000..3739ddf --- /dev/null +++ b/x/cli/output/printer/table/csv.go @@ -0,0 +1,30 @@ +package table + +import ( + "encoding/csv" + "io" + + "github.com/upfluence/cfg/x/cli/output/printer" +) + +type csvFormatter struct { + w *csv.Writer +} + +func NewCSVFormatter(w io.Writer) Formatter { + return &csvFormatter{w: csv.NewWriter(w)} +} + +func (f *csvFormatter) WriteLine(vals []string) error { + return f.w.Write(vals) //nolint:wrapcheck +} + +func (f *csvFormatter) Flush() error { + f.w.Flush() + + return f.w.Error() //nolint:wrapcheck +} + +func NewDefaultCSVPrinter[T any]() printer.Printer[[]T] { + return NewDefaultPrinter[T]("csv", NewCSVFormatter) +} diff --git a/x/cli/output/printer/table/formatter.go b/x/cli/output/printer/table/formatter.go new file mode 100644 index 0000000..b7c5c38 --- /dev/null +++ b/x/cli/output/printer/table/formatter.go @@ -0,0 +1,10 @@ +package table + +import "io" + +type Formatter interface { + WriteLine([]string) error + Flush() error +} + +type FormatterFunc func(io.Writer) Formatter diff --git a/x/cli/output/printer/table/printer.go b/x/cli/output/printer/table/printer.go new file mode 100644 index 0000000..cf20e31 --- /dev/null +++ b/x/cli/output/printer/table/printer.go @@ -0,0 +1,171 @@ +package table + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/upfluence/errors" + + "github.com/upfluence/cfg/internal/walker" + "github.com/upfluence/cfg/provider" + "github.com/upfluence/cfg/x/cli" + "github.com/upfluence/cfg/x/cli/output/printer" +) + +type columns struct { + available []string + selected []string +} + +func (c columns) String() string { return strings.Join(c.selected, ",") } + +func (c *columns) Parse(s string) error { + c.selected = strings.Split(s, ",") + + return nil +} + +func (c columns) Help() string { + return fmt.Sprintf("Columns to display (available: [%s])", strings.Join(c.available, " ")) +} + +type config struct { + Columns columns `flag:"columns"` +} + +type tablePrinter[T any] struct { + key string + columns []string + extractValue func(T, string) string + formatter FormatterFunc +} + +func NewPrinter[T any](key string, ff FormatterFunc, cols []string, extractValue func(T, string) string) printer.Printer[[]T] { + return &tablePrinter[T]{ + key: key, + columns: cols, + extractValue: extractValue, + formatter: ff, + } +} + +func introspectType[T any](key string) ([]string, func(T, string) string) { + var ( + cols []string + indexByColumn = make(map[string][]int) + + colProvider = provider.WrapFullyQualifiedProvider( + provider.NewStaticProvider(key, nil, nil), + ) + ) + + walker.Walk( //nolint:errcheck + reflect.New(reflect.TypeFor[T]()).Interface(), + func(f *walker.Field) error { + ft := f.Field.Type + + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + + if ft.Kind() == reflect.Struct { + return nil + } + + keys := walker.BuildFieldKeys(colProvider, f, false) + + if len(keys) == 0 { + return nil + } + + col := keys[0] + cols = append(cols, col) + indexByColumn[col] = buildIndex(f) + + return nil + }, + ) + + return cols, func(v T, col string) string { + rv := reflect.ValueOf(v) + idx, ok := indexByColumn[col] + + if !ok { + return "" + } + + return fmt.Sprintf("%v", rv.FieldByIndex(idx).Interface()) + } +} + +func NewDefaultPrinter[T any](key string, ff FormatterFunc) printer.Printer[[]T] { + cols, extractValue := introspectType[T](key) + + return NewPrinter[T](key, ff, cols, extractValue) +} + +func (p *tablePrinter[T]) Key() string { return p.key } + +func (p *tablePrinter[T]) CommandDefinition() cli.CommandDefinition { + return cli.CommandDefinition{ + Configs: []any{ + &config{ + Columns: columns{ + available: p.columns, + selected: p.columns, + }, + }, + }, + } +} + +func buildIndex(f *walker.Field) []int { + var idx []int + + for a := f.Ancestor; a != nil; a = a.Ancestor { + idx = append(idx, a.Field.Index...) + } + + // reverse ancestor indices to get root-first order + for i, j := 0, len(idx)-1; i < j; i, j = i+1, j-1 { + idx[i], idx[j] = idx[j], idx[i] + } + + return append(idx, f.Field.Index...) +} + +func (p *tablePrinter[T]) Print(ctx context.Context, cctx cli.CommandContext, vs []T) error { + var cfg = config{ + Columns: columns{ + available: p.columns, + selected: p.columns, + }, + } + + if err := cctx.Configurator.Populate(ctx, &cfg); err != nil { + return errors.Wrap(err, "populate table config") + } + + cols := cfg.Columns.selected + f := p.formatter(cctx.Stdout) + + if err := f.WriteLine(cols); err != nil { + return err //nolint:wrapcheck + } + + for _, v := range vs { + vals := make([]string, len(cols)) + + for i, col := range cols { + vals[i] = p.extractValue(v, col) + } + + if err := f.WriteLine(vals); err != nil { + return err //nolint:wrapcheck + } + } + + return errors.Wrap(f.Flush(), "flush formatter") +} diff --git a/x/cli/output/printer/table/printer_test.go b/x/cli/output/printer/table/printer_test.go new file mode 100644 index 0000000..fcde91e --- /dev/null +++ b/x/cli/output/printer/table/printer_test.go @@ -0,0 +1,287 @@ +package table + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upfluence/cfg" + "github.com/upfluence/cfg/x/cli" +) + +type testRow struct { + Name string + Age int + City string +} + +func testCommandContext(buf *bytes.Buffer) cli.CommandContext { + return cli.CommandContext{ + Stdout: buf, + Configurator: cfg.NewDefaultConfigurator(), + } +} + +func TestNewPrinter(t *testing.T) { + extFn := func(r testRow, col string) string { + switch col { + case "NAME": + return r.Name + case "AGE": + return fmt.Sprintf("%d", r.Age) + case "CITY": + return r.City + default: + return "" + } + } + + for _, tc := range []struct { + name string + haveKey string + haveFF FormatterFunc + haveRows []testRow + haveCols []string + haveExtFn func(testRow, string) string + want string + }{ + { + name: "table/single row", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveCols: []string{"NAME", "AGE"}, + haveExtFn: extFn, + haveRows: []testRow{{Name: "alice", Age: 30}}, + want: "NAME AGE\nalice 30\n", + }, + { + name: "table/multiple rows", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveCols: []string{"NAME", "CITY"}, + haveExtFn: extFn, + haveRows: []testRow{ + {Name: "alice", City: "paris"}, + {Name: "bob", City: "london"}, + }, + want: "NAME CITY\nalice paris\nbob london\n", + }, + { + name: "table/empty slice", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveCols: []string{"NAME"}, + haveExtFn: func(_ testRow, _ string) string { return "" }, + haveRows: []testRow{}, + want: "NAME\n", + }, + { + name: "csv/single row", + haveKey: "csv", + haveFF: NewCSVFormatter, + haveCols: []string{"NAME", "AGE"}, + haveExtFn: extFn, + haveRows: []testRow{{Name: "alice", Age: 30}}, + want: "NAME,AGE\nalice,30\n", + }, + { + name: "csv/multiple rows", + haveKey: "csv", + haveFF: NewCSVFormatter, + haveCols: []string{"NAME", "CITY"}, + haveExtFn: extFn, + haveRows: []testRow{ + {Name: "alice", City: "paris"}, + {Name: "bob", City: "london"}, + }, + want: "NAME,CITY\nalice,paris\nbob,london\n", + }, + { + name: "csv/empty slice", + haveKey: "csv", + haveFF: NewCSVFormatter, + haveCols: []string{"NAME"}, + haveExtFn: func(_ testRow, _ string) string { return "" }, + haveRows: []testRow{}, + want: "NAME\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + p := NewPrinter[testRow](tc.haveKey, tc.haveFF, tc.haveCols, tc.haveExtFn) + + assert.Equal(t, tc.haveKey, p.Key()) + + var buf bytes.Buffer + + err := p.Print(context.Background(), testCommandContext(&buf), tc.haveRows) + + require.NoError(t, err) + assert.Equal(t, tc.want, buf.String()) + }) + } +} + +func TestNewDefaultPrinter(t *testing.T) { + for _, tc := range []struct { + name string + haveKey string + haveFF FormatterFunc + haveRows []testRow + want string + }{ + { + name: "table/single row", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveRows: []testRow{{Name: "alice", Age: 30, City: "paris"}}, + want: "Name Age City\nalice 30 paris\n", + }, + { + name: "table/multiple rows", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveRows: []testRow{ + {Name: "alice", Age: 30, City: "paris"}, + {Name: "bob", Age: 25, City: "london"}, + }, + want: "Name Age City\nalice 30 paris\nbob 25 london\n", + }, + { + name: "table/empty slice", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveRows: []testRow{}, + want: "Name Age City\n", + }, + { + name: "csv/single row", + haveKey: "csv", + haveFF: NewCSVFormatter, + haveRows: []testRow{{Name: "alice", Age: 30, City: "paris"}}, + want: "Name,Age,City\nalice,30,paris\n", + }, + { + name: "csv/multiple rows", + haveKey: "csv", + haveFF: NewCSVFormatter, + haveRows: []testRow{ + {Name: "alice", Age: 30, City: "paris"}, + {Name: "bob", Age: 25, City: "london"}, + }, + want: "Name,Age,City\nalice,30,paris\nbob,25,london\n", + }, + { + name: "csv/empty slice", + haveKey: "csv", + haveFF: NewCSVFormatter, + haveRows: []testRow{}, + want: "Name,Age,City\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + p := NewDefaultPrinter[testRow](tc.haveKey, tc.haveFF) + + assert.Equal(t, tc.haveKey, p.Key()) + + var buf bytes.Buffer + + err := p.Print(context.Background(), testCommandContext(&buf), tc.haveRows) + + require.NoError(t, err) + assert.Equal(t, tc.want, buf.String()) + }) + } +} + +type nestedInner struct { + Value string +} + +type nestedRow struct { + ID int + Inner nestedInner +} + +func TestNewDefaultPrinterNested(t *testing.T) { + for _, tc := range []struct { + name string + haveKey string + haveFF FormatterFunc + haveRows []nestedRow + want string + }{ + { + name: "table", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveRows: []nestedRow{ + {ID: 1, Inner: nestedInner{Value: "foo"}}, + {ID: 2, Inner: nestedInner{Value: "bar"}}, + }, + want: "ID Inner.Value\n1 foo\n2 bar\n", + }, + { + name: "csv", + haveKey: "csv", + haveFF: NewCSVFormatter, + haveRows: []nestedRow{ + {ID: 1, Inner: nestedInner{Value: "foo"}}, + {ID: 2, Inner: nestedInner{Value: "bar"}}, + }, + want: "ID,Inner.Value\n1,foo\n2,bar\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + p := NewDefaultPrinter[nestedRow](tc.haveKey, tc.haveFF) + + var buf bytes.Buffer + + err := p.Print(context.Background(), testCommandContext(&buf), tc.haveRows) + + require.NoError(t, err) + assert.Equal(t, tc.want, buf.String()) + }) + } +} + +type taggedRow struct { + Name string `table:"name"` + Email string `table:"email"` + Age int `table:"-"` +} + +func TestNewDefaultPrinterWithTableTags(t *testing.T) { + p := NewDefaultPrinter[taggedRow]("table", NewTabwriterFormatter) + + var buf bytes.Buffer + + err := p.Print(context.Background(), testCommandContext(&buf), []taggedRow{ + {Name: "alice", Email: "alice@example.com", Age: 30}, + }) + + require.NoError(t, err) + assert.Equal(t, "name email\nalice alice@example.com\n", buf.String()) +} + +type csvTaggedRow struct { + Name string `csv:"name"` + Email string `csv:"email"` + Age int `csv:"-"` +} + +func TestNewDefaultPrinterWithCSVTags(t *testing.T) { + p := NewDefaultPrinter[csvTaggedRow]("csv", NewCSVFormatter) + + var buf bytes.Buffer + + err := p.Print(context.Background(), testCommandContext(&buf), []csvTaggedRow{ + {Name: "alice", Email: "alice@example.com", Age: 30}, + }) + + require.NoError(t, err) + assert.Equal(t, "name,email\nalice,alice@example.com\n", buf.String()) +} diff --git a/x/cli/output/printer/table/table.go b/x/cli/output/printer/table/table.go new file mode 100644 index 0000000..091f1b7 --- /dev/null +++ b/x/cli/output/printer/table/table.go @@ -0,0 +1,34 @@ +package table + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/upfluence/cfg/x/cli/output/printer" +) + +type tabwriterFormatter struct { + tw *tabwriter.Writer +} + +func NewTabwriterFormatter(w io.Writer) Formatter { + return &tabwriterFormatter{ + tw: tabwriter.NewWriter(w, 0, 0, 2, ' ', 0), + } +} + +func (f *tabwriterFormatter) WriteLine(vals []string) error { + _, err := fmt.Fprintln(f.tw, strings.Join(vals, "\t")) + + return err //nolint:wrapcheck +} + +func (f *tabwriterFormatter) Flush() error { + return f.tw.Flush() //nolint:wrapcheck +} + +func NewDefaultTablePrinter[T any]() printer.Printer[[]T] { + return NewDefaultPrinter[T]("table", NewTabwriterFormatter) +} diff --git a/x/cli/output/printer/yaml/printer.go b/x/cli/output/printer/yaml/printer.go new file mode 100644 index 0000000..e72a609 --- /dev/null +++ b/x/cli/output/printer/yaml/printer.go @@ -0,0 +1,26 @@ +package yaml + +import ( + "context" + + "gopkg.in/yaml.v3" + + "github.com/upfluence/cfg/x/cli" + "github.com/upfluence/cfg/x/cli/output/printer" +) + +const key = "yaml" + +var Printer printer.AnyPrinter = anyPrinter{} + +type anyPrinter struct{} + +func (anyPrinter) Key() string { return key } + +func (anyPrinter) CommandDefinition() cli.CommandDefinition { + return cli.CommandDefinition{} +} + +func (anyPrinter) Print(_ context.Context, cctx cli.CommandContext, v any) error { + return yaml.NewEncoder(cctx.Stdout).Encode(v) //nolint:wrapcheck +} diff --git a/x/cli/output/static_command.go b/x/cli/output/static_command.go new file mode 100644 index 0000000..b71d06d --- /dev/null +++ b/x/cli/output/static_command.go @@ -0,0 +1,98 @@ +package output + +import ( + "context" + "io" + + "github.com/upfluence/errors" + + "github.com/upfluence/cfg/x/cli" +) + +type StaticCommand[T any] struct { + Help cli.IntrospectionFunc + Synopsis cli.IntrospectionFunc + + Execute func(context.Context, cli.CommandContext) (T, error) +} + +func (sc StaticCommand[T]) WriteHelp(w io.Writer, opts cli.IntrospectionOptions) (int, error) { + if sc.Help == nil { + return 0, nil + } + + return sc.Help(w, opts) +} + +func (sc StaticCommand[T]) WriteSynopsis(w io.Writer, opts cli.IntrospectionOptions) (int, error) { + if sc.Synopsis == nil { + return 0, nil + } + + return sc.Synopsis(w, opts) +} + +func (sc StaticCommand[T]) Run(ctx context.Context, cctx cli.CommandContext) (T, error) { + return sc.Execute(ctx, cctx) +} + +type DefaultStaticCommandOption[C any, T any] func(*defaultStaticCommandOptions[C, T]) + +func WithShortHelp[C any, T any](h string) DefaultStaticCommandOption[C, T] { + return func(opts *defaultStaticCommandOptions[C, T]) { + opts.shortHelp = h + } +} + +func WithLongHelp[C any, T any](h string) DefaultStaticCommandOption[C, T] { + return func(opts *defaultStaticCommandOptions[C, T]) { + opts.longHelp = h + } +} + +func WithDefaultConfig[C any, T any](c C) DefaultStaticCommandOption[C, T] { + return func(opts *defaultStaticCommandOptions[C, T]) { + opts.defaultConfig = c + } +} + +type defaultStaticCommandOptions[C any, T any] struct { + defaultConfig C + + shortHelp string + longHelp string +} + +func (dsco *defaultStaticCommandOptions[C, T]) help() cli.EnhancedHelp { + return cli.EnhancedHelp{ + Short: dsco.shortHelp, + Long: dsco.longHelp, + Config: &dsco.defaultConfig, + } +} + +func DefaultStaticCommand[C any, T any](fn func(context.Context, cli.CommandContext, C) (T, error), opts ...DefaultStaticCommandOption[C, T]) StaticCommand[T] { + var o defaultStaticCommandOptions[C, T] + + for _, opt := range opts { + opt(&o) + } + + h := o.help() + + return StaticCommand[T]{ + Help: h.WriteHelp, + Synopsis: h.WriteSynopsis, + Execute: func(ctx context.Context, cctx cli.CommandContext) (T, error) { + var zero T + + config := o.defaultConfig + + if err := cctx.Configurator.Populate(ctx, &config); err != nil { + return zero, errors.Wrap(err, "populate config") + } + + return fn(ctx, cctx, config) + }, + } +}