From 5fd18bb39ab5dfbd8dfc1573c10aed1c0c7a84a4 Mon Sep 17 00:00:00 2001 From: Alexis Montagne Date: Sat, 18 Apr 2026 19:41:23 -0700 Subject: [PATCH 1/7] x/cli/output: Implement a first version of the output cli overlay --- x/cli/app.go | 12 +- x/cli/command_context.go | 12 +- x/cli/command_context_test.go | 2 +- x/cli/option.go | 24 ++++ x/cli/output/command.go | 137 ++++++++++++++++++ x/cli/output/command_test.go | 156 +++++++++++++++++++++ x/cli/output/printer/json/printer.go | 45 ++++++ x/cli/output/printer/printer.go | 27 ++++ x/cli/output/printer/table/printer.go | 129 +++++++++++++++++ x/cli/output/printer/table/printer_test.go | 152 ++++++++++++++++++++ x/cli/output/printer/yaml/printer.go | 26 ++++ x/cli/output/static_command.go | 98 +++++++++++++ 12 files changed, 811 insertions(+), 9 deletions(-) create mode 100644 x/cli/output/command.go create mode 100644 x/cli/output/command_test.go create mode 100644 x/cli/output/printer/json/printer.go create mode 100644 x/cli/output/printer/printer.go create mode 100644 x/cli/output/printer/table/printer.go create mode 100644 x/cli/output/printer/table/printer_test.go create mode 100644 x/cli/output/printer/yaml/printer.go create mode 100644 x/cli/output/static_command.go 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..67043fb --- /dev/null +++ b/x/cli/output/command.go @@ -0,0 +1,137 @@ +package output + +import ( + "context" + "fmt" + "io" + "reflect" + "slices" + "sort" + "strings" + + "github.com/upfluence/errors" + + "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) +} + +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 + } + + return &wrappedCommand[T]{ + cmd: cmd, + defaultOutputFormat: defaultPrinter.Key(), + printers: printers, + outputConfigType: buildOutputConfigType(printers), + } +} + +func buildOutputConfigType[T any](printers map[string]printer.Printer[T]) reflect.Type { + keys := make([]string, 0, len(printers)) + + for k := range printers { + keys = append(keys, k) + } + + sort.Strings(keys) + + helpTag := fmt.Sprintf( + "Output format (formats: [%s])", + strings.Join(keys, " "), + ) + + return reflect.StructOf([]reflect.StructField{ + { + Name: "OutputFormat", + Type: reflect.TypeFor[string](), + Tag: reflect.StructTag(fmt.Sprintf(`flag:"o,output" help:%q`, helpTag)), + }, + }) +} + +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 outputConfig struct { + OutputFormat string `flag:"o,output" help:"Output format"` +} + +type wrappedCommand[T any] struct { + cmd Command[T] + + defaultOutputFormat string + printers map[string]printer.Printer[T] + outputConfigType reflect.Type +} + +func (wc *wrappedCommand[T]) wrapIntrospectionOptions(opts cli.IntrospectionOptions) cli.IntrospectionOptions { + oc := reflect.New(wc.outputConfigType) + oc.Elem().Field(0).SetString(wc.defaultOutputFormat) + + opts.Definitions = append( + slices.Clone(opts.Definitions), + cli.CommandDefinition{ + Configs: []any{oc.Interface()}, + }, + ) + + for _, p := range wc.printers { + opts.Definitions = append(opts.Definitions, p.CommandDefinition()) + } + + 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: wc.defaultOutputFormat} + + if err := cctx.Configurator.Populate(ctx, &oc); err != nil { + return errors.Wrap(err, "populate output config") + } + + printer, ok := wc.printers[oc.OutputFormat] + + if !ok { + return fmt.Errorf("unknown output format: %q", oc.OutputFormat) + } + + v, err := wc.cmd.Run(ctx, cctx) + + if err != nil { + return err //nolint:wrapcheck + } + + return printer.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..bf902a5 --- /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] [--json-indent] [--foo, -f] [--bar, -b] +Arguments: +- OutputFormat: string Output format (formats: [json yaml]) (default: yaml) (env: OUTPUTFORMAT, flag: -o, --output) +- Indent: bool Indent JSON output (env: INDENT, flag: --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] [--json-indent] [--foo, -f] [--bar, -b] +Arguments: +- OutputFormat: string Output format (formats: [json]) (default: json) (env: OUTPUTFORMAT, flag: -o, --output) +- Indent: bool Indent JSON output (env: INDENT, flag: --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", "--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..e3e2cfe --- /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:"json-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/printer.go b/x/cli/output/printer/table/printer.go new file mode 100644 index 0000000..6b0db8e --- /dev/null +++ b/x/cli/output/printer/table/printer.go @@ -0,0 +1,129 @@ +package table + +import ( + "context" + "fmt" + "reflect" + "strings" + "text/tabwriter" + + "github.com/upfluence/cfg/internal/walker" + "github.com/upfluence/cfg/x/cli" + "github.com/upfluence/cfg/x/cli/output/printer" +) + +const key = "table" + +type tablePrinter[T any] struct { + columns []string + extractValue func(T, string) string +} + +func NewPrinter[T any](columns []string, extractValue func(T, string) string) printer.Printer[[]T] { + return &tablePrinter[T]{ + columns: columns, + extractValue: extractValue, + } +} + +func NewDefaultPrinter[T any]() printer.Printer[[]T] { + var ( + columns []string + indexByColumn = make(map[string][]int) + ) + + 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 + } + + col := buildColumnName(f) + columns = append(columns, col) + indexByColumn[col] = buildIndex(f) + + return nil + }, + ) + + return &tablePrinter[T]{ + columns: columns, + extractValue: 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 (p *tablePrinter[T]) Key() string { return key } + +func (p *tablePrinter[T]) CommandDefinition() cli.CommandDefinition { + return cli.CommandDefinition{} +} + +func buildColumnName(f *walker.Field) string { + var parts []string + + for a := f.Ancestor; a != nil; a = a.Ancestor { + parts = append(parts, strings.ToUpper(a.Field.Name)) + } + + // reverse to get root-first order + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + + parts = append(parts, strings.ToUpper(f.Field.Name)) + + return strings.Join(parts, ".") +} + +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(_ context.Context, cctx cli.CommandContext, vs []T) error { + tw := tabwriter.NewWriter(cctx.Stdout, 0, 0, 2, ' ', 0) + + if _, err := fmt.Fprintln(tw, strings.Join(p.columns, "\t")); err != nil { + return err //nolint:wrapcheck + } + + for _, v := range vs { + vals := make([]string, len(p.columns)) + + for i, col := range p.columns { + vals[i] = p.extractValue(v, col) + } + + if _, err := fmt.Fprintln(tw, strings.Join(vals, "\t")); err != nil { + return err //nolint:wrapcheck + } + } + + return tw.Flush() //nolint:wrapcheck +} 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..bd9b3b7 --- /dev/null +++ b/x/cli/output/printer/table/printer_test.go @@ -0,0 +1,152 @@ +package table + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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} +} + +func TestNewPrinter(t *testing.T) { + for _, tc := range []struct { + name string + haveRows []testRow + haveCols []string + haveExtFn func(testRow, string) string + want string + }{ + { + name: "single row", + haveCols: []string{"NAME", "AGE"}, + haveExtFn: func(r testRow, col string) string { + switch col { + case "NAME": + return r.Name + case "AGE": + return fmt.Sprintf("%d", r.Age) + default: + return "" + } + }, + haveRows: []testRow{{Name: "alice", Age: 30}}, + want: "NAME AGE\nalice 30\n", + }, + { + name: "multiple rows", + haveCols: []string{"NAME", "CITY"}, + haveExtFn: func(r testRow, col string) string { + switch col { + case "NAME": + return r.Name + case "CITY": + return r.City + default: + return "" + } + }, + haveRows: []testRow{ + {Name: "alice", City: "paris"}, + {Name: "bob", City: "london"}, + }, + want: "NAME CITY\nalice paris\nbob london\n", + }, + { + name: "empty slice", + 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.haveCols, tc.haveExtFn) + + assert.Equal(t, "table", 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 + haveRows []testRow + want string + }{ + { + name: "single row", + haveRows: []testRow{{Name: "alice", Age: 30, City: "paris"}}, + want: "NAME AGE CITY\nalice 30 paris\n", + }, + { + name: "multiple rows", + 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: "empty slice", + haveRows: []testRow{}, + want: "NAME AGE CITY\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + p := NewDefaultPrinter[testRow]() + + assert.Equal(t, "table", 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) { + p := NewDefaultPrinter[nestedRow]() + + var buf bytes.Buffer + + err := p.Print(context.Background(), testCommandContext(&buf), []nestedRow{ + {ID: 1, Inner: nestedInner{Value: "foo"}}, + {ID: 2, Inner: nestedInner{Value: "bar"}}, + }) + + require.NoError(t, err) + assert.Equal(t, "ID INNER.VALUE\n1 foo\n2 bar\n", buf.String()) +} 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) + }, + } +} From 3d16686e00c95eb4ae766b056cc4113c9d19d902 Mon Sep 17 00:00:00 2001 From: Alexis Montagne Date: Sat, 18 Apr 2026 20:19:06 -0700 Subject: [PATCH 2/7] internal/walker: Accept PrefixedWalk interface and use it to prefix cfg in x/cli/output --- configurator_test.go | 127 +++++++++++++++++++++++++++ internal/walker/walker.go | 48 +++++++++- internal/walker/walker_test.go | 66 ++++++++++++++ x/cli/output/command.go | 39 +++++++- x/cli/output/command_test.go | 10 +-- x/cli/output/printer/json/printer.go | 2 +- 6 files changed, 282 insertions(+), 10 deletions(-) diff --git a/configurator_test.go b/configurator_test.go index 588ef3f..e3af946 100644 --- a/configurator_test.go +++ b/configurator_test.go @@ -742,6 +742,133 @@ 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/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/output/command.go b/x/cli/output/command.go index 67043fb..89fbb79 100644 --- a/x/cli/output/command.go +++ b/x/cli/output/command.go @@ -11,6 +11,7 @@ import ( "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" @@ -80,6 +81,30 @@ type outputConfig struct { OutputFormat string `flag:"o,output" help:"Output format"` } +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] @@ -100,7 +125,14 @@ func (wc *wrappedCommand[T]) wrapIntrospectionOptions(opts cli.IntrospectionOpti ) for _, p := range wc.printers { - opts.Definitions = append(opts.Definitions, p.CommandDefinition()) + 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 @@ -133,5 +165,10 @@ func (wc *wrappedCommand[T]) Run(ctx context.Context, cctx cli.CommandContext) e return err //nolint:wrapcheck } + cctx.Configurator = &prefixedConfigurator{ + inner: cctx.Configurator, + prefix: oc.OutputFormat, + } + return printer.Print(ctx, cctx, v) //nolint:wrapcheck } diff --git a/x/cli/output/command_test.go b/x/cli/output/command_test.go index bf902a5..2e2020f 100644 --- a/x/cli/output/command_test.go +++ b/x/cli/output/command_test.go @@ -60,10 +60,10 @@ func TestRun(t *testing.T) { wantErr: `Description: Usage: -test-app [-o, --output] [--json-indent] [--foo, -f] [--bar, -b] +test-app [-o, --output] [--output.json.indent] [--foo, -f] [--bar, -b] Arguments: - OutputFormat: string Output format (formats: [json yaml]) (default: yaml) (env: OUTPUTFORMAT, flag: -o, --output) -- Indent: bool Indent JSON output (env: INDENT, flag: --json-indent) +- 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) `, }, @@ -81,10 +81,10 @@ Arguments: wantErr: `Description: Usage: -test-app [-o, --output] [--json-indent] [--foo, -f] [--bar, -b] +test-app [-o, --output] [--output.json.indent] [--foo, -f] [--bar, -b] Arguments: - OutputFormat: string Output format (formats: [json]) (default: json) (env: OUTPUTFORMAT, flag: -o, --output) -- Indent: bool Indent JSON output (env: INDENT, flag: --json-indent) +- 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) `, }, @@ -102,7 +102,7 @@ Arguments: }, { name: "json format with indent", - haveArgs: []string{"--foo", "bar", "-o", "json", "--json-indent"}, + haveArgs: []string{"--foo", "bar", "-o", "json", "--output.json.indent"}, haveCmd: output.WrapDefaultCommand[testResult](defaultStaticCommand()), wantOut: "{\n \"message\": \"ok\",\n \"foo\": \"bar\"\n}\n", }, diff --git a/x/cli/output/printer/json/printer.go b/x/cli/output/printer/json/printer.go index e3e2cfe..0ad03ef 100644 --- a/x/cli/output/printer/json/printer.go +++ b/x/cli/output/printer/json/printer.go @@ -15,7 +15,7 @@ const key = "json" var Printer printer.AnyPrinter = anyPrinter{} type config struct { - Indent bool `flag:"json-indent" help:"Indent JSON output"` + Indent bool `flag:"indent" help:"Indent JSON output"` } type anyPrinter struct{} From f1c9071e76358dd1775046b49993b856126e0a8c Mon Sep 17 00:00:00 2001 From: Alexis Montagne Date: Sat, 18 Apr 2026 20:53:10 -0700 Subject: [PATCH 3/7] internal/help: Leverage helper implementation --- internal/help/writer.go | 33 ++++++++++++++++++++++++++++++--- internal/help/writer_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/internal/help/writer.go b/internal/help/writer.go index decd312..1725799 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.TypeOf((*helper)(nil)).Elem() ) +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..6a3e80b 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 `flag:"dyn" env:"-"` +} + +type helperOverridesTagConfig struct { + Dynamic helpString `flag:"dyn" env:"-" help:"from tag"` +} + +type helperEmptyFallsBackConfig struct { + Dynamic helpString `flag:"dyn" env:"-" 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 From ffc0d0648dd8101c037e713d009b3c8483e4b913 Mon Sep 17 00:00:00 2001 From: Alexis Montagne Date: Sat, 18 Apr 2026 20:53:51 -0700 Subject: [PATCH 4/7] x/cli/output: Use the help update to refactor the config --- x/cli/output/command.go | 80 +++++++++++++++++++----------------- x/cli/output/command_test.go | 4 +- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/x/cli/output/command.go b/x/cli/output/command.go index 89fbb79..00d9e36 100644 --- a/x/cli/output/command.go +++ b/x/cli/output/command.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "reflect" "slices" "sort" "strings" @@ -25,6 +24,27 @@ type Command[T any] interface { 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 @@ -33,15 +53,6 @@ func WrapCommand[T any](cmd Command[T], defaultPrinter printer.Printer[T], addit printers[p.Key()] = p } - return &wrappedCommand[T]{ - cmd: cmd, - defaultOutputFormat: defaultPrinter.Key(), - printers: printers, - outputConfigType: buildOutputConfigType(printers), - } -} - -func buildOutputConfigType[T any](printers map[string]printer.Printer[T]) reflect.Type { keys := make([]string, 0, len(printers)) for k := range printers { @@ -50,18 +61,16 @@ func buildOutputConfigType[T any](printers map[string]printer.Printer[T]) reflec sort.Strings(keys) - helpTag := fmt.Sprintf( - "Output format (formats: [%s])", - strings.Join(keys, " "), - ) - - return reflect.StructOf([]reflect.StructField{ - { - Name: "OutputFormat", - Type: reflect.TypeFor[string](), - Tag: reflect.StructTag(fmt.Sprintf(`flag:"o,output" help:%q`, helpTag)), + 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 { @@ -77,10 +86,6 @@ func WrapDefaultCommand[T any](cmd Command[T], additionalPrinters ...printer.Pri ) } -type outputConfig struct { - OutputFormat string `flag:"o,output" help:"Output format"` -} - type prefixedConfig struct { prefix string value any @@ -108,19 +113,15 @@ func (pc *prefixedConfigurator) WithOptions(opts ...cfg.Option) cfg.Configurator type wrappedCommand[T any] struct { cmd Command[T] - defaultOutputFormat string - printers map[string]printer.Printer[T] - outputConfigType reflect.Type + outputConfig outputConfig + printers map[string]printer.Printer[T] } func (wc *wrappedCommand[T]) wrapIntrospectionOptions(opts cli.IntrospectionOptions) cli.IntrospectionOptions { - oc := reflect.New(wc.outputConfigType) - oc.Elem().Field(0).SetString(wc.defaultOutputFormat) - opts.Definitions = append( slices.Clone(opts.Definitions), cli.CommandDefinition{ - Configs: []any{oc.Interface()}, + Configs: []any{&wc.outputConfig}, }, ) @@ -147,16 +148,21 @@ func (wc *wrappedCommand[T]) WriteHelp(w io.Writer, opts cli.IntrospectionOption } func (wc *wrappedCommand[T]) Run(ctx context.Context, cctx cli.CommandContext) error { - var oc = outputConfig{OutputFormat: wc.defaultOutputFormat} + 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") } - printer, ok := wc.printers[oc.OutputFormat] + p, ok := wc.printers[oc.OutputFormat.selected] if !ok { - return fmt.Errorf("unknown output format: %q", oc.OutputFormat) + return fmt.Errorf("unknown output format: %q", oc.OutputFormat.selected) } v, err := wc.cmd.Run(ctx, cctx) @@ -167,8 +173,8 @@ func (wc *wrappedCommand[T]) Run(ctx context.Context, cctx cli.CommandContext) e cctx.Configurator = &prefixedConfigurator{ inner: cctx.Configurator, - prefix: oc.OutputFormat, + prefix: oc.OutputFormat.selected, } - return printer.Print(ctx, cctx, v) //nolint:wrapcheck + return p.Print(ctx, cctx, v) //nolint:wrapcheck } diff --git a/x/cli/output/command_test.go b/x/cli/output/command_test.go index 2e2020f..723ac78 100644 --- a/x/cli/output/command_test.go +++ b/x/cli/output/command_test.go @@ -62,7 +62,7 @@ func TestRun(t *testing.T) { Usage: test-app [-o, --output] [--output.json.indent] [--foo, -f] [--bar, -b] Arguments: -- OutputFormat: string Output format (formats: [json yaml]) (default: yaml) (env: OUTPUTFORMAT, flag: -o, --output) +- 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) `, @@ -83,7 +83,7 @@ Arguments: Usage: test-app [-o, --output] [--output.json.indent] [--foo, -f] [--bar, -b] Arguments: -- OutputFormat: string Output format (formats: [json]) (default: json) (env: OUTPUTFORMAT, flag: -o, --output) +- 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) `, From 2581ac16090a6099069446dd6998212e4a9df876 Mon Sep 17 00:00:00 2001 From: Alexis Montagne Date: Sat, 18 Apr 2026 20:54:18 -0700 Subject: [PATCH 5/7] x/cli/output/table: Accept Columns arguments --- x/cli/output/printer/table/printer.go | 91 ++++++++++++++++------ x/cli/output/printer/table/printer_test.go | 33 ++++++-- 2 files changed, 97 insertions(+), 27 deletions(-) diff --git a/x/cli/output/printer/table/printer.go b/x/cli/output/printer/table/printer.go index 6b0db8e..8567df3 100644 --- a/x/cli/output/printer/table/printer.go +++ b/x/cli/output/printer/table/printer.go @@ -7,28 +7,52 @@ import ( "strings" "text/tabwriter" + "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" ) const key = "table" +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 { columns []string extractValue func(T, string) string } -func NewPrinter[T any](columns []string, extractValue func(T, string) string) printer.Printer[[]T] { +func NewPrinter[T any](cols []string, extractValue func(T, string) string) printer.Printer[[]T] { return &tablePrinter[T]{ - columns: columns, + columns: cols, extractValue: extractValue, } } func NewDefaultPrinter[T any]() printer.Printer[[]T] { var ( - columns []string + cols []string indexByColumn = make(map[string][]int) ) @@ -45,8 +69,13 @@ func NewDefaultPrinter[T any]() printer.Printer[[]T] { return nil } - col := buildColumnName(f) - columns = append(columns, col) + col, ok := buildColumnName(f) + + if !ok { + return nil + } + + cols = append(cols, col) indexByColumn[col] = buildIndex(f) return nil @@ -54,7 +83,7 @@ func NewDefaultPrinter[T any]() printer.Printer[[]T] { ) return &tablePrinter[T]{ - columns: columns, + columns: cols, extractValue: func(v T, col string) string { rv := reflect.ValueOf(v) idx, ok := indexByColumn[col] @@ -71,24 +100,30 @@ func NewDefaultPrinter[T any]() printer.Printer[[]T] { func (p *tablePrinter[T]) Key() string { return key } func (p *tablePrinter[T]) CommandDefinition() cli.CommandDefinition { - return cli.CommandDefinition{} + return cli.CommandDefinition{ + Configs: []any{ + &config{ + Columns: columns{ + available: p.columns, + selected: p.columns, + }, + }, + }, + } } -func buildColumnName(f *walker.Field) string { - var parts []string +var columnProvider = provider.WrapFullyQualifiedProvider( + provider.NewStaticProvider("table", nil, nil), +) - for a := f.Ancestor; a != nil; a = a.Ancestor { - parts = append(parts, strings.ToUpper(a.Field.Name)) - } +func buildColumnName(f *walker.Field) (string, bool) { + keys := walker.BuildFieldKeys(columnProvider, f, false) - // reverse to get root-first order - for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { - parts[i], parts[j] = parts[j], parts[i] + if len(keys) == 0 { + return "", false } - parts = append(parts, strings.ToUpper(f.Field.Name)) - - return strings.Join(parts, ".") + return keys[0], true } func buildIndex(f *walker.Field) []int { @@ -106,17 +141,29 @@ func buildIndex(f *walker.Field) []int { return append(idx, f.Field.Index...) } -func (p *tablePrinter[T]) Print(_ context.Context, cctx cli.CommandContext, vs []T) error { +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 tw := tabwriter.NewWriter(cctx.Stdout, 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintln(tw, strings.Join(p.columns, "\t")); err != nil { + if _, err := fmt.Fprintln(tw, strings.Join(cols, "\t")); err != nil { return err //nolint:wrapcheck } for _, v := range vs { - vals := make([]string, len(p.columns)) + vals := make([]string, len(cols)) - for i, col := range p.columns { + for i, col := range cols { vals[i] = p.extractValue(v, col) } diff --git a/x/cli/output/printer/table/printer_test.go b/x/cli/output/printer/table/printer_test.go index bd9b3b7..e5d2476 100644 --- a/x/cli/output/printer/table/printer_test.go +++ b/x/cli/output/printer/table/printer_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/upfluence/cfg" "github.com/upfluence/cfg/x/cli" ) @@ -19,7 +20,10 @@ type testRow struct { } func testCommandContext(buf *bytes.Buffer) cli.CommandContext { - return cli.CommandContext{Stdout: buf} + return cli.CommandContext{ + Stdout: buf, + Configurator: cfg.NewDefaultConfigurator(), + } } func TestNewPrinter(t *testing.T) { @@ -97,7 +101,7 @@ func TestNewDefaultPrinter(t *testing.T) { { name: "single row", haveRows: []testRow{{Name: "alice", Age: 30, City: "paris"}}, - want: "NAME AGE CITY\nalice 30 paris\n", + want: "Name Age City\nalice 30 paris\n", }, { name: "multiple rows", @@ -105,12 +109,12 @@ func TestNewDefaultPrinter(t *testing.T) { {Name: "alice", Age: 30, City: "paris"}, {Name: "bob", Age: 25, City: "london"}, }, - want: "NAME AGE CITY\nalice 30 paris\nbob 25 london\n", + want: "Name Age City\nalice 30 paris\nbob 25 london\n", }, { name: "empty slice", haveRows: []testRow{}, - want: "NAME AGE CITY\n", + want: "Name Age City\n", }, } { t.Run(tc.name, func(t *testing.T) { @@ -148,5 +152,24 @@ func TestNewDefaultPrinterNested(t *testing.T) { }) require.NoError(t, err) - assert.Equal(t, "ID INNER.VALUE\n1 foo\n2 bar\n", buf.String()) + assert.Equal(t, "ID Inner.Value\n1 foo\n2 bar\n", buf.String()) +} + +type taggedRow struct { + Name string `table:"name"` + Email string `table:"email"` + Age int `table:"-"` +} + +func TestNewDefaultPrinterWithTableTags(t *testing.T) { + p := NewDefaultPrinter[taggedRow]() + + 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()) } From f69c22d365006e105e6445b3efed4a1b1375f323 Mon Sep 17 00:00:00 2001 From: Alexis Montagne Date: Sun, 19 Apr 2026 10:29:05 -0700 Subject: [PATCH 6/7] x/cli/output/table: Add csv writer --- x/cli/output/printer/table/csv.go | 30 ++++ x/cli/output/printer/table/formatter.go | 10 ++ x/cli/output/printer/table/printer.go | 67 ++++--- x/cli/output/printer/table/printer_test.go | 198 ++++++++++++++++----- x/cli/output/printer/table/table.go | 34 ++++ 5 files changed, 260 insertions(+), 79 deletions(-) create mode 100644 x/cli/output/printer/table/csv.go create mode 100644 x/cli/output/printer/table/formatter.go create mode 100644 x/cli/output/printer/table/table.go 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 index 8567df3..6eff158 100644 --- a/x/cli/output/printer/table/printer.go +++ b/x/cli/output/printer/table/printer.go @@ -5,7 +5,6 @@ import ( "fmt" "reflect" "strings" - "text/tabwriter" "github.com/upfluence/errors" @@ -15,8 +14,6 @@ import ( "github.com/upfluence/cfg/x/cli/output/printer" ) -const key = "table" - type columns struct { available []string selected []string @@ -39,21 +36,29 @@ type config struct { } type tablePrinter[T any] struct { + key string columns []string extractValue func(T, string) string + formatter FormatterFunc } -func NewPrinter[T any](cols []string, extractValue func(T, string) string) printer.Printer[[]T] { +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 NewDefaultPrinter[T any]() printer.Printer[[]T] { +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 @@ -69,12 +74,13 @@ func NewDefaultPrinter[T any]() printer.Printer[[]T] { return nil } - col, ok := buildColumnName(f) + keys := walker.BuildFieldKeys(colProvider, f, false) - if !ok { + if len(keys) == 0 { return nil } + col := keys[0] cols = append(cols, col) indexByColumn[col] = buildIndex(f) @@ -82,22 +88,25 @@ func NewDefaultPrinter[T any]() printer.Printer[[]T] { }, ) - return &tablePrinter[T]{ - columns: cols, - extractValue: func(v T, col string) string { - rv := reflect.ValueOf(v) - idx, ok := indexByColumn[col] + return cols, func(v T, col string) string { + rv := reflect.ValueOf(v) + idx, ok := indexByColumn[col] - if !ok { - return "" - } + if !ok { + return "" + } - return fmt.Sprintf("%v", rv.FieldByIndex(idx).Interface()) - }, + return fmt.Sprintf("%v", rv.FieldByIndex(idx).Interface()) } } -func (p *tablePrinter[T]) Key() string { return key } +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{ @@ -112,20 +121,6 @@ func (p *tablePrinter[T]) CommandDefinition() cli.CommandDefinition { } } -var columnProvider = provider.WrapFullyQualifiedProvider( - provider.NewStaticProvider("table", nil, nil), -) - -func buildColumnName(f *walker.Field) (string, bool) { - keys := walker.BuildFieldKeys(columnProvider, f, false) - - if len(keys) == 0 { - return "", false - } - - return keys[0], true -} - func buildIndex(f *walker.Field) []int { var idx []int @@ -154,9 +149,9 @@ func (p *tablePrinter[T]) Print(ctx context.Context, cctx cli.CommandContext, vs } cols := cfg.Columns.selected - tw := tabwriter.NewWriter(cctx.Stdout, 0, 0, 2, ' ', 0) + f := p.formatter(cctx.Stdout) - if _, err := fmt.Fprintln(tw, strings.Join(cols, "\t")); err != nil { + if err := f.WriteLine(cols); err != nil { return err //nolint:wrapcheck } @@ -167,10 +162,10 @@ func (p *tablePrinter[T]) Print(ctx context.Context, cctx cli.CommandContext, vs vals[i] = p.extractValue(v, col) } - if _, err := fmt.Fprintln(tw, strings.Join(vals, "\t")); err != nil { + if err := f.WriteLine(vals); err != nil { return err //nolint:wrapcheck } } - return tw.Flush() //nolint:wrapcheck + return f.Flush() } diff --git a/x/cli/output/printer/table/printer_test.go b/x/cli/output/printer/table/printer_test.go index e5d2476..fcde91e 100644 --- a/x/cli/output/printer/table/printer_test.go +++ b/x/cli/output/printer/table/printer_test.go @@ -27,42 +27,43 @@ func testCommandContext(buf *bytes.Buffer) cli.CommandContext { } 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: "single row", - haveCols: []string{"NAME", "AGE"}, - haveExtFn: func(r testRow, col string) string { - switch col { - case "NAME": - return r.Name - case "AGE": - return fmt.Sprintf("%d", r.Age) - default: - return "" - } - }, - haveRows: []testRow{{Name: "alice", Age: 30}}, - want: "NAME AGE\nalice 30\n", + 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: "multiple rows", - haveCols: []string{"NAME", "CITY"}, - haveExtFn: func(r testRow, col string) string { - switch col { - case "NAME": - return r.Name - case "CITY": - return r.City - default: - return "" - } - }, + name: "table/multiple rows", + haveKey: "table", + haveFF: NewTabwriterFormatter, + haveCols: []string{"NAME", "CITY"}, + haveExtFn: extFn, haveRows: []testRow{ {Name: "alice", City: "paris"}, {Name: "bob", City: "london"}, @@ -70,7 +71,39 @@ func TestNewPrinter(t *testing.T) { want: "NAME CITY\nalice paris\nbob london\n", }, { - name: "empty slice", + 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{}, @@ -78,9 +111,9 @@ func TestNewPrinter(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - p := NewPrinter[testRow](tc.haveCols, tc.haveExtFn) + p := NewPrinter[testRow](tc.haveKey, tc.haveFF, tc.haveCols, tc.haveExtFn) - assert.Equal(t, "table", p.Key()) + assert.Equal(t, tc.haveKey, p.Key()) var buf bytes.Buffer @@ -95,16 +128,22 @@ func TestNewPrinter(t *testing.T) { func TestNewDefaultPrinter(t *testing.T) { for _, tc := range []struct { name string + haveKey string + haveFF FormatterFunc haveRows []testRow want string }{ { - name: "single row", + 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: "multiple rows", + name: "table/multiple rows", + haveKey: "table", + haveFF: NewTabwriterFormatter, haveRows: []testRow{ {Name: "alice", Age: 30, City: "paris"}, {Name: "bob", Age: 25, City: "london"}, @@ -112,15 +151,41 @@ func TestNewDefaultPrinter(t *testing.T) { want: "Name Age City\nalice 30 paris\nbob 25 london\n", }, { - name: "empty slice", + 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]() + p := NewDefaultPrinter[testRow](tc.haveKey, tc.haveFF) - assert.Equal(t, "table", p.Key()) + assert.Equal(t, tc.haveKey, p.Key()) var buf bytes.Buffer @@ -142,17 +207,45 @@ type nestedRow struct { } func TestNewDefaultPrinterNested(t *testing.T) { - p := NewDefaultPrinter[nestedRow]() + 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 + var buf bytes.Buffer - err := p.Print(context.Background(), testCommandContext(&buf), []nestedRow{ - {ID: 1, Inner: nestedInner{Value: "foo"}}, - {ID: 2, Inner: nestedInner{Value: "bar"}}, - }) + err := p.Print(context.Background(), testCommandContext(&buf), tc.haveRows) - require.NoError(t, err) - assert.Equal(t, "ID Inner.Value\n1 foo\n2 bar\n", buf.String()) + require.NoError(t, err) + assert.Equal(t, tc.want, buf.String()) + }) + } } type taggedRow struct { @@ -162,7 +255,7 @@ type taggedRow struct { } func TestNewDefaultPrinterWithTableTags(t *testing.T) { - p := NewDefaultPrinter[taggedRow]() + p := NewDefaultPrinter[taggedRow]("table", NewTabwriterFormatter) var buf bytes.Buffer @@ -173,3 +266,22 @@ func TestNewDefaultPrinterWithTableTags(t *testing.T) { 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) +} From 60446cbda4dc002d5ebcda4674f240a8e9e9f747 Mon Sep 17 00:00:00 2001 From: Alexis Montagne Date: Tue, 28 Apr 2026 11:35:13 -0700 Subject: [PATCH 7/7] *: lint update --- configurator_test.go | 2 ++ internal/help/writer.go | 2 +- internal/help/writer_test.go | 6 +++--- internal/setter/setter.go | 6 +++--- x/cli/output/printer/table/printer.go | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/configurator_test.go b/configurator_test.go index e3af946..df743de 100644 --- a/configurator_test.go +++ b/configurator_test.go @@ -799,7 +799,9 @@ func TestPrefixedPopulate(t *testing.T) { want: func() *nestedStruct { var v int64 = 42 ns := &nestedStruct{} + ns.Nested.Inner = &v + return ns }(), }, diff --git a/internal/help/writer.go b/internal/help/writer.go index 1725799..1c07215 100644 --- a/internal/help/writer.go +++ b/internal/help/writer.go @@ -28,7 +28,7 @@ var ( }, } - helperType = reflect.TypeOf((*helper)(nil)).Elem() + helperType = reflect.TypeFor[helper]() ) type helper interface { diff --git a/internal/help/writer_test.go b/internal/help/writer_test.go index 6a3e80b..c2f95a9 100644 --- a/internal/help/writer_test.go +++ b/internal/help/writer_test.go @@ -45,15 +45,15 @@ type helpString string func (h helpString) Help() string { return string(h) } type helperFieldConfig struct { - Dynamic helpString `flag:"dyn" env:"-"` + Dynamic helpString `env:"-" flag:"dyn"` } type helperOverridesTagConfig struct { - Dynamic helpString `flag:"dyn" env:"-" help:"from tag"` + Dynamic helpString `env:"-" flag:"dyn" help:"from tag"` } type helperEmptyFallsBackConfig struct { - Dynamic helpString `flag:"dyn" env:"-" help:"from tag"` + Dynamic helpString `env:"-" flag:"dyn" help:"from tag"` } func TestPrintDefaults(t *testing.T) { 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/x/cli/output/printer/table/printer.go b/x/cli/output/printer/table/printer.go index 6eff158..cf20e31 100644 --- a/x/cli/output/printer/table/printer.go +++ b/x/cli/output/printer/table/printer.go @@ -167,5 +167,5 @@ func (p *tablePrinter[T]) Print(ctx context.Context, cctx cli.CommandContext, vs } } - return f.Flush() + return errors.Wrap(f.Flush(), "flush formatter") }