From 040e2869b7505aae638f8aa60b93d520c3e73840 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 10:44:43 +0100 Subject: [PATCH 01/29] first draft --- core/human/v2/format.go | 1 + core/human/v2/human.go | 154 ++++++++++++++++++++++++++++++++++++ core/human/v2/human_test.go | 108 +++++++++++++++++++++++++ go.mod | 3 + go.sum | 3 + 5 files changed, 269 insertions(+) create mode 100644 core/human/v2/format.go create mode 100644 core/human/v2/human.go create mode 100644 core/human/v2/human_test.go diff --git a/core/human/v2/format.go b/core/human/v2/format.go new file mode 100644 index 0000000000..a8eb772a2a --- /dev/null +++ b/core/human/v2/format.go @@ -0,0 +1 @@ +package human diff --git a/core/human/v2/human.go b/core/human/v2/human.go new file mode 100644 index 0000000000..6aef0d172f --- /dev/null +++ b/core/human/v2/human.go @@ -0,0 +1,154 @@ +package human + +import ( + "fmt" + "reflect" + "slices" + "strings" + "sync" + + "github.com/kr/pretty" + "github.com/scaleway/scaleway-cli/v2/internal/tabwriter" +) + +// Layout describes the structure of the rendered data. Sections define +// blocks that will be rendered individually from the data in their path, +// the remaining fields being collected into the main section, defined with +// an empty path and no title by default. Sections order is conserved, with +// the main section being prepended if it isn't found in the list. +type Layout struct { + Sections []Section +} + +type Section struct { + // Path is the dotted-path to the field composing the section in the data. + // The main section is denoted with an empty path. + Path string + + // Title is the string for the section's header. It will be omitted if empty. + Title string + + // Omittable controls whether the section should be rendered if empty. + Omittable bool +} + +// ResolveSections return the final list of sections, with any default applied +// like the default section. +func (l Layout) ResolveSections() []Section { + if slices.ContainsFunc(l.Sections, func(s Section) bool { return s.Path == "" }) { + return l.Sections + } + + return append([]Section{{Path: "", Title: ""}}, l.Sections...) +} + +var layouts sync.Map + +// SetLayout stores a custom layout for a particular type. This custom layout +// will override the default one, and be eventually overridden by the render +// options themselves. This is useful because we don't want to have to +// maintain the layouts in the SDK itself, nor wrap the types to implement +// an interface. +func SetLayout(t reflect.Type, l Layout) { + layouts.Store(t, l) +} + +// Render data into a specific layout. +func Render(data any, layout Layout) (string, error) { + sections := layout.ResolveSections() + value := reflect.ValueOf(data) + + switch value.Kind() { + // If the value is a slice or array, we ensure that the layout is a single + // pathless section and write it. + case reflect.Slice, reflect.Array: + if len(sections) != 1 { + return "", fmt.Errorf("found %d sections for %T payload", len(sections), data) + } + + if sections[0].Path != "" { + return "", fmt.Errorf("unknown path %q for %T payload", sections[0].Path, data) + } + + var b strings.Builder + err := writeSection(&b, sections[0], data) + if err != nil { + return "", fmt.Errorf("rendering section: %w", err) + } + + return b.String(), nil + + // If the value is a struct, we initialize a map of the data fields, then + // split them by section. + case reflect.Struct: + fields := make(map[string]any, value.NumField()) + for i := range value.NumField() { + fields[value.Type().Field(i).Name] = value.Field(i).Interface() + } + + fieldsBySection := make(map[int]any, len(sections)) + for i, section := range sections { + if section.Path == "" { + fieldsBySection[i] = fields + } + if field, ok := fields[section.Path]; ok { + fieldsBySection[i] = field + delete(fields, section.Path) + } else { + return "", fmt.Errorf("path %q not found in %T", section.Path, data) + } + } + + var b strings.Builder + for i, section := range sections { + err := writeSection(&b, section, fieldsBySection[i]) + if err != nil { + return "", fmt.Errorf("rendering section %d: %w", i, err) + } + b.WriteRune('\n') + } + + return b.String(), nil + } + + return "", nil +} + +func writeSection(b *strings.Builder, section Section, data any) error { + if section.Title != "" { + b.WriteString(section.Title) + b.WriteRune(':') + b.WriteRune('\n') + } + + value := reflect.ValueOf(data) + + switch value.Kind() { + case reflect.Slice, reflect.Array: + elem := value.Type().Elem() + + w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) + for fieldIndex := range elem.NumField() { + if fieldIndex != 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, elem.Field(fieldIndex).Name) + } + fmt.Fprint(w, "\n") + for index := range value.Len() { + for fieldIndex := range value.Index(index).NumField() { + if fieldIndex != 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, value.Index(index).Field(fieldIndex).Interface()) + } + fmt.Fprint(w, "\n") + } + w.Flush() + + default: + b.WriteString(pretty.Sprint(data)) + } + + return nil +} diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go new file mode 100644 index 0000000000..245a635e14 --- /dev/null +++ b/core/human/v2/human_test.go @@ -0,0 +1,108 @@ +package human_test + +import ( + "testing" + + human "github.com/scaleway/scaleway-cli/v2/core/human/v2" + "github.com/stretchr/testify/require" +) + +func TestLayout_resolveSections(t *testing.T) { + for _, tc := range []struct { + name string + layout human.Layout + want []human.Section + }{ + { + name: "empty layout", + layout: human.Layout{}, + want: []human.Section{ + {Path: "", Title: "", Omittable: false}, + }, + }, + { + name: "non empty layout", + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Foo"}, + }, + }, + want: []human.Section{ + {Path: "", Title: "", Omittable: false}, + {Path: "Foo", Title: "", Omittable: false}, + }, + }, + { + name: "overridden default", + layout: human.Layout{ + Sections: []human.Section{ + {Path: "", Title: "Default"}, + {Path: "Foo"}, + }, + }, + want: []human.Section{ + {Path: "", Title: "Default", Omittable: false}, + {Path: "Foo", Title: "", Omittable: false}, + }, + }, + { + name: "out-of-order default", + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Foo"}, + {Path: ""}, + }, + }, + want: []human.Section{ + {Path: "Foo", Title: "", Omittable: false}, + {Path: "", Title: "", Omittable: false}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := tc.layout.ResolveSections() + require.Equal(t, tc.want, got) + }) + } +} + +type DummyStruct struct { + Foo string + Bar int +} + +func TestRender(t *testing.T) { + for _, tc := range []struct { + name string + data any + layout human.Layout + want string + wantErr string // substring of the expected error, empty to expect success + }{ + { + name: "simple struct", + data: []DummyStruct{ + {Foo: "foo", Bar: 1}, + {Foo: "bar", Bar: 2}, + }, + want: `Foo Bar +foo 1 +bar 2 +`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := human.Render(tc.data, tc.layout) + + if tc.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/go.mod b/go.mod index c7effe3a2c..df03ce6ae6 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-version v1.9.0 github.com/karrick/tparse/v2 v2.8.2 + github.com/kr/pretty v0.3.1 github.com/mattn/go-colorable v0.1.16 github.com/mattn/go-isatty v0.0.24 github.com/moby/buildkit v0.34.1 @@ -139,6 +140,7 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.20.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -169,6 +171,7 @@ require ( github.com/prometheus/procfs v0.21.1 // indirect github.com/rivo/tview v0.42.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect diff --git a/go.sum b/go.sum index 4100f1aebd..2c453db473 100644 --- a/go.sum +++ b/go.sum @@ -176,6 +176,7 @@ github.com/containerd/ttrpc v1.2.10/go.mod h1:YrmEQKkMbBA4EM26AzVAIwHGIYgptkL4CJ github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= @@ -410,6 +411,7 @@ github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -432,6 +434,7 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= From a47a61fece37e8c778f8f05711ba2d79b91909cb Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 11:15:18 +0100 Subject: [PATCH 02/29] struct --- core/human/v2/human.go | 43 +++++++++++++++++++++++++++---------- core/human/v2/human_test.go | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 6aef0d172f..2b3bdaf369 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -71,7 +71,7 @@ func Render(data any, layout Layout) (string, error) { } var b strings.Builder - err := writeSection(&b, sections[0], data) + err := writeSection(&b, sections[0], data, nil) if err != nil { return "", fmt.Errorf("rendering section: %w", err) } @@ -86,35 +86,45 @@ func Render(data any, layout Layout) (string, error) { fields[value.Type().Field(i).Name] = value.Field(i).Interface() } - fieldsBySection := make(map[int]any, len(sections)) - for i, section := range sections { + // claimed tracks the top-level fields rendered as their own section so + // they are skipped in the main (pathless) section, keeping declaration + // order. + claimed := make(map[string]bool, len(sections)) + for _, section := range sections { if section.Path == "" { - fieldsBySection[i] = fields + continue } - if field, ok := fields[section.Path]; ok { - fieldsBySection[i] = field - delete(fields, section.Path) - } else { + if _, ok := fields[section.Path]; !ok { return "", fmt.Errorf("path %q not found in %T", section.Path, data) } + claimed[section.Path] = true } var b strings.Builder for i, section := range sections { - err := writeSection(&b, section, fieldsBySection[i]) + data := data + if section.Path != "" { + data = fields[section.Path] + } + err := writeSection(&b, section, data, claimed) if err != nil { return "", fmt.Errorf("rendering section %d: %w", i, err) } b.WriteRune('\n') } - return b.String(), nil + return strings.TrimSpace(b.String()), nil } return "", nil } -func writeSection(b *strings.Builder, section Section, data any) error { +// writeSection data to the given string builder, with a map of the already +// claimed paths so we can skip the extracted sections from being rendered in +// the default section. This is necessary because the section data must be +// rendered in declaration order, so we can't convert the original data to +// a map without having to add additional complexity. +func writeSection(b *strings.Builder, section Section, data any, claimed map[string]bool) error { if section.Title != "" { b.WriteString(section.Title) b.WriteRune(':') @@ -146,6 +156,17 @@ func writeSection(b *strings.Builder, section Section, data any) error { } w.Flush() + case reflect.Struct: + w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) + for i := range value.NumField() { + field := value.Type().Field(i) + if claimed[field.Name] { + continue + } + fmt.Fprintf(w, "%s\t%v\n", field.Name, value.Field(i).Interface()) + } + w.Flush() + default: b.WriteString(pretty.Sprint(data)) } diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 245a635e14..e4825dd0e2 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -71,6 +71,17 @@ type DummyStruct struct { Bar int } +type RootLevelDummyStruct struct { + RootLevelFieldA string + RootLevelFieldB string + FirstLevelSection FirstLevelDummyStruct +} + +type FirstLevelDummyStruct struct { + FirstLevelFieldA string + FirstLevelFieldB string +} + func TestRender(t *testing.T) { for _, tc := range []struct { name string @@ -90,6 +101,36 @@ foo 1 bar 2 `, }, + { + name: "struct default layout", + data: DummyStruct{Foo: "foo", Bar: 1}, + // no explicit section: only the default pathless section. + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "struct with titled struct section", + data: RootLevelDummyStruct{ + RootLevelFieldA: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + RootLevelFieldB: "aaaaaaaa-aaaaaa-aaaaaa", + FirstLevelSection: FirstLevelDummyStruct{ + FirstLevelFieldA: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + FirstLevelFieldB: "BBBBB", + }, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "FirstLevelSection", Title: "First Level"}, + }, + }, + want: `RootLevelFieldA AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA +RootLevelFieldB aaaaaaaa-aaaaaa-aaaaaa + +First Level: +FirstLevelFieldA BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB +FirstLevelFieldB BBBBB`, + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Render(tc.data, tc.layout) From ce962c504a9c8370b2edf0411804c24d685d9c0d Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 11:39:18 +0100 Subject: [PATCH 03/29] pointers --- core/human/v2/human.go | 9 +++++++++ core/human/v2/human_test.go | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 2b3bdaf369..543d6d26bd 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -58,6 +58,15 @@ func Render(data any, layout Layout) (string, error) { sections := layout.ResolveSections() value := reflect.ValueOf(data) + // Dereference pointers until the underlying value is reached. + for value.Kind() == reflect.Pointer { + if value.IsNil() { + return "-", nil + } + value = value.Elem() + } + data = value.Interface() + switch value.Kind() { // If the value is a slice or array, we ensure that the layout is a single // pathless section and write it. diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index e4825dd0e2..6b7be6139b 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -107,6 +107,26 @@ bar 2 // no explicit section: only the default pathless section. layout: human.Layout{}, want: `Foo foo +Bar 1`, + }, + { + name: "pointer to struct", + data: &DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "nil pointer", + data: (*DummyStruct)(nil), + layout: human.Layout{}, + want: `-`, + }, + { + name: "multi-level pointer to struct", + data: new(new(&DummyStruct{Foo: "foo", Bar: 1})), + layout: human.Layout{}, + want: `Foo foo Bar 1`, }, { From 6a127482460a47eb1afb009b7fb3f8b79b8d01fc Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 11:50:37 +0100 Subject: [PATCH 04/29] renderer interface --- core/human/v2/human.go | 18 +++++++++++++++++- core/human/v2/human_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 543d6d26bd..b655c9e135 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -53,18 +53,34 @@ func SetLayout(t reflect.Type, l Layout) { layouts.Store(t, l) } +// Renderer is implemented by types that want full control over their own +// rendering, bypassing the layout-based default. It acts as an escape hatch +// for callers. +type Renderer interface { + RenderHuman() (string, error) +} + // Render data into a specific layout. func Render(data any, layout Layout) (string, error) { sections := layout.ResolveSections() value := reflect.ValueOf(data) - // Dereference pointers until the underlying value is reached. + // Dereference pointers until the underlying value is reached, letting any + // value in the chain escape through RenderHuman. for value.Kind() == reflect.Pointer { if value.IsNil() { return "-", nil } + if h, ok := reflect.TypeAssert[Renderer](value); ok { + return h.RenderHuman() + } value = value.Elem() } + if value.IsValid() { + if h, ok := reflect.TypeAssert[Renderer](value); ok { + return h.RenderHuman() + } + } data = value.Interface() switch value.Kind() { diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 6b7be6139b..75f6372fce 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -82,6 +82,14 @@ type FirstLevelDummyStruct struct { FirstLevelFieldB string } +type RendererStruct struct { + Foo string +} + +func (RendererStruct) RenderHuman() (string, error) { + return "custom-rendered", nil +} + func TestRender(t *testing.T) { for _, tc := range []struct { name string @@ -129,6 +137,24 @@ Bar 1`, want: `Foo foo Bar 1`, }, + { + name: "renderer value", + data: RendererStruct{Foo: "ignored"}, + layout: human.Layout{}, + want: "custom-rendered", + }, + { + name: "renderer pointer", + data: &RendererStruct{Foo: "ignored"}, + layout: human.Layout{}, + want: "custom-rendered", + }, + { + name: "renderer nil pointer", + data: (*RendererStruct)(nil), + layout: human.Layout{}, + want: `-`, + }, { name: "struct with titled struct section", data: RootLevelDummyStruct{ From 717c6344357680ed6cc19de540a7dc71e46f9d92 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 13:05:27 +0100 Subject: [PATCH 05/29] registry & omittable --- core/human/v2/format.go | 8 ++++ core/human/v2/human.go | 20 ++++++--- core/human/v2/human_test.go | 86 +++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/core/human/v2/format.go b/core/human/v2/format.go index a8eb772a2a..0d9b839340 100644 --- a/core/human/v2/format.go +++ b/core/human/v2/format.go @@ -1 +1,9 @@ package human + +type Formatter interface { + FormatHuman() (string, error) +} + +func Format(data any) (string, error) { + return "", nil +} diff --git a/core/human/v2/human.go b/core/human/v2/human.go index b655c9e135..bd9a25ffc8 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -62,11 +62,9 @@ type Renderer interface { // Render data into a specific layout. func Render(data any, layout Layout) (string, error) { - sections := layout.ResolveSections() - value := reflect.ValueOf(data) - // Dereference pointers until the underlying value is reached, letting any // value in the chain escape through RenderHuman. + value := reflect.ValueOf(data) for value.Kind() == reflect.Pointer { if value.IsNil() { return "-", nil @@ -83,6 +81,15 @@ func Render(data any, layout Layout) (string, error) { } data = value.Interface() + // Fall back to a per-type registered layout when the caller gave none, + // then apply the default pathless section if needed. + if reflect.ValueOf(layout).IsZero() { + if l, ok := layouts.Load(value.Type()); ok { + layout = l.(Layout) + } + } + sections := layout.ResolveSections() + switch value.Kind() { // If the value is a slice or array, we ensure that the layout is a single // pathless section and write it. @@ -150,14 +157,17 @@ func Render(data any, layout Layout) (string, error) { // rendered in declaration order, so we can't convert the original data to // a map without having to add additional complexity. func writeSection(b *strings.Builder, section Section, data any, claimed map[string]bool) error { + value := reflect.ValueOf(data) + if (!value.IsValid() || value.IsZero()) && section.Omittable { + return nil + } + if section.Title != "" { b.WriteString(section.Title) b.WriteRune(':') b.WriteRune('\n') } - value := reflect.ValueOf(data) - switch value.Kind() { case reflect.Slice, reflect.Array: elem := value.Type().Elem() diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 75f6372fce..0bd7de2830 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -1,6 +1,7 @@ package human_test import ( + "reflect" "testing" human "github.com/scaleway/scaleway-cli/v2/core/human/v2" @@ -82,6 +83,26 @@ type FirstLevelDummyStruct struct { FirstLevelFieldB string } +type ListDummyStruct struct { + OmittableList []FirstLevelDummyStruct + RequiredList []FirstLevelDummyStruct +} + +type RegistryDummyStruct struct { + RegistryField string + RegistrySection FirstLevelDummyStruct +} + +func init() { + // Registered once for the whole table; only the two registry cases use + // RegistryDummyStruct, so no other case is affected. + human.SetLayout(reflect.TypeFor[RegistryDummyStruct](), human.Layout{ + Sections: []human.Section{ + {Path: "RegistrySection", Title: "Custom"}, + }, + }) +} + type RendererStruct struct { Foo string } @@ -177,6 +198,71 @@ First Level: FirstLevelFieldA BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB FirstLevelFieldB BBBBB`, }, + { + name: "omittable empty section", + data: ListDummyStruct{}, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "OmittableList", Title: "Omittable List", Omittable: true}, + {Path: "RequiredList", Title: "Required List", Omittable: false}, + }, + }, + // The omittable section is empty so it is skipped, the required one + // keeps its header-only table. + want: `Required List: +FirstLevelFieldA FirstLevelFieldB`, + }, + { + name: "omittable non-empty section", + data: ListDummyStruct{ + OmittableList: []FirstLevelDummyStruct{ + {FirstLevelFieldA: "x", FirstLevelFieldB: "y"}, + }, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "OmittableList", Title: "Omittable List", Omittable: true}, + {Path: "RequiredList", Title: "Required List", Omittable: false}, + }, + }, + want: `Omittable List: +FirstLevelFieldA FirstLevelFieldB +x y + +Required List: +FirstLevelFieldA FirstLevelFieldB`, + }, + { + name: "registry layout", + data: RegistryDummyStruct{ + RegistryField: "value", + RegistrySection: FirstLevelDummyStruct{FirstLevelFieldA: "aaa", FirstLevelFieldB: "bbb"}, + }, + // No layout given: the one registered for the type applies. + layout: human.Layout{}, + want: `RegistryField value + +Custom: +FirstLevelFieldA aaa +FirstLevelFieldB bbb`, + }, + { + name: "explicit layout overrides registry", + data: RegistryDummyStruct{ + RegistryField: "value", + RegistrySection: FirstLevelDummyStruct{FirstLevelFieldA: "aaa", FirstLevelFieldB: "bbb"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "RegistrySection", Title: "Explicit"}, + }, + }, + want: `RegistryField value + +Explicit: +FirstLevelFieldA aaa +FirstLevelFieldB bbb`, + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Render(tc.data, tc.layout) From 90e5a8cbb1b4ed394a5577d63b3ee15047932392 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 13:54:54 +0100 Subject: [PATCH 06/29] format --- core/human/v2/format.go | 48 ++++++++++- core/human/v2/format_test.go | 153 +++++++++++++++++++++++++++++++++++ core/human/v2/human.go | 9 ++- core/human/v2/human_test.go | 6 ++ 4 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 core/human/v2/format_test.go diff --git a/core/human/v2/format.go b/core/human/v2/format.go index 0d9b839340..42d00910cf 100644 --- a/core/human/v2/format.go +++ b/core/human/v2/format.go @@ -1,9 +1,55 @@ package human +import ( + "fmt" + "reflect" + "time" + + "github.com/dustin/go-humanize" + "github.com/scaleway/scaleway-sdk-go/scw" +) + type Formatter interface { FormatHuman() (string, error) } func Format(data any) (string, error) { - return "", nil + // Dereference pointers until the underlying value is reached, letting any + // value in the chain escape through FormatHuman. + value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer { + if value.IsNil() { + return "-", nil + } + if f, ok := reflect.TypeAssert[Formatter](value); ok { + return f.FormatHuman() + } + value = value.Elem() + } + if !value.IsValid() { + return "-", nil + } + if f, ok := reflect.TypeAssert[Formatter](value); ok { + return f.FormatHuman() + } + data = value.Interface() + + switch v := data.(type) { + case string: + if v == "" { + return "-", nil + } + + return v, nil + case time.Time: + return humanize.Time(v), nil + case scw.Size: + if isIECNotation := v%1024 == 0 && v%1000 != 0; isIECNotation { + return humanize.IBytes(uint64(v)), nil + } + + return humanize.Bytes(uint64(v)), nil + } + + return fmt.Sprint(data), nil } diff --git a/core/human/v2/format_test.go b/core/human/v2/format_test.go new file mode 100644 index 0000000000..373542c8ab --- /dev/null +++ b/core/human/v2/format_test.go @@ -0,0 +1,153 @@ +package human_test + +import ( + "testing" + "time" + + "github.com/dustin/go-humanize" + human "github.com/scaleway/scaleway-cli/v2/core/human/v2" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/stretchr/testify/require" +) + +type FormatterStruct struct { + Foo string +} + +func (FormatterStruct) FormatHuman() (string, error) { + return "cell-formatted", nil +} + +type StringerStruct struct{} + +func (StringerStruct) String() string { + return "stringed" +} + +type ErrorStruct struct{} + +func (ErrorStruct) Error() string { + return "something broke" +} + +func TestFormat(t *testing.T) { + date := time.Date(1990, 11, 17, 20, 20, 0, 0, time.UTC) + humanDate := humanize.Time(date) + + for _, tc := range []struct { + name string + data any + want string + wantErr string // substring of the expected error, empty to expect success + }{ + { + name: "formatter value", + data: FormatterStruct{Foo: "ignored"}, + want: "cell-formatted", + }, + { + name: "formatter pointer", + data: &FormatterStruct{Foo: "ignored"}, + want: "cell-formatted", + }, + { + name: "formatter multi-pointer", + data: new(new(&FormatterStruct{Foo: "ignored"})), + want: "cell-formatted", + }, + { + name: "string", + data: "hello", + want: "hello", + }, + { + name: "integer", + data: 42, + want: "42", + }, + { + name: "boolean", + data: true, + want: "true", + }, + { + name: "empty string", + data: "", + want: "-", + }, + { + name: "nil", + data: nil, + want: "-", + }, + { + name: "stringer value", + data: StringerStruct{}, + want: "stringed", + }, + { + name: "stringer pointer", + data: &StringerStruct{}, + want: "stringed", + }, + { + name: "error", + data: ErrorStruct{}, + want: "something broke", + }, + { + name: "pointer to integer", + data: new(int(42)), + want: "42", + }, + { + name: "nil pointer", + data: (*int)(nil), + want: "-", + }, + { + name: "float", + data: 3.14, + want: "3.14", + }, + { + name: "time", + data: date, + want: humanDate, + }, + { + name: "nil time pointer", + data: (*time.Time)(nil), + want: "-", + }, + { + name: "size", + data: scw.Size(13200), + want: "13 kB", + }, + { + name: "size IEC notation", + data: scw.Size(52428800), + want: "50 MiB", + }, + { + name: "nil size pointer", + data: (*scw.Size)(nil), + want: "-", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := human.Format(tc.data) + + if tc.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/core/human/v2/human.go b/core/human/v2/human.go index bd9a25ffc8..1632824731 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -74,10 +74,11 @@ func Render(data any, layout Layout) (string, error) { } value = value.Elem() } - if value.IsValid() { - if h, ok := reflect.TypeAssert[Renderer](value); ok { - return h.RenderHuman() - } + if !value.IsValid() { + return "-", nil + } + if h, ok := reflect.TypeAssert[Renderer](value); ok { + return h.RenderHuman() } data = value.Interface() diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 0bd7de2830..1a8d058cba 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -151,6 +151,12 @@ Bar 1`, layout: human.Layout{}, want: `-`, }, + { + name: "nil", + data: nil, + layout: human.Layout{}, + want: `-`, + }, { name: "multi-level pointer to struct", data: new(new(&DummyStruct{Foo: "foo", Bar: 1})), From 56ccdf21dff6d7881873d047d6175b0510fe8e9b Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 14:07:32 +0100 Subject: [PATCH 07/29] leaf values --- core/human/v2/human.go | 36 ++++++++++++++++++++++++++++++++---- core/human/v2/human_test.go | 17 +++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 1632824731..775616d555 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -7,7 +7,6 @@ import ( "strings" "sync" - "github.com/kr/pretty" "github.com/scaleway/scaleway-cli/v2/internal/tabwriter" ) @@ -60,6 +59,28 @@ type Renderer interface { RenderHuman() (string, error) } +// isFormattable reports whether t should be rendered inline by Format rather +// than as a composite table. +func isFormattable(t reflect.Type) bool { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + switch { + // A struct is a table, unless it escapes through Formatter. + case t.Kind() == reflect.Struct: + return t.Implements(reflect.TypeFor[Formatter]()) + // A slice of structs breaks into a table; a scalar slice is inline. + case t.Kind() == reflect.Slice || t.Kind() == reflect.Array: + elem := t.Elem() + for elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + return elem.Kind() != reflect.Struct + default: + return true + } +} + // Render data into a specific layout. func Render(data any, layout Layout) (string, error) { // Dereference pointers until the underlying value is reached, letting any @@ -169,6 +190,16 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str b.WriteRune('\n') } + if !value.IsValid() || isFormattable(value.Type()) { + str, err := Format(data) + if err != nil { + return err + } + b.WriteString(str) + + return nil + } + switch value.Kind() { case reflect.Slice, reflect.Array: elem := value.Type().Elem() @@ -202,9 +233,6 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str fmt.Fprintf(w, "%s\t%v\n", field.Name, value.Field(i).Interface()) } w.Flush() - - default: - b.WriteString(pretty.Sprint(data)) } return nil diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 1a8d058cba..a2a306bc45 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -93,6 +93,10 @@ type RegistryDummyStruct struct { RegistrySection FirstLevelDummyStruct } +type ScalarSliceDummyStruct struct { + Fields []string +} + func init() { // Registered once for the whole table; only the two registry cases use // RegistryDummyStruct, so no other case is affected. @@ -237,6 +241,19 @@ x y Required List: FirstLevelFieldA FirstLevelFieldB`, + }, + { + name: "scalar slice section", + data: ScalarSliceDummyStruct{ + Fields: []string{"poweron", "backup"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Fields", Title: "Allowed"}, + }, + }, + want: `Allowed: +[poweron backup]`, }, { name: "registry layout", From 6a65e5ff2066223251a31bba881aa52631470c35 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 14:14:22 +0100 Subject: [PATCH 08/29] maps --- core/human/v2/human.go | 30 ++++++++++++++++++++++++++++++ core/human/v2/human_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 775616d555..39d9906124 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -76,6 +76,9 @@ func isFormattable(t reflect.Type) bool { elem = elem.Elem() } return elem.Kind() != reflect.Struct + // A map is a key-value table. + case t.Kind() == reflect.Map: + return false default: return true } @@ -223,6 +226,33 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str } w.Flush() + case reflect.Map: + w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) + keys := value.MapKeys() + slices.SortFunc(keys, func(i, j reflect.Value) int { + return strings.Compare(i.String(), j.String()) + }) + for _, key := range keys { + mapValue := value.MapIndex(key) + var str string + if mapValue.Kind() == reflect.Slice || mapValue.Kind() == reflect.Array { + values := make([]string, mapValue.Len()) + for i := range mapValue.Len() { + values[i] = fmt.Sprint(mapValue.Index(i).Interface()) + } + slices.Sort(values) + str = strings.Join(values, " ") + } else { + var err error + str, err = Format(mapValue.Interface()) + if err != nil { + return err + } + } + fmt.Fprintf(w, "%s\t%s\n", key.String(), str) + } + w.Flush() + case reflect.Struct: w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) for i := range value.NumField() { diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index a2a306bc45..2209f8108c 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -97,6 +97,14 @@ type ScalarSliceDummyStruct struct { Fields []string } +type MapDummyStruct struct { + Fields map[string]string +} + +type MapListDummyStruct struct { + Fields map[string][]string +} + func init() { // Registered once for the whole table; only the two registry cases use // RegistryDummyStruct, so no other case is affected. @@ -254,6 +262,34 @@ FirstLevelFieldA FirstLevelFieldB`, }, want: `Allowed: [poweron backup]`, + }, + { + name: "map section", + data: MapDummyStruct{ + Fields: map[string]string{"b": "two", "a": "one"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Fields", Title: "Fields"}, + }, + }, + want: `Fields: +a one +b two`, + }, + { + name: "map section with slice value", + data: MapListDummyStruct{ + Fields: map[string][]string{"b": {"y", "x"}, "a": {"one"}}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Fields", Title: "Fields"}, + }, + }, + want: `Fields: +a one +b x y`, }, { name: "registry layout", From 083efd65488add169da24990bfc72d086135bf64 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 15:21:58 +0100 Subject: [PATCH 09/29] better dotted walk --- core/human/v2/human.go | 197 ++++++++++++++++++++++++++++-------- core/human/v2/human_test.go | 149 ++++++++++++++++++++++++++- 2 files changed, 302 insertions(+), 44 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 39d9906124..078a92fde2 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -4,6 +4,7 @@ import ( "fmt" "reflect" "slices" + "strconv" "strings" "sync" @@ -62,7 +63,12 @@ type Renderer interface { // isFormattable reports whether t should be rendered inline by Format rather // than as a composite table. func isFormattable(t reflect.Type) bool { + // A formatter at any pointer level escapes through Format, which checks + // each level in its deref loop, so mirror it here. for t.Kind() == reflect.Pointer { + if t.Implements(reflect.TypeFor[Formatter]()) { + return true + } t = t.Elem() } switch { @@ -75,6 +81,7 @@ func isFormattable(t reflect.Type) bool { for elem.Kind() == reflect.Pointer { elem = elem.Elem() } + return elem.Kind() != reflect.Struct // A map is a key-value table. case t.Kind() == reflect.Map: @@ -135,35 +142,33 @@ func Render(data any, layout Layout) (string, error) { return b.String(), nil - // If the value is a struct, we initialize a map of the data fields, then - // split them by section. + // If the value is a struct, we resolve the sections from the data, then + // split them by section. claimed tracks the dotted paths rendered as their + // own section so the flattening of the main block skips them, keeping + // declaration order. case reflect.Struct: - fields := make(map[string]any, value.NumField()) - for i := range value.NumField() { - fields[value.Type().Field(i).Name] = value.Field(i).Interface() - } - - // claimed tracks the top-level fields rendered as their own section so - // they are skipped in the main (pathless) section, keeping declaration - // order. claimed := make(map[string]bool, len(sections)) for _, section := range sections { if section.Path == "" { continue } - if _, ok := fields[section.Path]; !ok { - return "", fmt.Errorf("path %q not found in %T", section.Path, data) + if _, err := valueAtPath(data, section.Path); err != nil { + return "", fmt.Errorf("path %q not found in %T: %w", section.Path, data, err) } claimed[section.Path] = true } var b strings.Builder for i, section := range sections { - data := data + sectionData := data if section.Path != "" { - data = fields[section.Path] + var err error + sectionData, err = valueAtPath(data, section.Path) + if err != nil { + return "", fmt.Errorf("path %q not found in %T: %w", section.Path, data, err) + } } - err := writeSection(&b, section, data, claimed) + err := writeSection(&b, section, sectionData, claimed) if err != nil { return "", fmt.Errorf("rendering section %d: %w", i, err) } @@ -226,44 +231,152 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str } w.Flush() - case reflect.Map: + // A struct and a map render the same: one walk flattens composites into + // dotted rows, renders formattable leaves in place, and skips paths claimed + // by other sections. Maps are just walked as keyed leaves. + case reflect.Map, reflect.Struct: w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) - keys := value.MapKeys() - slices.SortFunc(keys, func(i, j reflect.Value) int { - return strings.Compare(i.String(), j.String()) - }) - for _, key := range keys { - mapValue := value.MapIndex(key) - var str string - if mapValue.Kind() == reflect.Slice || mapValue.Kind() == reflect.Array { - values := make([]string, mapValue.Len()) - for i := range mapValue.Len() { - values[i] = fmt.Sprint(mapValue.Index(i).Interface()) - } - slices.Sort(values) - str = strings.Join(values, " ") - } else { - var err error - str, err = Format(mapValue.Interface()) + err := walk(value, "", func(keys string, node reflect.Value) (bool, error) { + if claimed[keys] { + return false, nil + } + + // Scalars, formatter structs (and pointers to them) render in place. + // Everything else descends into dotted leaves. isFormattable reports + // scalar slices as inline for section bodies, but a walk always + // flattens slices into indexed rows. + t := node.Type() + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if isFormattable(node.Type()) && t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + str, err := Format(node.Interface()) if err != nil { - return err + return false, err } + fmt.Fprintf(w, "%s\t%s\n", keys, str) + + return false, nil } - fmt.Fprintf(w, "%s\t%s\n", key.String(), str) + + return true, nil + }) + if err != nil { + return err } w.Flush() + } + + return nil +} + +// walk represents value and its nodes to f; f installs its own walk logic: +// it's called for every node with its dotted-path keys (Sub.X, Strings.0, +// Map.key) and returns whether to keep descending after it. Nil pointers and +// interfaces yield no node at all. +func walk(value reflect.Value, keys string, f func(keys string, value reflect.Value) (bool, error)) error { + if (value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface) && value.IsNil() { + return nil + } + + descend, err := f(keys, value) + if err != nil { + return err + } + if !descend { + return nil + } + + switch value.Kind() { + case reflect.Pointer, reflect.Interface: + return walk(value.Elem(), keys, f) case reflect.Struct: - w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) for i := range value.NumField() { - field := value.Type().Field(i) - if claimed[field.Name] { - continue + name := value.Type().Field(i).Name + if keys != "" { + name = keys + "." + name + } + err := walk(value.Field(i), name, f) + if err != nil { + return err } - fmt.Fprintf(w, "%s\t%v\n", field.Name, value.Field(i).Interface()) } - w.Flush() + + return nil + + case reflect.Slice, reflect.Array: + prefix := keys + if keys != "" { + prefix += "." + } + for i := range value.Len() { + err := walk(value.Index(i), prefix+strconv.Itoa(i), f) + if err != nil { + return err + } + } + + return nil + + case reflect.Map: + mapKeys := value.MapKeys() + slices.SortFunc(mapKeys, func(i, j reflect.Value) int { + return strings.Compare(i.String(), j.String()) + }) + prefix := keys + if keys != "" { + prefix += "." + } + for _, mapKey := range mapKeys { + err := walk(value.MapIndex(mapKey), prefix+mapKey.String(), f) + if err != nil { + return err + } + } + + return nil + + default: + // Leaf: f already decided whether to descend. + return nil } +} - return nil +// valueAtPath returns the value at the given dotted path, dereferencing +// pointers and interfaces along the way. +func valueAtPath(data any, path string) (any, error) { + value := reflect.ValueOf(data) + + for part := range strings.SplitSeq(path, ".") { + for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if value.IsNil() { + return nil, fmt.Errorf("path %q not found in %T: nil value", path, data) + } + value = value.Elem() + } + + switch value.Kind() { + case reflect.Struct: + value = value.FieldByName(part) + if !value.IsValid() || !value.CanInterface() { + return nil, fmt.Errorf("path %q not found in %T", path, data) + } + case reflect.Slice, reflect.Array: + index, err := strconv.Atoi(part) + if err != nil || index < 0 || index >= value.Len() { + return nil, fmt.Errorf("path %q not found in %T", path, data) + } + value = value.Index(index) + case reflect.Map: + value = value.MapIndex(reflect.ValueOf(part)) + if !value.IsValid() { + return nil, fmt.Errorf("path %q not found in %T", path, data) + } + default: + return nil, fmt.Errorf("path %q not found in %T", path, data) + } + } + + return value.Interface(), nil } diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 2209f8108c..216460b0cd 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -97,6 +97,45 @@ type ScalarSliceDummyStruct struct { Fields []string } +type NestedDummyStruct struct { + X string + Y string +} + +type RootDummyStruct struct { + RootA string + Sub NestedDummyStruct + RootB string +} + +type RootSliceDummyStruct struct { + RootA string + Strings []string +} + +type RootMapDummyStruct struct { + RootA string + Labels map[string]string +} + +type NestedFormatterDummyStruct struct { + RootA string + Sub FormatterStruct +} + +type pointerRecvFormatter struct { + Foo string +} + +func (s *pointerRecvFormatter) FormatHuman() (string, error) { + return "pointer-formatted", nil +} + +type NestedPointerFormatterDummyStruct struct { + RootA string + Sub *pointerRecvFormatter +} + type MapDummyStruct struct { Fields map[string]string } @@ -288,8 +327,114 @@ b two`, }, }, want: `Fields: -a one -b x y`, +a.0 one +b.0 y +b.1 x`, + }, + { + name: "flattened struct fields", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{}, + want: `RootA aaa +Sub.X one +Sub.Y two +RootB bbb`, + }, + { + name: "dotted section path", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub.Y", Title: "Y"}, + }, + }, + // Sub.Y is claimed by the section, Sub.X keeps flattening into + // the main block. + want: `RootA aaa +Sub.X one +RootB bbb + +Y: +two`, + }, + { + name: "nested formatter struct rendered inline", + data: NestedFormatterDummyStruct{ + RootA: "aaa", + Sub: FormatterStruct{Foo: "ignored"}, + }, + layout: human.Layout{}, + want: `RootA aaa +Sub cell-formatted`, + }, + { + name: "nested pointer-receiver formatter rendered inline", + data: NestedPointerFormatterDummyStruct{ + RootA: "aaa", + Sub: &pointerRecvFormatter{Foo: "x"}, + }, + layout: human.Layout{}, + want: `RootA aaa +Sub pointer-formatted`, + }, + { + name: "dotted section path for a whole struct", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "Sub"}, + }, + }, + // Claiming the whole Sub struct also prunes its leaves from the main + // block, not just the exact dotted path. + want: `RootA aaa +RootB bbb + +Sub: +X one +Y two`, + }, + { + name: "flattened slice fields", + data: RootSliceDummyStruct{ + RootA: "aaa", + Strings: []string{"s1", "s2"}, + }, + layout: human.Layout{}, + want: `RootA aaa +Strings.0 s1 +Strings.1 s2`, + }, + { + name: "flattened map fields", + data: RootMapDummyStruct{ + RootA: "aaa", + Labels: map[string]string{"b": "two", "a": "one"}, + }, + layout: human.Layout{}, + want: `RootA aaa +Labels.a one +Labels.b two`, + }, + { + name: "nil composite field dropped", + data: RootSliceDummyStruct{ + RootA: "aaa", + }, + layout: human.Layout{}, + want: `RootA aaa`, }, { name: "registry layout", From 8e648b7d2ba42ae84607b823827d2fa798ba72c1 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 16:08:40 +0100 Subject: [PATCH 10/29] slices of nullables --- core/human/v2/human.go | 26 +++++++++++++++++++++----- core/human/v2/human_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 078a92fde2..78e0973650 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -210,22 +210,38 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str switch value.Kind() { case reflect.Slice, reflect.Array: - elem := value.Type().Elem() + itemType := value.Type().Elem() + for itemType.Kind() == reflect.Pointer { + itemType = itemType.Elem() + } w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) - for fieldIndex := range elem.NumField() { + for fieldIndex := range itemType.NumField() { if fieldIndex != 0 { fmt.Fprint(w, "\t") } - fmt.Fprint(w, elem.Field(fieldIndex).Name) + fmt.Fprint(w, itemType.Field(fieldIndex).Name) } fmt.Fprint(w, "\n") + var err error for index := range value.Len() { - for fieldIndex := range value.Index(index).NumField() { + item := value.Index(index) + for (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && !item.IsNil() { + item = item.Elem() + } + isNil := item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface + for fieldIndex := range itemType.NumField() { if fieldIndex != 0 { fmt.Fprint(w, "\t") } - fmt.Fprint(w, value.Index(index).Field(fieldIndex).Interface()) + str := "-" + if !isNil { + str, err = Format(item.Field(fieldIndex).Interface()) + if err != nil { + return err + } + } + fmt.Fprint(w, str) } fmt.Fprint(w, "\n") } diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 216460b0cd..f7560ba47a 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -179,6 +179,37 @@ func TestRender(t *testing.T) { want: `Foo Bar foo 1 bar 2 +`, + }, + { + name: "slice of pointer to struct", + data: []*FirstLevelDummyStruct{ + {FirstLevelFieldA: "a", FirstLevelFieldB: "b"}, + {FirstLevelFieldA: "c", FirstLevelFieldB: "d"}, + }, + want: `FirstLevelFieldA FirstLevelFieldB +a b +c d +`, + }, + { + name: "slice with nil struct item", + data: []*FirstLevelDummyStruct{ + nil, + {FirstLevelFieldA: "c", FirstLevelFieldB: "d"}, + }, + want: `FirstLevelFieldA FirstLevelFieldB +- - +c d +`, + }, + { + name: "empty string cell formatted", + data: []DummyStruct{ + {Foo: "", Bar: 1}, + }, + want: `Foo Bar +- 1 `, }, { From 4e6acb8f2cccede2b49c93d4bfa24c6b227a2cb3 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 16:26:07 +0100 Subject: [PATCH 11/29] format durations --- core/human/v2/format.go | 38 ++++++++++++++++++++++++++++++++++++ core/human/v2/format_test.go | 20 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/core/human/v2/format.go b/core/human/v2/format.go index 42d00910cf..cd2d26508e 100644 --- a/core/human/v2/format.go +++ b/core/human/v2/format.go @@ -3,6 +3,7 @@ package human import ( "fmt" "reflect" + "strings" "time" "github.com/dustin/go-humanize" @@ -49,7 +50,44 @@ func Format(data any) (string, error) { } return humanize.Bytes(uint64(v)), nil + case scw.Duration: + return formatDuration(v), nil } return fmt.Sprint(data), nil } + +// formatDuration renders a duration as its joined non-zero components, +// matching v1's registered marshaler for scw.Duration. +func formatDuration(v scw.Duration) string { + const ( + minutes = int64(60) + hours = 60 * minutes + days = 24 * hours + ) + d := v.Seconds / days + h := (v.Seconds - d*days) / hours + m := (v.Seconds - (d*days + h*hours)) / minutes + s := v.Seconds % 60 + res := []string(nil) + if d != 0 { + res = append(res, fmt.Sprintf("%d days", d)) + } + if h != 0 { + res = append(res, fmt.Sprintf("%d hours", h)) + } + if m != 0 { + res = append(res, fmt.Sprintf("%d minutes", m)) + } + if s != 0 { + res = append(res, fmt.Sprintf("%d seconds", s)) + } + if v.Nanos != 0 { + res = append(res, fmt.Sprintf("%d nanoseconds", v.Nanos)) + } + if len(res) == 0 { + return "0 seconds" + } + + return strings.Join(res, " ") +} diff --git a/core/human/v2/format_test.go b/core/human/v2/format_test.go index 373542c8ab..7f34107e6f 100644 --- a/core/human/v2/format_test.go +++ b/core/human/v2/format_test.go @@ -135,6 +135,26 @@ func TestFormat(t *testing.T) { data: (*scw.Size)(nil), want: "-", }, + { + name: "duration", + data: scw.Duration{Seconds: 604800}, + want: "7 days", + }, + { + name: "duration combined", + data: scw.Duration{Seconds: 9000}, + want: "2 hours 30 minutes", + }, + { + name: "duration zero", + data: scw.Duration{}, + want: "0 seconds", + }, + { + name: "duration pointer", + data: &scw.Duration{Seconds: 30}, + want: "30 seconds", + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Format(tc.data) From 341e3b195c7daa22c7bc3d2be73097c55a49064d Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Mon, 2 Nov 2026 16:45:11 +0100 Subject: [PATCH 12/29] orphan blanks --- core/human/v2/human.go | 31 ++++++++++++++++++++----------- core/human/v2/human_test.go | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 78e0973650..19d21ecca2 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -135,7 +135,7 @@ func Render(data any, layout Layout) (string, error) { } var b strings.Builder - err := writeSection(&b, sections[0], data, nil) + _, err := writeSection(&b, sections[0], data, nil) if err != nil { return "", fmt.Errorf("rendering section: %w", err) } @@ -168,11 +168,15 @@ func Render(data any, layout Layout) (string, error) { return "", fmt.Errorf("path %q not found in %T: %w", section.Path, data, err) } } - err := writeSection(&b, section, sectionData, claimed) + wrote, err := writeSection(&b, section, sectionData, claimed) if err != nil { return "", fmt.Errorf("rendering section %d: %w", i, err) } - b.WriteRune('\n') + // Only separate sections that wrote content, so a skipped omittable + // section leaves no orphan blank line. + if wrote { + b.WriteRune('\n') + } } return strings.TrimSpace(b.String()), nil @@ -185,11 +189,12 @@ func Render(data any, layout Layout) (string, error) { // claimed paths so we can skip the extracted sections from being rendered in // the default section. This is necessary because the section data must be // rendered in declaration order, so we can't convert the original data to -// a map without having to add additional complexity. -func writeSection(b *strings.Builder, section Section, data any, claimed map[string]bool) error { +// a map without having to add additional complexity. It returns whether +// anything was written. +func writeSection(b *strings.Builder, section Section, data any, claimed map[string]bool) (bool, error) { value := reflect.ValueOf(data) if (!value.IsValid() || value.IsZero()) && section.Omittable { - return nil + return false, nil } if section.Title != "" { @@ -201,11 +206,11 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str if !value.IsValid() || isFormattable(value.Type()) { str, err := Format(data) if err != nil { - return err + return false, err } b.WriteString(str) - return nil + return true, nil } switch value.Kind() { @@ -238,7 +243,7 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str if !isNil { str, err = Format(item.Field(fieldIndex).Interface()) if err != nil { - return err + return false, err } } fmt.Fprint(w, str) @@ -247,6 +252,8 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str } w.Flush() + return true, nil + // A struct and a map render the same: one walk flattens composites into // dotted rows, renders formattable leaves in place, and skips paths claimed // by other sections. Maps are just walked as keyed leaves. @@ -278,12 +285,14 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str return true, nil }) if err != nil { - return err + return false, err } w.Flush() + + return true, nil } - return nil + return false, nil } // walk represents value and its nodes to f; f installs its own walk logic: diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index f7560ba47a..bc44521a5c 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -374,6 +374,26 @@ b.1 x`, Sub.X one Sub.Y two RootB bbb`, + }, + { + name: "omittable skipped between two sections", + data: RootDummyStruct{ + RootA: "aaa", + // Sub zero -> omitted + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "B", Omittable: true}, + {Path: "RootB", Title: "C"}, + }, + }, + // The skipped omittable section must not leave an orphan blank line + // between the two rendered ones. + want: `RootA aaa + +C: +bbb`, }, { name: "dotted section path", From 28552ad7b2a4c888caf439ae417d125c38187da9 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 10:05:32 +0100 Subject: [PATCH 13/29] selected fields --- core/human/v2/human.go | 70 +++++++++++++++++++++++++++++++------ core/human/v2/human_test.go | 59 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 19d21ecca2..c25a614bc8 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -30,6 +30,11 @@ type Section struct { // Omittable controls whether the section should be rendered if empty. Omittable bool + + // Fields limits the section to the selected dotted field paths: for a + // table it selects the columns, for a struct or map it selects the lines. + // Empty means all fields. + Fields []string } // ResolveSections return the final list of sections, with any default applied @@ -214,38 +219,62 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str } switch value.Kind() { + // Slices and arrays renders as table. case reflect.Slice, reflect.Array: itemType := value.Type().Elem() for itemType.Kind() == reflect.Pointer { itemType = itemType.Elem() } + // Resolve the selected fields. If no field was selected, initialize the list + // with the item's fields. + fields := section.Fields + if len(section.Fields) == 0 { + fields = make([]string, itemType.NumField()) + for fieldIndex := range itemType.NumField() { + fields[fieldIndex] = itemType.Field(fieldIndex).Name + } + } + w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) - for fieldIndex := range itemType.NumField() { + for fieldIndex, field := range fields { if fieldIndex != 0 { fmt.Fprint(w, "\t") } - fmt.Fprint(w, itemType.Field(fieldIndex).Name) + fmt.Fprint(w, field) } fmt.Fprint(w, "\n") - var err error + for index := range value.Len() { item := value.Index(index) + + // Dereference the item's value until it is either nil or not a pointer so + // we can access the underlying's fields. for (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && !item.IsNil() { item = item.Elem() } isNil := item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface - for fieldIndex := range itemType.NumField() { + + for fieldIndex, field := range fields { if fieldIndex != 0 { fmt.Fprint(w, "\t") } - str := "-" - if !isNil { - str, err = Format(item.Field(fieldIndex).Interface()) - if err != nil { - return false, err - } + + if isNil { + fmt.Fprint(w, "-") + continue } + + cell, err := valueAtPath(item.Interface(), field) + if err != nil { + return false, err + } + + str, err := Format(cell) + if err != nil { + return false, err + } + fmt.Fprint(w, str) } fmt.Fprint(w, "\n") @@ -264,6 +293,27 @@ func writeSection(b *strings.Builder, section Section, data any, claimed map[str return false, nil } + // Field selection keeps only the selected subtrees: a node above + // one still descends, a branch without selection is pruned. + if len(section.Fields) > 0 && keys != "" { + under := false + for _, f := range section.Fields { + if keys == f || strings.HasPrefix(keys, f+".") { + under = true + break + } + } + if !under { + for _, f := range section.Fields { + if strings.HasPrefix(f, keys+".") { + return true, nil + } + } + + return false, nil + } + } + // Scalars, formatter structs (and pointers to them) render in place. // Everything else descends into dotted leaves. isFormattable reports // scalar slices as inline for section bodies, but a walk always diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index bc44521a5c..47ab1ca044 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -500,6 +500,65 @@ Labels.b two`, Custom: FirstLevelFieldA aaa FirstLevelFieldB bbb`, + }, + { + name: "flat table with fields", + data: []DummyStruct{ + {Foo: "f", Bar: 1}, + {Foo: "b", Bar: 2}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []string{"Bar"}}, + }, + }, + want: `Bar +1 +2 +`, + }, + { + name: "table column with dotted path", + data: []RootDummyStruct{ + {RootA: "a", Sub: NestedDummyStruct{X: "x", Y: "y"}}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []string{"RootA", "Sub.X"}}, + }, + }, + want: `RootA Sub.X +a x +`, + }, + { + name: "struct lines filtered", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []string{"RootA", "Sub.X"}}, + }, + }, + want: `RootA aaa +Sub.X one`, + }, + { + name: "selected composite subtree flattens", + data: RootSliceDummyStruct{ + RootA: "a", + Strings: []string{"s1", "s2"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []string{"Strings"}}, + }, + }, + want: `Strings.0 s1 +Strings.1 s2`, }, { name: "explicit layout overrides registry", From 5ba9be2c9c0eac4ae6ea0dfefed193bf6614f020 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 10:23:20 +0100 Subject: [PATCH 14/29] simplification --- core/human/v2/human.go | 73 +++++++++++++++---------------------- core/human/v2/human_test.go | 26 +++---------- 2 files changed, 35 insertions(+), 64 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index c25a614bc8..2affbc3297 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -58,13 +58,6 @@ func SetLayout(t reflect.Type, l Layout) { layouts.Store(t, l) } -// Renderer is implemented by types that want full control over their own -// rendering, bypassing the layout-based default. It acts as an escape hatch -// for callers. -type Renderer interface { - RenderHuman() (string, error) -} - // isFormattable reports whether t should be rendered inline by Format rather // than as a composite table. func isFormattable(t reflect.Type) bool { @@ -98,24 +91,17 @@ func isFormattable(t reflect.Type) bool { // Render data into a specific layout. func Render(data any, layout Layout) (string, error) { - // Dereference pointers until the underlying value is reached, letting any - // value in the chain escape through RenderHuman. + // Dereference pointers until the underlying value is reached. value := reflect.ValueOf(data) for value.Kind() == reflect.Pointer { if value.IsNil() { return "-", nil } - if h, ok := reflect.TypeAssert[Renderer](value); ok { - return h.RenderHuman() - } value = value.Elem() } if !value.IsValid() { return "-", nil } - if h, ok := reflect.TypeAssert[Renderer](value); ok { - return h.RenderHuman() - } data = value.Interface() // Fall back to a per-type registered layout when the caller gave none, @@ -152,15 +138,14 @@ func Render(data any, layout Layout) (string, error) { // own section so the flattening of the main block skips them, keeping // declaration order. case reflect.Struct: + // claimed tracks the dotted paths rendered as their own section so + // the flattening of the main block skips them, keeping declaration + // order. Paths are validated when they are resolved below. claimed := make(map[string]bool, len(sections)) for _, section := range sections { - if section.Path == "" { - continue - } - if _, err := valueAtPath(data, section.Path); err != nil { - return "", fmt.Errorf("path %q not found in %T: %w", section.Path, data, err) + if section.Path != "" { + claimed[section.Path] = true } - claimed[section.Path] = true } var b strings.Builder @@ -187,7 +172,7 @@ func Render(data any, layout Layout) (string, error) { return strings.TrimSpace(b.String()), nil } - return "", nil + return Format(data) } // writeSection data to the given string builder, with a map of the already @@ -369,10 +354,7 @@ func walk(value reflect.Value, keys string, f func(keys string, value reflect.Va case reflect.Struct: for i := range value.NumField() { name := value.Type().Field(i).Name - if keys != "" { - name = keys + "." + name - } - err := walk(value.Field(i), name, f) + err := walk(value.Field(i), joinKeys(keys, name), f) if err != nil { return err } @@ -381,12 +363,8 @@ func walk(value reflect.Value, keys string, f func(keys string, value reflect.Va return nil case reflect.Slice, reflect.Array: - prefix := keys - if keys != "" { - prefix += "." - } for i := range value.Len() { - err := walk(value.Index(i), prefix+strconv.Itoa(i), f) + err := walk(value.Index(i), joinKeys(keys, strconv.Itoa(i)), f) if err != nil { return err } @@ -399,12 +377,8 @@ func walk(value reflect.Value, keys string, f func(keys string, value reflect.Va slices.SortFunc(mapKeys, func(i, j reflect.Value) int { return strings.Compare(i.String(), j.String()) }) - prefix := keys - if keys != "" { - prefix += "." - } for _, mapKey := range mapKeys { - err := walk(value.MapIndex(mapKey), prefix+mapKey.String(), f) + err := walk(value.MapIndex(mapKey), joinKeys(keys, mapKey.String()), f) if err != nil { return err } @@ -418,6 +392,15 @@ func walk(value reflect.Value, keys string, f func(keys string, value reflect.Va } } +// joinKeys appends part to keys, dropping the separator at the root. +func joinKeys(keys, part string) string { + if keys == "" { + return part + } + + return keys + "." + part +} + // valueAtPath returns the value at the given dotted path, dereferencing // pointers and interfaces along the way. func valueAtPath(data any, path string) (any, error) { @@ -434,24 +417,26 @@ func valueAtPath(data any, path string) (any, error) { switch value.Kind() { case reflect.Struct: value = value.FieldByName(part) - if !value.IsValid() || !value.CanInterface() { - return nil, fmt.Errorf("path %q not found in %T", path, data) - } case reflect.Slice, reflect.Array: index, err := strconv.Atoi(part) if err != nil || index < 0 || index >= value.Len() { - return nil, fmt.Errorf("path %q not found in %T", path, data) + return nil, pathNotFound(path, data) } value = value.Index(index) case reflect.Map: value = value.MapIndex(reflect.ValueOf(part)) - if !value.IsValid() { - return nil, fmt.Errorf("path %q not found in %T", path, data) - } default: - return nil, fmt.Errorf("path %q not found in %T", path, data) + return nil, pathNotFound(path, data) + } + if !value.IsValid() || !value.CanInterface() { + return nil, pathNotFound(path, data) } } return value.Interface(), nil } + +// pathNotFound reports a dotted path component that does not resolve in data. +func pathNotFound(path string, data any) error { + return fmt.Errorf("path %q not found in %T", path, data) +} diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 47ab1ca044..d3726c1b3b 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -154,14 +154,6 @@ func init() { }) } -type RendererStruct struct { - Foo string -} - -func (RendererStruct) RenderHuman() (string, error) { - return "custom-rendered", nil -} - func TestRender(t *testing.T) { for _, tc := range []struct { name string @@ -247,22 +239,16 @@ Bar 1`, Bar 1`, }, { - name: "renderer value", - data: RendererStruct{Foo: "ignored"}, + name: "formatter value", + data: FormatterStruct{Foo: "ignored"}, layout: human.Layout{}, - want: "custom-rendered", + want: "cell-formatted", }, { - name: "renderer pointer", - data: &RendererStruct{Foo: "ignored"}, + name: "scalar", + data: 42, layout: human.Layout{}, - want: "custom-rendered", - }, - { - name: "renderer nil pointer", - data: (*RendererStruct)(nil), - layout: human.Layout{}, - want: `-`, + want: "42", }, { name: "struct with titled struct section", From 9764778af4a1b5e9689039a765c427b1462f486f Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 12:20:37 +0100 Subject: [PATCH 15/29] split into methods --- core/human/v2/human.go | 359 +++++++++++++++++------------ core/human/v2/human_test.go | 434 ++++++++++++++++++++++++++---------- 2 files changed, 535 insertions(+), 258 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 2affbc3297..e7d7cb66fb 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -89,9 +89,28 @@ func isFormattable(t reflect.Type) bool { } } -// Render data into a specific layout. +// Block is the resolved content of one section, free of any rendering +// concern. It is either a table (Header non-empty, Rows are cells) or a set +// of lines (Header empty, Rows are [key, value] pairs or a single cell). +type Block struct { + Title string + Header []string + Rows [][]string +} + +// Empty reports whether block draws nothing at all. +func (b Block) Empty() bool { + return b.Title == "" && len(b.Header) == 0 && len(b.Rows) == 0 +} + +// Render resolves data into its layout blocks and draws them, separating the +// blocks with a blank line. Pointers are dereferenced at the top level, and a +// zero layout falls back to the layout registered for the concrete type of +// data. func Render(data any, layout Layout) (string, error) { - // Dereference pointers until the underlying value is reached. + // Dereference pointers until the underlying value is reached, so the nil + // shortcut and the concrete type keying the fallback apply to the same + // value. value := reflect.ValueOf(data) for value.Kind() == reflect.Pointer { if value.IsNil() { @@ -104,43 +123,79 @@ func Render(data any, layout Layout) (string, error) { } data = value.Interface() - // Fall back to a per-type registered layout when the caller gave none, - // then apply the default pathless section if needed. if reflect.ValueOf(layout).IsZero() { if l, ok := layouts.Load(value.Type()); ok { layout = l.(Layout) } } - sections := layout.ResolveSections() + + blocks, err := layout.Resolve(data) + if err != nil { + return "", err + } + + var b strings.Builder + for _, block := range blocks { + if block.Empty() { + continue + } + if b.Len() > 0 { + b.WriteString("\n\n") + } + b.WriteString(block.Draw()) + } + + return b.String(), nil +} + +// Resolve splits data into the content blocks of the layout, without any +// rendering. It is exposed so tests and a future layout engine can reuse the +// resolution part of Render. Pointers are dereferenced first, so direct +// callers can pass them too; leaves and nil values fold into single-cell +// blocks. +func (l Layout) Resolve(data any) ([]Block, error) { + // Dereference pointers until the underlying value is reached. + value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer { + if value.IsNil() { + return []Block{{Rows: [][]string{{"-"}}}}, nil + } + value = value.Elem() + } + if !value.IsValid() { + return []Block{{Rows: [][]string{{"-"}}}}, nil + } + data = value.Interface() + + // Apply the default pathless section if needed. + sections := l.ResolveSections() switch value.Kind() { - // If the value is a slice or array, we ensure that the layout is a single - // pathless section and write it. + // A slice or array is a single pathless section, rendered as a table. case reflect.Slice, reflect.Array: if len(sections) != 1 { - return "", fmt.Errorf("found %d sections for %T payload", len(sections), data) + return nil, fmt.Errorf("found %d sections for %T payload", len(sections), data) } if sections[0].Path != "" { - return "", fmt.Errorf("unknown path %q for %T payload", sections[0].Path, data) + return nil, fmt.Errorf("unknown path %q for %T payload", sections[0].Path, data) } - var b strings.Builder - _, err := writeSection(&b, sections[0], data, nil) + block, err := sections[0].Resolve(data, nil) if err != nil { - return "", fmt.Errorf("rendering section: %w", err) + return nil, fmt.Errorf("rendering section: %w", err) + } + if block.Empty() { + return nil, nil } - return b.String(), nil + return []Block{block}, nil - // If the value is a struct, we resolve the sections from the data, then - // split them by section. claimed tracks the dotted paths rendered as their - // own section so the flattening of the main block skips them, keeping - // declaration order. + // A struct resolves into one block per section. claimed tracks the dotted + // paths rendered as their own section so the flattening of the main block + // skips them, keeping declaration order. Paths are validated here, when + // they are resolved. case reflect.Struct: - // claimed tracks the dotted paths rendered as their own section so - // the flattening of the main block skips them, keeping declaration - // order. Paths are validated when they are resolved below. claimed := make(map[string]bool, len(sections)) for _, section := range sections { if section.Path != "" { @@ -148,186 +203,204 @@ func Render(data any, layout Layout) (string, error) { } } - var b strings.Builder + blocks := make([]Block, 0, len(sections)) for i, section := range sections { sectionData := data if section.Path != "" { var err error sectionData, err = valueAtPath(data, section.Path) if err != nil { - return "", fmt.Errorf("path %q not found in %T: %w", section.Path, data, err) + return nil, fmt.Errorf("path %q not found in %T: %w", section.Path, data, err) } } - wrote, err := writeSection(&b, section, sectionData, claimed) + block, err := section.Resolve(sectionData, claimed) if err != nil { - return "", fmt.Errorf("rendering section %d: %w", i, err) + return nil, fmt.Errorf("rendering section %d: %w", i, err) } - // Only separate sections that wrote content, so a skipped omittable - // section leaves no orphan blank line. - if wrote { - b.WriteRune('\n') + if !block.Empty() { + blocks = append(blocks, block) } } - return strings.TrimSpace(b.String()), nil + return blocks, nil } - return Format(data) + // A leaf (a scalar or any other kind) renders inline through its formatter. + str, err := Format(data) + if err != nil { + return nil, err + } + + return []Block{{Rows: [][]string{{str}}}}, nil } -// writeSection data to the given string builder, with a map of the already -// claimed paths so we can skip the extracted sections from being rendered in -// the default section. This is necessary because the section data must be -// rendered in declaration order, so we can't convert the original data to -// a map without having to add additional complexity. It returns whether -// anything was written. -func writeSection(b *strings.Builder, section Section, data any, claimed map[string]bool) (bool, error) { +// Resolve renders section into a block from data, without drawing. claimed +// tracks the dotted paths resolved as their own section so the flattening of +// the default block skips them. An omitted or empty section resolves to an +// empty block, a slice or array to a table, and a struct or map to a list of +// keyed rows. +func (s Section) Resolve(data any, claimed map[string]bool) (Block, error) { value := reflect.ValueOf(data) - if (!value.IsValid() || value.IsZero()) && section.Omittable { - return false, nil + if (!value.IsValid() || value.IsZero()) && s.Omittable { + return Block{}, nil } - if section.Title != "" { - b.WriteString(section.Title) - b.WriteRune(':') - b.WriteRune('\n') - } + block := Block{Title: s.Title} if !value.IsValid() || isFormattable(value.Type()) { str, err := Format(data) if err != nil { - return false, err + return Block{}, err } - b.WriteString(str) + block.Rows = [][]string{{str}} - return true, nil + return block, nil } switch value.Kind() { - // Slices and arrays renders as table. case reflect.Slice, reflect.Array: - itemType := value.Type().Elem() - for itemType.Kind() == reflect.Pointer { - itemType = itemType.Elem() + return s.resolveTable(value, block) + case reflect.Map, reflect.Struct: + return s.resolveList(value, block, claimed) + } + + return block, nil +} + +// resolveTable renders a slice or array section as a table. With no selected +// Fields the item's fields become the columns; values are formatted per cell. +func (s Section) resolveTable(value reflect.Value, block Block) (Block, error) { + itemType := value.Type().Elem() + for itemType.Kind() == reflect.Pointer { + itemType = itemType.Elem() + } + + // Resolve the selected fields. If no field was selected, initialize the + // list with the item's fields. + fields := s.Fields + if len(s.Fields) == 0 { + fields = make([]string, itemType.NumField()) + for fieldIndex := range itemType.NumField() { + fields[fieldIndex] = itemType.Field(fieldIndex).Name } + } - // Resolve the selected fields. If no field was selected, initialize the list - // with the item's fields. - fields := section.Fields - if len(section.Fields) == 0 { - fields = make([]string, itemType.NumField()) - for fieldIndex := range itemType.NumField() { - fields[fieldIndex] = itemType.Field(fieldIndex).Name - } + block.Header = fields + for index := range value.Len() { + item := value.Index(index) + + // Dereference the item's value until it is either nil or not a + // pointer so we can access the underlying's fields. + for (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && !item.IsNil() { + item = item.Elem() } + isNil := item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface - w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) + row := make([]string, len(fields)) for fieldIndex, field := range fields { - if fieldIndex != 0 { - fmt.Fprint(w, "\t") + if isNil { + row[fieldIndex] = "-" + continue } - fmt.Fprint(w, field) - } - fmt.Fprint(w, "\n") - - for index := range value.Len() { - item := value.Index(index) - // Dereference the item's value until it is either nil or not a pointer so - // we can access the underlying's fields. - for (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && !item.IsNil() { - item = item.Elem() + cell, err := valueAtPath(item.Interface(), field) + if err != nil { + return Block{}, err } - isNil := item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface - for fieldIndex, field := range fields { - if fieldIndex != 0 { - fmt.Fprint(w, "\t") - } - - if isNil { - fmt.Fprint(w, "-") - continue - } - - cell, err := valueAtPath(item.Interface(), field) - if err != nil { - return false, err - } - - str, err := Format(cell) - if err != nil { - return false, err - } - - fmt.Fprint(w, str) + str, err := Format(cell) + if err != nil { + return Block{}, err } - fmt.Fprint(w, "\n") + row[fieldIndex] = str } - w.Flush() + block.Rows = append(block.Rows, row) + } - return true, nil + return block, nil +} - // A struct and a map render the same: one walk flattens composites into - // dotted rows, renders formattable leaves in place, and skips paths claimed - // by other sections. Maps are just walked as keyed leaves. - case reflect.Map, reflect.Struct: - w := tabwriter.NewWriter(b, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) - err := walk(value, "", func(keys string, node reflect.Value) (bool, error) { - if claimed[keys] { - return false, nil - } +// resolveList renders a struct or map section into dotted keyed rows: one walk +// flattens composite values into dotted keys, formats formattable leaves in +// place, and skips paths claimed by other sections. Maps are just walked as +// keyed leaves. +func (s Section) resolveList(value reflect.Value, block Block, claimed map[string]bool) (Block, error) { + err := walk(value, "", func(keys string, node reflect.Value) (bool, error) { + if claimed[keys] { + return false, nil + } - // Field selection keeps only the selected subtrees: a node above - // one still descends, a branch without selection is pruned. - if len(section.Fields) > 0 && keys != "" { - under := false - for _, f := range section.Fields { - if keys == f || strings.HasPrefix(keys, f+".") { - under = true - break - } + // Field selection keeps only the selected subtrees: a node above + // one still descends, a branch without selection is pruned. + if len(s.Fields) > 0 && keys != "" { + under := false + for _, f := range s.Fields { + if keys == f || strings.HasPrefix(keys, f+".") { + under = true + break } - if !under { - for _, f := range section.Fields { - if strings.HasPrefix(f, keys+".") { - return true, nil - } + } + if !under { + for _, f := range s.Fields { + if strings.HasPrefix(f, keys+".") { + return true, nil } - - return false, nil } - } - // Scalars, formatter structs (and pointers to them) render in place. - // Everything else descends into dotted leaves. isFormattable reports - // scalar slices as inline for section bodies, but a walk always - // flattens slices into indexed rows. - t := node.Type() - for t.Kind() == reflect.Pointer { - t = t.Elem() + return false, nil } - if isFormattable(node.Type()) && t.Kind() != reflect.Slice && t.Kind() != reflect.Array { - str, err := Format(node.Interface()) - if err != nil { - return false, err - } - fmt.Fprintf(w, "%s\t%s\n", keys, str) + } - return false, nil + // Scalars, formatter structs (and pointers to them) render in place. + // Everything else descends into dotted leaves. isFormattable reports + // scalar slices as inline for section bodies, but a walk always + // flattens slices into indexed rows. + t := node.Type() + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if isFormattable(node.Type()) && t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + str, err := Format(node.Interface()) + if err != nil { + return false, err } + block.Rows = append(block.Rows, []string{keys, str}) - return true, nil - }) - if err != nil { - return false, err + return false, nil } - w.Flush() return true, nil + }) + if err != nil { + return Block{}, err + } + + return block, nil +} + +// Draw renders the block to text: an optional title line followed by its +// lines or table, aligned to columns. It is the layout hook of the package: +// column alignment today, terminal-width shrinking and lipgloss styling +// later, so swapping the layout engine only touches this method. An empty +// block draws nothing, and accumulated whitespace is trimmed. +func (b Block) Draw() string { + var s strings.Builder + if b.Title != "" { + s.WriteString(b.Title) + s.WriteRune(':') + s.WriteRune('\n') + } + + w := tabwriter.NewWriter(&s, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) + if len(b.Header) > 0 { + fmt.Fprintln(w, strings.Join(b.Header, "\t")) + } + for _, row := range b.Rows { + fmt.Fprintln(w, strings.Join(row, "\t")) } + w.Flush() - return false, nil + return strings.TrimSpace(s.String()) } // walk represents value and its nodes to f; f installs its own walk logic: diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index d3726c1b3b..2fd2588ec8 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestLayout_resolveSections(t *testing.T) { +func TestLayout_ResolveSections(t *testing.T) { for _, tc := range []struct { name string layout human.Layout @@ -154,12 +154,12 @@ func init() { }) } -func TestRender(t *testing.T) { +func TestLayout_Resolve(t *testing.T) { for _, tc := range []struct { name string data any layout human.Layout - want string + want []human.Block wantErr string // substring of the expected error, empty to expect success }{ { @@ -168,10 +168,12 @@ func TestRender(t *testing.T) { {Foo: "foo", Bar: 1}, {Foo: "bar", Bar: 2}, }, - want: `Foo Bar -foo 1 -bar 2 -`, + want: []human.Block{ + { + Header: []string{"Foo", "Bar"}, + Rows: [][]string{{"foo", "1"}, {"bar", "2"}}, + }, + }, }, { name: "slice of pointer to struct", @@ -179,10 +181,12 @@ bar 2 {FirstLevelFieldA: "a", FirstLevelFieldB: "b"}, {FirstLevelFieldA: "c", FirstLevelFieldB: "d"}, }, - want: `FirstLevelFieldA FirstLevelFieldB -a b -c d -`, + want: []human.Block{ + { + Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + Rows: [][]string{{"a", "b"}, {"c", "d"}}, + }, + }, }, { name: "slice with nil struct item", @@ -190,65 +194,73 @@ c d nil, {FirstLevelFieldA: "c", FirstLevelFieldB: "d"}, }, - want: `FirstLevelFieldA FirstLevelFieldB -- - -c d -`, + want: []human.Block{ + { + Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + Rows: [][]string{{"-", "-"}, {"c", "d"}}, + }, + }, }, { name: "empty string cell formatted", data: []DummyStruct{ {Foo: "", Bar: 1}, }, - want: `Foo Bar -- 1 -`, + want: []human.Block{ + { + Header: []string{"Foo", "Bar"}, + Rows: [][]string{{"-", "1"}}, + }, + }, }, { name: "struct default layout", data: DummyStruct{Foo: "foo", Bar: 1}, // no explicit section: only the default pathless section. layout: human.Layout{}, - want: `Foo foo -Bar 1`, + want: []human.Block{ + {Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}}, + }, }, { name: "pointer to struct", data: &DummyStruct{Foo: "foo", Bar: 1}, layout: human.Layout{}, - want: `Foo foo -Bar 1`, + want: []human.Block{ + {Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}}, + }, }, { name: "nil pointer", data: (*DummyStruct)(nil), layout: human.Layout{}, - want: `-`, + want: []human.Block{{Rows: [][]string{{"-"}}}}, }, { name: "nil", data: nil, layout: human.Layout{}, - want: `-`, + want: []human.Block{{Rows: [][]string{{"-"}}}}, }, { name: "multi-level pointer to struct", data: new(new(&DummyStruct{Foo: "foo", Bar: 1})), layout: human.Layout{}, - want: `Foo foo -Bar 1`, + want: []human.Block{ + {Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}}, + }, }, { name: "formatter value", data: FormatterStruct{Foo: "ignored"}, layout: human.Layout{}, - want: "cell-formatted", + want: []human.Block{{Rows: [][]string{{"cell-formatted"}}}}, }, { name: "scalar", data: 42, layout: human.Layout{}, - want: "42", + want: []human.Block{{Rows: [][]string{{"42"}}}}, }, { name: "struct with titled struct section", @@ -265,12 +277,21 @@ Bar 1`, {Path: "FirstLevelSection", Title: "First Level"}, }, }, - want: `RootLevelFieldA AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA -RootLevelFieldB aaaaaaaa-aaaaaa-aaaaaa - -First Level: -FirstLevelFieldA BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB -FirstLevelFieldB BBBBB`, + want: []human.Block{ + { + Rows: [][]string{ + {"RootLevelFieldA", "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"}, + {"RootLevelFieldB", "aaaaaaaa-aaaaaa-aaaaaa"}, + }, + }, + { + Title: "First Level", + Rows: [][]string{ + {"FirstLevelFieldA", "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"}, + {"FirstLevelFieldB", "BBBBB"}, + }, + }, + }, }, { name: "omittable empty section", @@ -281,10 +302,11 @@ FirstLevelFieldB BBBBB`, {Path: "RequiredList", Title: "Required List", Omittable: false}, }, }, - // The omittable section is empty so it is skipped, the required one + // The omittable section is empty so it is dropped, the required one // keeps its header-only table. - want: `Required List: -FirstLevelFieldA FirstLevelFieldB`, + want: []human.Block{ + {Title: "Required List", Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}}, + }, }, { name: "omittable non-empty section", @@ -299,12 +321,14 @@ FirstLevelFieldA FirstLevelFieldB`, {Path: "RequiredList", Title: "Required List", Omittable: false}, }, }, - want: `Omittable List: -FirstLevelFieldA FirstLevelFieldB -x y - -Required List: -FirstLevelFieldA FirstLevelFieldB`, + want: []human.Block{ + { + Title: "Omittable List", + Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + Rows: [][]string{{"x", "y"}}, + }, + {Title: "Required List", Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}}, + }, }, { name: "scalar slice section", @@ -316,8 +340,9 @@ FirstLevelFieldA FirstLevelFieldB`, {Path: "Fields", Title: "Allowed"}, }, }, - want: `Allowed: -[poweron backup]`, + want: []human.Block{ + {Title: "Allowed", Rows: [][]string{{"[poweron backup]"}}}, + }, }, { name: "map section", @@ -329,9 +354,9 @@ FirstLevelFieldA FirstLevelFieldB`, {Path: "Fields", Title: "Fields"}, }, }, - want: `Fields: -a one -b two`, + want: []human.Block{ + {Title: "Fields", Rows: [][]string{{"a", "one"}, {"b", "two"}}}, + }, }, { name: "map section with slice value", @@ -343,10 +368,9 @@ b two`, {Path: "Fields", Title: "Fields"}, }, }, - want: `Fields: -a.0 one -b.0 y -b.1 x`, + want: []human.Block{ + {Title: "Fields", Rows: [][]string{{"a.0", "one"}, {"b.0", "y"}, {"b.1", "x"}}}, + }, }, { name: "flattened struct fields", @@ -356,10 +380,9 @@ b.1 x`, RootB: "bbb", }, layout: human.Layout{}, - want: `RootA aaa -Sub.X one -Sub.Y two -RootB bbb`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}, {"Sub.Y", "two"}, {"RootB", "bbb"}}}, + }, }, { name: "omittable skipped between two sections", @@ -374,12 +397,11 @@ RootB bbb`, {Path: "RootB", Title: "C"}, }, }, - // The skipped omittable section must not leave an orphan blank line - // between the two rendered ones. - want: `RootA aaa - -C: -bbb`, + // The skipped omittable section leaves no block at all. + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}}}, + {Title: "C", Rows: [][]string{{"bbb"}}}, + }, }, { name: "dotted section path", @@ -395,12 +417,10 @@ bbb`, }, // Sub.Y is claimed by the section, Sub.X keeps flattening into // the main block. - want: `RootA aaa -Sub.X one -RootB bbb - -Y: -two`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}, {"RootB", "bbb"}}}, + {Title: "Y", Rows: [][]string{{"two"}}}, + }, }, { name: "nested formatter struct rendered inline", @@ -409,8 +429,9 @@ two`, Sub: FormatterStruct{Foo: "ignored"}, }, layout: human.Layout{}, - want: `RootA aaa -Sub cell-formatted`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub", "cell-formatted"}}}, + }, }, { name: "nested pointer-receiver formatter rendered inline", @@ -419,8 +440,9 @@ Sub cell-formatted`, Sub: &pointerRecvFormatter{Foo: "x"}, }, layout: human.Layout{}, - want: `RootA aaa -Sub pointer-formatted`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub", "pointer-formatted"}}}, + }, }, { name: "dotted section path for a whole struct", @@ -436,12 +458,10 @@ Sub pointer-formatted`, }, // Claiming the whole Sub struct also prunes its leaves from the main // block, not just the exact dotted path. - want: `RootA aaa -RootB bbb - -Sub: -X one -Y two`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"RootB", "bbb"}}}, + {Title: "Sub", Rows: [][]string{{"X", "one"}, {"Y", "two"}}}, + }, }, { name: "flattened slice fields", @@ -450,9 +470,9 @@ Y two`, Strings: []string{"s1", "s2"}, }, layout: human.Layout{}, - want: `RootA aaa -Strings.0 s1 -Strings.1 s2`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Strings.0", "s1"}, {"Strings.1", "s2"}}}, + }, }, { name: "flattened map fields", @@ -461,9 +481,9 @@ Strings.1 s2`, Labels: map[string]string{"b": "two", "a": "one"}, }, layout: human.Layout{}, - want: `RootA aaa -Labels.a one -Labels.b two`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Labels.a", "one"}, {"Labels.b", "two"}}}, + }, }, { name: "nil composite field dropped", @@ -471,21 +491,9 @@ Labels.b two`, RootA: "aaa", }, layout: human.Layout{}, - want: `RootA aaa`, - }, - { - name: "registry layout", - data: RegistryDummyStruct{ - RegistryField: "value", - RegistrySection: FirstLevelDummyStruct{FirstLevelFieldA: "aaa", FirstLevelFieldB: "bbb"}, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}}}, }, - // No layout given: the one registered for the type applies. - layout: human.Layout{}, - want: `RegistryField value - -Custom: -FirstLevelFieldA aaa -FirstLevelFieldB bbb`, }, { name: "flat table with fields", @@ -498,10 +506,9 @@ FirstLevelFieldB bbb`, {Fields: []string{"Bar"}}, }, }, - want: `Bar -1 -2 -`, + want: []human.Block{ + {Header: []string{"Bar"}, Rows: [][]string{{"1"}, {"2"}}}, + }, }, { name: "table column with dotted path", @@ -513,9 +520,9 @@ FirstLevelFieldB bbb`, {Fields: []string{"RootA", "Sub.X"}}, }, }, - want: `RootA Sub.X -a x -`, + want: []human.Block{ + {Header: []string{"RootA", "Sub.X"}, Rows: [][]string{{"a", "x"}}}, + }, }, { name: "struct lines filtered", @@ -529,8 +536,9 @@ a x {Fields: []string{"RootA", "Sub.X"}}, }, }, - want: `RootA aaa -Sub.X one`, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}}}, + }, }, { name: "selected composite subtree flattens", @@ -543,8 +551,9 @@ Sub.X one`, {Fields: []string{"Strings"}}, }, }, - want: `Strings.0 s1 -Strings.1 s2`, + want: []human.Block{ + {Rows: [][]string{{"Strings.0", "s1"}, {"Strings.1", "s2"}}}, + }, }, { name: "explicit layout overrides registry", @@ -557,15 +566,14 @@ Strings.1 s2`, {Path: "RegistrySection", Title: "Explicit"}, }, }, - want: `RegistryField value - -Explicit: -FirstLevelFieldA aaa -FirstLevelFieldB bbb`, + want: []human.Block{ + {Rows: [][]string{{"RegistryField", "value"}}}, + {Title: "Explicit", Rows: [][]string{{"FirstLevelFieldA", "aaa"}, {"FirstLevelFieldB", "bbb"}}}, + }, }, } { t.Run(tc.name, func(t *testing.T) { - got, err := human.Render(tc.data, tc.layout) + got, err := tc.layout.Resolve(tc.data) if tc.wantErr != "" { require.Error(t, err) @@ -579,3 +587,199 @@ FirstLevelFieldB bbb`, }) } } + +func TestBlock_Draw(t *testing.T) { + for _, tc := range []struct { + name string + block human.Block + want string + }{ + { + name: "table", + block: human.Block{ + Header: []string{"Foo", "Bar"}, + Rows: [][]string{{"foo", "1"}, {"bar", "2"}}, + }, + want: `Foo Bar +foo 1 +bar 2`, + }, + { + name: "key value lines", + block: human.Block{ + Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}, + }, + want: `Foo foo +Bar 1`, + }, + { + name: "title and table", + block: human.Block{ + Title: "Required List", + Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + }, + want: `Required List: +FirstLevelFieldA FirstLevelFieldB`, + }, + { + name: "title and lines", + block: human.Block{ + Title: "Fields", + Rows: [][]string{{"a", "one"}, {"b", "two"}}, + }, + want: `Fields: +a one +b two`, + }, + { + name: "single cell", + block: human.Block{ + Rows: [][]string{{"-"}}, + }, + want: `-`, + }, + { + name: "empty block draws nothing", + block: human.Block{}, + want: ``, + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, tc.block.Draw()) + }) + } +} + +func TestRender(t *testing.T) { + for _, tc := range []struct { + name string + data any + layout human.Layout + want string + }{ + { + name: "nil", + data: nil, + layout: human.Layout{}, + want: "-", + }, + { + name: "nil pointer", + data: (*DummyStruct)(nil), + layout: human.Layout{}, + want: "-", + }, + { + name: "pointer to struct", + data: &DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "multi-level pointer to struct", + data: new(new(&DummyStruct{Foo: "foo", Bar: 1})), + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "scalar", + data: 42, + layout: human.Layout{}, + want: "42", + }, + { + name: "table", + data: []DummyStruct{ + {Foo: "foo", Bar: 1}, + {Foo: "bar", Bar: 2}, + }, + layout: human.Layout{}, + want: `Foo Bar +foo 1 +bar 2`, + }, + { + name: "plain struct", + data: DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "registry layout fallback", + data: RegistryDummyStruct{ + RegistryField: "value", + RegistrySection: FirstLevelDummyStruct{FirstLevelFieldA: "aaa", FirstLevelFieldB: "bbb"}, + }, + // Zero layout: Render falls back to the one registered for the type. + layout: human.Layout{}, + want: `RegistryField value + +Custom: +FirstLevelFieldA aaa +FirstLevelFieldB bbb`, + }, + { + name: "joined sections", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "Sub"}, + }, + }, + want: `RootA aaa +RootB bbb + +Sub: +X one +Y two`, + }, + { + name: "scalar sections joined with blank line", + data: DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Foo", Title: "A"}, + {Path: "Bar", Title: "B"}, + }, + }, + // v1 separates consecutive scalar sections with a blank line too. + want: `A: +foo + +B: +1`, + }, + { + name: "omittable skipped between two sections", + data: RootDummyStruct{ + RootA: "aaa", + // Sub zero -> omitted + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "B", Omittable: true}, + {Path: "RootB", Title: "C"}, + }, + }, + // The skipped omittable section leaves no orphan blank line. + want: `RootA aaa + +C: +bbb`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := human.Render(tc.data, tc.layout) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} From 54f11e41f0ceaba7c9421894a574707331a8c5bc Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 12:52:31 +0100 Subject: [PATCH 16/29] labels --- core/human/v2/human.go | 41 +++++++++++++++++++++++++++++------ core/human/v2/human_test.go | 43 +++++++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index e7d7cb66fb..4d7ec05015 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/scaleway/scaleway-cli/v2/internal/tabwriter" + "github.com/scaleway/scaleway-sdk-go/strcase" ) // Layout describes the structure of the rendered data. Sections define @@ -34,7 +35,29 @@ type Section struct { // Fields limits the section to the selected dotted field paths: for a // table it selects the columns, for a struct or map it selects the lines. // Empty means all fields. - Fields []string + Fields []Field +} + +// Field selects one column of a table or one line of a struct or map. +type Field struct { + // Path is the dotted path of the field in the data. + Path string + + // Label overrides the derived column header. Empty derives it from Path. + Label string +} + +// getLabel returns the field's display label: the explicit Label if set, +// otherwise the Path derived like v1 (bash-arg spaced and uppercased). +func (f Field) getLabel() string { + if f.Label != "" { + return f.Label + } + + label := strings.ReplaceAll(f.Path, ".", " ") + label = strcase.ToBashArg(label) + label = strings.ReplaceAll(label, "-", " ") + return strings.ToUpper(label) } // ResolveSections return the final list of sections, with any default applied @@ -279,13 +302,17 @@ func (s Section) resolveTable(value reflect.Value, block Block) (Block, error) { // list with the item's fields. fields := s.Fields if len(s.Fields) == 0 { - fields = make([]string, itemType.NumField()) + fields = make([]Field, itemType.NumField()) for fieldIndex := range itemType.NumField() { - fields[fieldIndex] = itemType.Field(fieldIndex).Name + fields[fieldIndex] = Field{Path: itemType.Field(fieldIndex).Name} } } - block.Header = fields + block.Header = make([]string, len(fields)) + for fieldIndex, field := range fields { + block.Header[fieldIndex] = field.getLabel() + } + for index := range value.Len() { item := value.Index(index) @@ -303,7 +330,7 @@ func (s Section) resolveTable(value reflect.Value, block Block) (Block, error) { continue } - cell, err := valueAtPath(item.Interface(), field) + cell, err := valueAtPath(item.Interface(), field.Path) if err != nil { return Block{}, err } @@ -335,14 +362,14 @@ func (s Section) resolveList(value reflect.Value, block Block, claimed map[strin if len(s.Fields) > 0 && keys != "" { under := false for _, f := range s.Fields { - if keys == f || strings.HasPrefix(keys, f+".") { + if keys == f.Path || strings.HasPrefix(keys, f.Path+".") { under = true break } } if !under { for _, f := range s.Fields { - if strings.HasPrefix(f, keys+".") { + if strings.HasPrefix(f.Path, keys+".") { return true, nil } } diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 2fd2588ec8..a3fc30afef 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -170,7 +170,7 @@ func TestLayout_Resolve(t *testing.T) { }, want: []human.Block{ { - Header: []string{"Foo", "Bar"}, + Header: []string{"FOO", "BAR"}, Rows: [][]string{{"foo", "1"}, {"bar", "2"}}, }, }, @@ -183,7 +183,7 @@ func TestLayout_Resolve(t *testing.T) { }, want: []human.Block{ { - Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}, Rows: [][]string{{"a", "b"}, {"c", "d"}}, }, }, @@ -196,7 +196,7 @@ func TestLayout_Resolve(t *testing.T) { }, want: []human.Block{ { - Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}, Rows: [][]string{{"-", "-"}, {"c", "d"}}, }, }, @@ -208,7 +208,7 @@ func TestLayout_Resolve(t *testing.T) { }, want: []human.Block{ { - Header: []string{"Foo", "Bar"}, + Header: []string{"FOO", "BAR"}, Rows: [][]string{{"-", "1"}}, }, }, @@ -305,7 +305,7 @@ func TestLayout_Resolve(t *testing.T) { // The omittable section is empty so it is dropped, the required one // keeps its header-only table. want: []human.Block{ - {Title: "Required List", Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}}, + {Title: "Required List", Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}}, }, }, { @@ -324,10 +324,10 @@ func TestLayout_Resolve(t *testing.T) { want: []human.Block{ { Title: "Omittable List", - Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}, Rows: [][]string{{"x", "y"}}, }, - {Title: "Required List", Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}}, + {Title: "Required List", Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}}, }, }, { @@ -503,11 +503,26 @@ func TestLayout_Resolve(t *testing.T) { }, layout: human.Layout{ Sections: []human.Section{ - {Fields: []string{"Bar"}}, + {Fields: []human.Field{{Path: "Bar"}}}, }, }, want: []human.Block{ - {Header: []string{"Bar"}, Rows: [][]string{{"1"}, {"2"}}}, + {Header: []string{"BAR"}, Rows: [][]string{{"1"}, {"2"}}}, + }, + }, + { + name: "table column with explicit label", + data: []DummyStruct{ + {Foo: "f", Bar: 1}, + {Foo: "b", Bar: 2}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Bar", Label: "Size"}}}, + }, + }, + want: []human.Block{ + {Header: []string{"Size"}, Rows: [][]string{{"1"}, {"2"}}}, }, }, { @@ -517,11 +532,11 @@ func TestLayout_Resolve(t *testing.T) { }, layout: human.Layout{ Sections: []human.Section{ - {Fields: []string{"RootA", "Sub.X"}}, + {Fields: []human.Field{{Path: "RootA"}, {Path: "Sub.X"}}}, }, }, want: []human.Block{ - {Header: []string{"RootA", "Sub.X"}, Rows: [][]string{{"a", "x"}}}, + {Header: []string{"ROOT A", "SUB X"}, Rows: [][]string{{"a", "x"}}}, }, }, { @@ -533,7 +548,7 @@ func TestLayout_Resolve(t *testing.T) { }, layout: human.Layout{ Sections: []human.Section{ - {Fields: []string{"RootA", "Sub.X"}}, + {Fields: []human.Field{{Path: "RootA"}, {Path: "Sub.X"}}}, }, }, want: []human.Block{ @@ -548,7 +563,7 @@ func TestLayout_Resolve(t *testing.T) { }, layout: human.Layout{ Sections: []human.Section{ - {Fields: []string{"Strings"}}, + {Fields: []human.Field{{Path: "Strings"}}}, }, }, want: []human.Block{ @@ -696,7 +711,7 @@ Bar 1`, {Foo: "bar", Bar: 2}, }, layout: human.Layout{}, - want: `Foo Bar + want: `FOO BAR foo 1 bar 2`, }, From e059c2b1bf7cd39f07b321903d52698589a57bb8 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 13:06:57 +0100 Subject: [PATCH 17/29] layout title --- core/human/v2/human.go | 8 ++++++++ core/human/v2/human_test.go | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 4d7ec05015..8fe186d45e 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -18,6 +18,10 @@ import ( // an empty path and no title by default. Sections order is conserved, with // the main section being prepended if it isn't found in the list. type Layout struct { + // Title, when set, is rendered on its own line above the whole output, + // wrapping all sections like v1's MarshalOpt.Title. + Title string + Sections []Section } @@ -168,6 +172,10 @@ func Render(data any, layout Layout) (string, error) { b.WriteString(block.Draw()) } + if layout.Title != "" { + return layout.Title + ":\n" + b.String(), nil + } + return b.String(), nil } diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index a3fc30afef..aae2cc454e 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -790,6 +790,46 @@ B: C: bbb`, }, + { + name: "layout title before main section", + data: DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{ + Title: "Policy", + }, + want: `Policy: +Foo foo +Bar 1`, + }, + { + name: "layout title wraps sections", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Title: "Details", + Sections: []human.Section{ + {Path: "Sub", Title: "Sub"}, + }, + }, + want: `Details: +RootA aaa +RootB bbb + +Sub: +X one +Y two`, + }, + { + name: "layout title wraps leaf", + data: "text", + layout: human.Layout{ + Title: "Details", + }, + want: `Details: +text`, + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Render(tc.data, tc.layout) From 8681578d9e2818ebe389e540ed67b1acfd47f0b9 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 13:32:57 +0100 Subject: [PATCH 18/29] unknown fields error --- core/human/v2/error.go | 14 ++++++ core/human/v2/human.go | 93 ++++++++++++++++++++++++++++++++++++- core/human/v2/human_test.go | 55 ++++++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 core/human/v2/error.go diff --git a/core/human/v2/error.go b/core/human/v2/error.go new file mode 100644 index 0000000000..1c9d12c2d3 --- /dev/null +++ b/core/human/v2/error.go @@ -0,0 +1,14 @@ +package human + +import "fmt" + +// UnknownFieldError is returned when a selected field path does not exist in +// the data, so callers like the printer can suggest the valid fields. +type UnknownFieldError struct { + FieldName string + ValidFields []string +} + +func (e *UnknownFieldError) Error() string { + return fmt.Sprintf("unknown field %s, valid fields are: %v", e.FieldName, e.ValidFields) +} diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 8fe186d45e..981de72edd 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -316,6 +316,12 @@ func (s Section) resolveTable(value reflect.Value, block Block) (Block, error) { } } + if itemType.Kind() == reflect.Struct { + if err := validateFields(itemType, fields); err != nil { + return Block{}, err + } + } + block.Header = make([]string, len(fields)) for fieldIndex, field := range fields { block.Header[fieldIndex] = field.getLabel() @@ -340,7 +346,11 @@ func (s Section) resolveTable(value reflect.Value, block Block) (Block, error) { cell, err := valueAtPath(item.Interface(), field.Path) if err != nil { - return Block{}, err + // Field paths are pre-validated, so only per-item missing values + // (nil intermediate, absent map key) land here: degrade to "-" + // like v1 instead of failing the whole table. + row[fieldIndex] = "-" + continue } str, err := Format(cell) @@ -360,6 +370,16 @@ func (s Section) resolveTable(value reflect.Value, block Block) (Block, error) { // place, and skips paths claimed by other sections. Maps are just walked as // keyed leaves. func (s Section) resolveList(value reflect.Value, block Block, claimed map[string]bool) (Block, error) { + valueType := value.Type() + for valueType.Kind() == reflect.Pointer { + valueType = valueType.Elem() + } + if valueType.Kind() == reflect.Struct { + if err := validateFields(valueType, s.Fields); err != nil { + return Block{}, err + } + } + err := walk(value, "", func(keys string, node reflect.Value) (bool, error) { if claimed[keys] { return false, nil @@ -413,6 +433,77 @@ func (s Section) resolveList(value reflect.Value, block Block, claimed map[strin return block, nil } +// validateFields returns an UnknownFieldError for the first selected field +// whose path does not resolve through fields of t. +func validateFields(t reflect.Type, fields []Field) error { + for _, field := range fields { + if !hasField(t, field.Path) { + return &UnknownFieldError{FieldName: field.Path, ValidFields: listFields(t)} + } + } + + return nil +} + +// hasField reports whether the dotted path resolves through t's fields. +func hasField(t reflect.Type, path string) bool { + for part := range strings.SplitSeq(path, ".") { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return false + } + + field, ok := t.FieldByName(part) + if !ok { + return false + } + t = field.Type + } + + return true +} + +// listFields returns the selectable dotted field paths of t: structs recurse, +// other leaves (scalars, slices, maps) contribute their own path. +func listFields(t reflect.Type) []string { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + res := []string(nil) + collectFields(t, "", &res) + + return res +} + +func collectFields(t reflect.Type, prefix string, res *[]string) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if t.Kind() == reflect.Struct { + for field := range t.Fields() { + if field.PkgPath != "" { + continue + } + + parent := prefix + if !field.Anonymous { + parent = joinKeys(prefix, field.Name) + } + collectFields(field.Type, parent, res) + } + + return + } + + if prefix != "" { + *res = append(*res, prefix) + } +} + // Draw renders the block to text: an optional title line followed by its // lines or table, aligned to columns. It is the layout hook of the package: // column alignment today, terminal-width shrinking and lipgloss styling diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index aae2cc454e..6511efe1dc 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -539,6 +539,22 @@ func TestLayout_Resolve(t *testing.T) { {Header: []string{"ROOT A", "SUB X"}, Rows: [][]string{{"a", "x"}}}, }, }, + { + name: "table column with nil intermediate", + data: []NestedPointerFormatterDummyStruct{ + {RootA: "a"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Sub.Foo"}}}, + }, + }, + // The path is valid, but this item's Sub is nil: the cell degrades + // to "-" instead of failing the whole table, like v1. + want: []human.Block{ + {Header: []string{"SUB FOO"}, Rows: [][]string{{"-"}}}, + }, + }, { name: "struct lines filtered", data: RootDummyStruct{ @@ -603,6 +619,45 @@ func TestLayout_Resolve(t *testing.T) { } } +func TestResolveUnknownField(t *testing.T) { + for _, tc := range []struct { + name string + data any + layout human.Layout + }{ + { + name: "table column", + data: []DummyStruct{{Foo: "f", Bar: 1}}, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Nope"}}}, + }, + }, + }, + { + name: "struct line", + data: DummyStruct{Foo: "f", Bar: 1}, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Nope"}}}, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.layout.Resolve(tc.data) + require.Error(t, err) + + unknownFieldErr := &human.UnknownFieldError{} + require.ErrorAs(t, err, &unknownFieldErr) + require.Equal(t, "Nope", unknownFieldErr.FieldName) + require.Contains(t, unknownFieldErr.ValidFields, "Foo") + require.Contains(t, unknownFieldErr.ValidFields, "Bar") + require.NotContains(t, unknownFieldErr.ValidFields, "Nope") + }) + } +} + func TestBlock_Draw(t *testing.T) { for _, tc := range []struct { name string From 2507e3b5a25b0d9bfd0d3a000c66bcb72adb83b2 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 13:54:42 +0100 Subject: [PATCH 19/29] no header switch --- core/human/v2/human.go | 12 +++++++++++- core/human/v2/human_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 981de72edd..5939ea71dd 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -22,6 +22,10 @@ type Layout struct { // wrapping all sections like v1's MarshalOpt.Title. Title string + // NoHeaders skips the header row of table blocks, like v1's + // MarshalOpt.NoHeaders. + NoHeaders bool + Sections []Section } @@ -123,6 +127,9 @@ type Block struct { Title string Header []string Rows [][]string + + // NoHeaders skips the header row when drawing a table block. + NoHeaders bool } // Empty reports whether block draws nothing at all. @@ -220,6 +227,8 @@ func (l Layout) Resolve(data any) ([]Block, error) { return nil, nil } + block.NoHeaders = l.NoHeaders + return []Block{block}, nil // A struct resolves into one block per section. claimed tracks the dotted @@ -249,6 +258,7 @@ func (l Layout) Resolve(data any) ([]Block, error) { return nil, fmt.Errorf("rendering section %d: %w", i, err) } if !block.Empty() { + block.NoHeaders = l.NoHeaders blocks = append(blocks, block) } } @@ -518,7 +528,7 @@ func (b Block) Draw() string { } w := tabwriter.NewWriter(&s, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) - if len(b.Header) > 0 { + if len(b.Header) > 0 && !b.NoHeaders { fmt.Fprintln(w, strings.Join(b.Header, "\t")) } for _, row := range b.Rows { diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 6511efe1dc..bf55a5df4f 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -175,6 +175,18 @@ func TestLayout_Resolve(t *testing.T) { }, }, }, + { + name: "simple struct without headers", + data: []DummyStruct{{Foo: "foo", Bar: 1}}, + layout: human.Layout{NoHeaders: true}, + want: []human.Block{ + { + Header: []string{"FOO", "BAR"}, + Rows: [][]string{{"foo", "1"}}, + NoHeaders: true, + }, + }, + }, { name: "slice of pointer to struct", data: []*FirstLevelDummyStruct{ @@ -708,6 +720,16 @@ b two`, }, want: `-`, }, + { + name: "table without headers", + block: human.Block{ + Header: []string{"Foo", "Bar"}, + Rows: [][]string{{"foo", "1"}, {"bar", "2"}}, + NoHeaders: true, + }, + want: `foo 1 +bar 2`, + }, { name: "empty block draws nothing", block: human.Block{}, @@ -759,6 +781,16 @@ Bar 1`, layout: human.Layout{}, want: "42", }, + { + name: "table without headers", + data: []DummyStruct{ + {Foo: "foo", Bar: 1}, + {Foo: "bar", Bar: 2}, + }, + layout: human.Layout{NoHeaders: true}, + want: `foo 1 +bar 2`, + }, { name: "table", data: []DummyStruct{ From 2241b029dfdc3e6a390ce77d92709b0d9e1a4ea3 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 14:04:10 +0100 Subject: [PATCH 20/29] title derivation --- core/human/v2/human.go | 22 +++++++++++++++++++++- core/human/v2/human_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 5939ea71dd..c1ed6f62a7 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -10,6 +10,8 @@ import ( "github.com/scaleway/scaleway-cli/v2/internal/tabwriter" "github.com/scaleway/scaleway-sdk-go/strcase" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // Layout describes the structure of the rendered data. Sections define @@ -62,9 +64,16 @@ func (f Field) getLabel() string { return f.Label } - label := strings.ReplaceAll(f.Path, ".", " ") + return deriveLabel(f.Path) +} + +// deriveLabel renders a path as an ALL-CAPS column header, mirroring v1's +// getDefaultFieldsOpt label derivation. +func deriveLabel(path string) string { + label := strings.ReplaceAll(path, ".", " ") label = strcase.ToBashArg(label) label = strings.ReplaceAll(label, "-", " ") + return strings.ToUpper(label) } @@ -287,6 +296,9 @@ func (s Section) Resolve(data any, claimed map[string]bool) (Block, error) { } block := Block{Title: s.Title} + if block.Title == "" && s.Path != "" { + block.Title = deriveTitle(s.Path) + } if !value.IsValid() || isFormattable(value.Type()) { str, err := Format(data) @@ -514,6 +526,14 @@ func collectFields(t reflect.Type, prefix string, res *[]string) { } } +// deriveTitle renders a section's dotted path as a titled section header, +// mirroring v1's marshalSection title derivation. +func deriveTitle(path string) string { + title := strings.ReplaceAll(strcase.ToBashArg(path), "-", " ") + + return cases.Title(language.English).String(strings.ReplaceAll(title, ".", " - ")) +} + // Draw renders the block to text: an optional title line followed by its // lines or table, aligned to columns. It is the layout hook of the package: // column alignment today, terminal-width shrinking and lipgloss styling diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index bf55a5df4f..5b78259edd 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -434,6 +434,42 @@ func TestLayout_Resolve(t *testing.T) { {Title: "Y", Rows: [][]string{{"two"}}}, }, }, + { + name: "section derives title from path", + data: RootDummyStruct{ + RootA: "aaa", + RootB: "bbb", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "RootB"}, // no Title: derived like v1 + }, + }, + // Layout.ResolveSections prepends the main block; only the selected + // path is claimed, the rest flattens into it. + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}, {"Sub.Y", "two"}}}, + {Title: "Root B", Rows: [][]string{{"bbb"}}}, + }, + }, + { + name: "section derives dotted title from path", + data: RootDummyStruct{ + RootA: "aaa", + RootB: "bbb", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub.X"}, + }, + }, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.Y", "two"}, {"RootB", "bbb"}}}, + {Title: "Sub - X", Rows: [][]string{{"one"}}}, + }, + }, { name: "nested formatter struct rendered inline", data: NestedFormatterDummyStruct{ From f293efc3c44e19a7e65226d5e698e53a8149426a Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 15:05:46 +0100 Subject: [PATCH 21/29] shrinking --- core/human/v2/human.go | 79 +++++++++++++++++++++++++++++++++---- core/human/v2/human_test.go | 48 +++++++++++++++++++++- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index c1ed6f62a7..cb4ec1ea26 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/scaleway/scaleway-cli/v2/internal/tabwriter" + "github.com/scaleway/scaleway-cli/v2/internal/terminal" "github.com/scaleway/scaleway-sdk-go/strcase" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -28,6 +29,14 @@ type Layout struct { // MarshalOpt.NoHeaders. NoHeaders bool + // NoShrinking keeps every table column regardless of the terminal width, + // like v1's MarshalOpt.DisableShrinking. + NoShrinking bool + + // ColPadding is the number of spaces between table columns; 0 uses the + // default of 2, like v1's colPadding. + ColPadding int + Sections []Section } @@ -178,6 +187,16 @@ func Render(data any, layout Layout) (string, error) { } var b strings.Builder + // Width-driven shrinking only applies to tables and only when the output + // is a terminal; disabling it (wide output) or a missing width keeps + // every column. + width := 0 + if !layout.NoShrinking { + if w := terminal.GetWidth(); w > 0 { + width = w + } + } + for _, block := range blocks { if block.Empty() { continue @@ -185,7 +204,7 @@ func Render(data any, layout Layout) (string, error) { if b.Len() > 0 { b.WriteString("\n\n") } - b.WriteString(block.Draw()) + b.WriteString(block.Draw(width, layout.ColPadding)) } if layout.Title != "" { @@ -538,8 +557,14 @@ func deriveTitle(path string) string { // lines or table, aligned to columns. It is the layout hook of the package: // column alignment today, terminal-width shrinking and lipgloss styling // later, so swapping the layout engine only touches this method. An empty -// block draws nothing, and accumulated whitespace is trimmed. -func (b Block) Draw() string { +// Draw renders the block to text: an optional title line followed by its +// lines, table columns shrunk to fit the given width (0 or negative keeps +// every column). Only table blocks shrink, like v1's formatGrid. +func (b Block) Draw(width, colPadding int) string { + if colPadding == 0 { + colPadding = 2 // v1 default + } + var s strings.Builder if b.Title != "" { s.WriteString(b.Title) @@ -547,18 +572,58 @@ func (b Block) Draw() string { s.WriteRune('\n') } - w := tabwriter.NewWriter(&s, 5, 1, 2, ' ', tabwriter.ANSIGraphicsRendition) + lines := make([][]string, 0, len(b.Rows)+1) if len(b.Header) > 0 && !b.NoHeaders { - fmt.Fprintln(w, strings.Join(b.Header, "\t")) + lines = append(lines, b.Header) } - for _, row := range b.Rows { - fmt.Fprintln(w, strings.Join(row, "\t")) + lines = append(lines, b.Rows...) + + cols := len(b.Header) + if cols == 0 && len(b.Rows) > 0 { + cols = len(b.Rows[0]) + } + if cols > 0 && width > 0 && len(b.Header) > 0 { + if fit := MaxCols(lines, width, colPadding); fit < cols { + cols = fit + } + } + + w := tabwriter.NewWriter(&s, 5, 1, colPadding, ' ', tabwriter.ANSIGraphicsRendition) + for _, line := range lines { + if cols > 0 && len(line) > cols { + line = line[:cols] + } + fmt.Fprintln(w, strings.Join(line, "\t")) } w.Flush() return strings.TrimSpace(s.String()) } +// MaxCols returns the number of columns that fit within width, mirroring v1: +// each column takes its widest cell plus colPadding, and the first column +// whose running width exceeds width is dropped. +func MaxCols(lines [][]string, width, colPadding int) int { + cols := len(lines[0]) + colMaxSize := make([]int, len(lines[0])) + + for _, line := range lines { + lineSize := 0 + for j := 0; j < cols; j++ { + size := len(line[j]) + colPadding + if size >= colMaxSize[j] { + colMaxSize[j] = size + } + lineSize += colMaxSize[j] + if lineSize > width { + cols = j + } + } + } + + return cols +} + // walk represents value and its nodes to f; f installs its own walk logic: // it's called for every node with its dotted-path keys (Sub.X, Strings.0, // Map.key) and returns whether to keep descending after it. Nil pointers and diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 5b78259edd..167139d067 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -710,6 +710,7 @@ func TestBlock_Draw(t *testing.T) { for _, tc := range []struct { name string block human.Block + width int want string }{ { @@ -766,6 +767,28 @@ b two`, want: `foo 1 bar 2`, }, + { + name: "table shrinks to fit width", + block: human.Block{ + Header: []string{"ID", "NAME", "TYPE"}, + Rows: [][]string{{"1", "xavier", "instance"}}, + }, + // Columns beyond the second drop once the running column width + // exceeds the given width; tabwriter pads 2-char cells to 5. + want: `ID NAME +1 xavier`, + width: 12, + }, + { + name: "no-headers table shrinks data rows", + block: human.Block{ + Header: []string{"ID", "NAME", "TYPE"}, + Rows: [][]string{{"1", "xavier", "instance"}}, + NoHeaders: true, + }, + want: `1 xavier`, + width: 12, + }, { name: "empty block draws nothing", block: human.Block{}, @@ -773,7 +796,7 @@ bar 2`, }, } { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, tc.block.Draw()) + require.Equal(t, tc.want, tc.block.Draw(tc.width, 0)) }) } } @@ -961,3 +984,26 @@ text`, }) } } + +func TestMaxCols(t *testing.T) { + grid := [][]string{ + {"ID", "NAME", "TYPE", "STATE", "ZONE", "PUBLIC IP"}, + {"1", "xavier", "instance", "running", "fr-par-1", "1.2.3.4"}, + } + for _, tc := range []struct { + width int + colPadding int + want int + }{ + {width: 3, colPadding: 2, want: 0}, + {width: 30, colPadding: 2, want: 3}, + {width: 40, colPadding: 2, want: 4}, + {width: 51, colPadding: 2, want: 5}, + {width: 60, colPadding: 2, want: 6}, + // colPadding is part of each column's estimated width: without it the + // same width fits every column. + {width: 40, colPadding: 0, want: 6}, + } { + require.Equal(t, tc.want, human.MaxCols(grid, tc.width, tc.colPadding)) + } +} From eb09d33c308e64b36a229337b15b8fa4ef78f9f5 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 15:46:58 +0100 Subject: [PATCH 22/29] format registry --- core/human/v2/format.go | 195 +++++++++++++++++++++++++---------- core/human/v2/format_test.go | 110 ++++++++++++++++++++ core/human/v2/human.go | 27 +++-- core/human/v2/human_test.go | 17 +++ 4 files changed, 287 insertions(+), 62 deletions(-) diff --git a/core/human/v2/format.go b/core/human/v2/format.go index cd2d26508e..8fc277cd0a 100644 --- a/core/human/v2/format.go +++ b/core/human/v2/format.go @@ -1,12 +1,16 @@ package human import ( + "encoding/json" "fmt" + "net" "reflect" "strings" + "sync" "time" "github.com/dustin/go-humanize" + version "github.com/hashicorp/go-version" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -14,14 +18,146 @@ type Formatter interface { FormatHuman() (string, error) } +// formatFuncs is the register of format functions bound to a concrete type, +// the v2 counterpart of v1's marshalerFuncs. It is consulted by Format at +// every pointer level and on the final value, taking precedence over the +// Formatter interface like v1's register does over Marshaler. +var formatFuncs sync.Map + +// RegisterFormatFunc binds f to the concrete type T so Format renders T +// through f. It is the v2 equivalent of v1's RegisterMarshalerFunc, without +// the MarshalOpt parameter: the function only formats a value, the layout +// (sections, fields, titles) stays in the Layout. +func RegisterFormatFunc[T any](f func(T) (string, error)) { + formatFuncs.Store(reflect.TypeFor[T](), func(i any) (string, error) { + return f(i.(T)) + }) +} + +// getFormatFunc returns the format function registered for t, if any. +func getFormatFunc(t reflect.Type) (func(any) (string, error), bool) { + v, ok := formatFuncs.Load(t) + if !ok { + return nil, false + } + + return v.(func(any) (string, error)), true +} + +// init registers the built-in format functions so they flow through the same +// registry as user-registered ones, like v1's init registrations. The +// fmt.Sprint fallback in Format covers everything else, including Stringers +// and errors. +func init() { + RegisterFormatFunc(func(v string) (string, error) { + if v == "" { + return "-", nil + } + + return v, nil + }) + RegisterFormatFunc(func(t time.Time) (string, error) { + return humanize.Time(t), nil + }) + RegisterFormatFunc(func(v scw.Size) (string, error) { + if isIECNotation := v%1024 == 0 && v%1000 != 0; isIECNotation { + return humanize.IBytes(uint64(v)), nil + } + + return humanize.Bytes(uint64(v)), nil + }) + RegisterFormatFunc(func(v scw.Duration) (string, error) { + const ( + minutes = int64(60) + hours = 60 * minutes + days = 24 * hours + ) + d := v.Seconds / days + h := (v.Seconds - d*days) / hours + m := (v.Seconds - (d*days + h*hours)) / minutes + s := v.Seconds % 60 + res := []string(nil) + if d != 0 { + res = append(res, fmt.Sprintf("%d days", d)) + } + if h != 0 { + res = append(res, fmt.Sprintf("%d hours", h)) + } + if m != 0 { + res = append(res, fmt.Sprintf("%d minutes", m)) + } + if s != 0 { + res = append(res, fmt.Sprintf("%d seconds", s)) + } + if v.Nanos != 0 { + res = append(res, fmt.Sprintf("%d nanoseconds", v.Nanos)) + } + if len(res) == 0 { + return "0 seconds", nil + } + + return strings.Join(res, " "), nil + }) + // The remaining leaf formatters are ported verbatim from v1's registered + // marshaler funcs so Format renders them identically. + RegisterFormatFunc(func(sizes []scw.Size) (string, error) { + strs := []string(nil) + for _, size := range sizes { + s, err := Format(size) + if err != nil { + return "", err + } + strs = append(strs, s) + } + + return strings.Join(strs, ", "), nil + }) + RegisterFormatFunc(func(ip net.IP) (string, error) { + return fmt.Sprintf("%v", ip), nil + }) + RegisterFormatFunc(func(ips []net.IP) (string, error) { + return fmt.Sprintf("%v", ips), nil + }) + RegisterFormatFunc(func(ip scw.IPNet) (string, error) { + str := ip.String() + if str == "" { + return "-", nil + } + + return str, nil + }) + RegisterFormatFunc(func(v version.Version) (string, error) { + return v.String(), nil + }) + RegisterFormatFunc(func(v scw.JSONObject) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + + return string(data), nil + }) + RegisterFormatFunc(func(v []byte) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + + return strings.Trim(string(data), "\""), nil + }) +} + func Format(data any) (string, error) { // Dereference pointers until the underlying value is reached, letting any - // value in the chain escape through FormatHuman. + // value in the chain escape through a registered function or FormatHuman. value := reflect.ValueOf(data) for value.Kind() == reflect.Pointer { if value.IsNil() { return "-", nil } + if f, ok := getFormatFunc(value.Type()); ok { + return f(value.Interface()) + } if f, ok := reflect.TypeAssert[Formatter](value); ok { return f.FormatHuman() } @@ -30,64 +166,13 @@ func Format(data any) (string, error) { if !value.IsValid() { return "-", nil } + if f, ok := getFormatFunc(value.Type()); ok { + return f(value.Interface()) + } if f, ok := reflect.TypeAssert[Formatter](value); ok { return f.FormatHuman() } data = value.Interface() - switch v := data.(type) { - case string: - if v == "" { - return "-", nil - } - - return v, nil - case time.Time: - return humanize.Time(v), nil - case scw.Size: - if isIECNotation := v%1024 == 0 && v%1000 != 0; isIECNotation { - return humanize.IBytes(uint64(v)), nil - } - - return humanize.Bytes(uint64(v)), nil - case scw.Duration: - return formatDuration(v), nil - } - return fmt.Sprint(data), nil } - -// formatDuration renders a duration as its joined non-zero components, -// matching v1's registered marshaler for scw.Duration. -func formatDuration(v scw.Duration) string { - const ( - minutes = int64(60) - hours = 60 * minutes - days = 24 * hours - ) - d := v.Seconds / days - h := (v.Seconds - d*days) / hours - m := (v.Seconds - (d*days + h*hours)) / minutes - s := v.Seconds % 60 - res := []string(nil) - if d != 0 { - res = append(res, fmt.Sprintf("%d days", d)) - } - if h != 0 { - res = append(res, fmt.Sprintf("%d hours", h)) - } - if m != 0 { - res = append(res, fmt.Sprintf("%d minutes", m)) - } - if s != 0 { - res = append(res, fmt.Sprintf("%d seconds", s)) - } - if v.Nanos != 0 { - res = append(res, fmt.Sprintf("%d nanoseconds", v.Nanos)) - } - if len(res) == 0 { - return "0 seconds" - } - - return strings.Join(res, " ") -} diff --git a/core/human/v2/format_test.go b/core/human/v2/format_test.go index 7f34107e6f..f8917ee1ef 100644 --- a/core/human/v2/format_test.go +++ b/core/human/v2/format_test.go @@ -1,15 +1,27 @@ package human_test import ( + "net" "testing" "time" "github.com/dustin/go-humanize" + version "github.com/hashicorp/go-version" human "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-sdk-go/scw" "github.com/stretchr/testify/require" ) +// makeIPNet parses a CIDR into a scw.IPNet for tests. +func makeIPNet(cidr string) scw.IPNet { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + + return scw.IPNet{IPNet: *ipNet} +} + type FormatterStruct struct { Foo string } @@ -30,6 +42,64 @@ func (ErrorStruct) Error() string { return "something broke" } +// Registry test types are distinct per test: the registry is global, so +// sharing a type between tests would couple their run order. + +type RegistryFormatted struct { + Foo string +} + +type RegistryPointerFormatted struct { + Foo string +} + +type RegistryPrecedenceFormatted struct{} + +func (RegistryPrecedenceFormatted) FormatHuman() (string, error) { + return "interface-formatted", nil +} + +type RegistryNestedStruct struct { + Root string + Sub RegistryFormatted +} + +func TestFormatRegistered(t *testing.T) { + human.RegisterFormatFunc[RegistryFormatted](func(v RegistryFormatted) (string, error) { + return "registered-" + v.Foo, nil + }) + + got, err := human.Format(RegistryFormatted{Foo: "a"}) + require.NoError(t, err) + require.Equal(t, "registered-a", got) + + // A pointer derefs to the registered value type. + got, err = human.Format(&RegistryFormatted{Foo: "b"}) + require.NoError(t, err) + require.Equal(t, "registered-b", got) +} + +func TestFormatRegisteredPointer(t *testing.T) { + human.RegisterFormatFunc[*RegistryPointerFormatted](func(v *RegistryPointerFormatted) (string, error) { + return "pointer-" + v.Foo, nil + }) + + got, err := human.Format(&RegistryPointerFormatted{Foo: "c"}) + require.NoError(t, err) + require.Equal(t, "pointer-c", got) +} + +func TestFormatRegisteredPrecedence(t *testing.T) { + human.RegisterFormatFunc[RegistryPrecedenceFormatted](func(RegistryPrecedenceFormatted) (string, error) { + return "registered", nil + }) + + // Like v1, a registered format function wins over Formatter. + got, err := human.Format(RegistryPrecedenceFormatted{}) + require.NoError(t, err) + require.Equal(t, "registered", got) +} + func TestFormat(t *testing.T) { date := time.Date(1990, 11, 17, 20, 20, 0, 0, time.UTC) humanDate := humanize.Time(date) @@ -155,6 +225,46 @@ func TestFormat(t *testing.T) { data: &scw.Duration{Seconds: 30}, want: "30 seconds", }, + { + name: "sizes", + data: []scw.Size{13200, 52428800}, + want: "13 kB, 50 MiB", + }, + { + name: "ip", + data: net.ParseIP("1.2.3.4"), + want: "1.2.3.4", + }, + { + name: "ips", + data: []net.IP{net.ParseIP("1.2.3.4")}, + want: "[1.2.3.4]", + }, + { + name: "ipnet", + data: makeIPNet("10.0.0.0/24"), + want: "10.0.0.0/24", + }, + { + name: "nil ipnet", + data: scw.IPNet{}, + want: "-", + }, + { + name: "version", + data: version.Must(version.NewVersion("1.2.3")), + want: "1.2.3", + }, + { + name: "json object", + data: scw.JSONObject{"a": 1}, + want: `{"a":1}`, + }, + { + name: "bytes", + data: []byte{0, 1}, + want: "AAE=", + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Format(tc.data) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index cb4ec1ea26..580db9dfee 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -110,29 +110,42 @@ func SetLayout(t reflect.Type, l Layout) { // isFormattable reports whether t should be rendered inline by Format rather // than as a composite table. func isFormattable(t reflect.Type) bool { - // A formatter at any pointer level escapes through Format, which checks - // each level in its deref loop, so mirror it here. + // A formatter or registered format function at any pointer level escapes + // through Format, which checks each level in its deref loop, so mirror it + // here. for t.Kind() == reflect.Pointer { if t.Implements(reflect.TypeFor[Formatter]()) { return true } + if _, ok := getFormatFunc(t); ok { + return true + } t = t.Elem() } switch { - // A struct is a table, unless it escapes through Formatter. + // A struct is a table, unless it escapes through Formatter or a + // registered format function. case t.Kind() == reflect.Struct: - return t.Implements(reflect.TypeFor[Formatter]()) - // A slice of structs breaks into a table; a scalar slice is inline. + _, hasFormatFunc := getFormatFunc(t) + + return t.Implements(reflect.TypeFor[Formatter]()) || hasFormatFunc + // A slice of structs breaks into a table, unless the slice type itself + // has a registered format function; a scalar slice is inline. case t.Kind() == reflect.Slice || t.Kind() == reflect.Array: + if _, ok := getFormatFunc(t); ok { + return true + } elem := t.Elem() for elem.Kind() == reflect.Pointer { elem = elem.Elem() } return elem.Kind() != reflect.Struct - // A map is a key-value table. + // A map is a key-value table, unless it has a registered format function. case t.Kind() == reflect.Map: - return false + _, hasFormatFunc := getFormatFunc(t) + + return hasFormatFunc default: return true } diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 167139d067..7566b63c1a 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -152,6 +152,9 @@ func init() { {Path: "RegistrySection", Title: "Custom"}, }, }) + human.RegisterFormatFunc[RegistryFormatted](func(v RegistryFormatted) (string, error) { + return "registered-" + v.Foo, nil + }) } func TestLayout_Resolve(t *testing.T) { @@ -976,6 +979,20 @@ Y two`, want: `Details: text`, }, + { + // A registered struct escapes through Format at the top level instead + // of flattening into its raw fields. + name: "registered format func top-level", + data: RegistryFormatted{Foo: "x"}, + want: "registered-x", + }, + { + // Nested as a struct field, it formats in place rather than + // flattening. + name: "registered format func nested", + data: RegistryNestedStruct{Root: "r", Sub: RegistryFormatted{Foo: "s"}}, + want: "Root r\nSub registered-s", + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Render(tc.data, tc.layout) From 80d2fe7891bddac412822eaf7b049cd51084d2d7 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 19:23:55 +0100 Subject: [PATCH 23/29] deref in section.resolve --- core/human/v2/human.go | 8 +++++--- core/human/v2/human_test.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 580db9dfee..27a01301d9 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -283,7 +283,6 @@ func (l Layout) Resolve(data any) ([]Block, error) { claimed[section.Path] = true } } - blocks := make([]Block, 0, len(sections)) for i, section := range sections { sectionData := data @@ -323,6 +322,9 @@ func (l Layout) Resolve(data any) ([]Block, error) { // keyed rows. func (s Section) Resolve(data any, claimed map[string]bool) (Block, error) { value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer { + value = value.Elem() + } if (!value.IsValid() || value.IsZero()) && s.Omittable { return Block{}, nil } @@ -347,9 +349,9 @@ func (s Section) Resolve(data any, claimed map[string]bool) (Block, error) { return s.resolveTable(value, block) case reflect.Map, reflect.Struct: return s.resolveList(value, block, claimed) + default: + return block, fmt.Errorf("unresolvable type %T", value.Interface(), value.Kind().String()) } - - return block, nil } // resolveTable renders a slice or array section as a table. With no selected diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 7566b63c1a..a1c9333a09 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -152,7 +152,7 @@ func init() { {Path: "RegistrySection", Title: "Custom"}, }, }) - human.RegisterFormatFunc[RegistryFormatted](func(v RegistryFormatted) (string, error) { + human.RegisterFormatFunc(func(v RegistryFormatted) (string, error) { return "registered-" + v.Foo, nil }) } From 282b8c4a96280f7a0f71c6ab829bb39787f0ade8 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 19:24:07 +0100 Subject: [PATCH 24/29] use humanv2 in core --- core/bootstrap.go | 2 +- core/command.go | 6 +++--- core/error.go | 24 ++++++++++++++++++++---- core/printer.go | 41 ++++++++++++++++++++++++----------------- core/result.go | 4 ++-- core/shell.go | 2 +- core/testing.go | 7 +++---- core/view.go | 30 +++++++++++++++++------------- 8 files changed, 71 insertions(+), 45 deletions(-) diff --git a/core/bootstrap.go b/core/bootstrap.go index 9a0fe6cdb6..f6d18186be 100644 --- a/core/bootstrap.go +++ b/core/bootstrap.go @@ -315,7 +315,7 @@ func Bootstrap(ctx context.Context, config *BootstrapConfig) (exitCode int, resu } if meta.command != nil { - printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt()) + printErr := printer.Print(meta.result, meta.command.getHumanLayout()) if printErr != nil { _, _ = fmt.Fprintln(config.Stderr, printErr) } diff --git a/core/command.go b/core/command.go index a9e1a296af..76a0fae027 100644 --- a/core/command.go +++ b/core/command.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/alias" ) @@ -262,9 +262,9 @@ func (c *Command) signature() string { return c.Namespace + " " + c.Resource + " " + c.Verb + " " + c.Short } -func (c *Command) getHumanMarshalerOpt() *human.MarshalOpt { +func (c *Command) getHumanLayout() *humanv2.Layout { if c.View != nil { - return c.View.getHumanMarshalerOpt() + return c.View.getHumanLayout() } return nil diff --git a/core/error.go b/core/error.go index baef3c8784..f9ffe695c3 100644 --- a/core/error.go +++ b/core/error.go @@ -4,8 +4,9 @@ import ( "encoding/json" "fmt" "strings" + "unicode" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" ) // CliError is an all-in-one error structure that can be used in commands to return useful errors to the user. @@ -44,7 +45,7 @@ func (s *CliError) MarshalHuman() (string, error) { if s.Message != "" { humanError = fmt.Errorf("%s", s.Message) } - str, err := human.Marshal(humanError, nil) + str, err := humanv2.Render(humanError, humanv2.Layout{}) if err != nil { return "", err } @@ -52,7 +53,7 @@ func (s *CliError) MarshalHuman() (string, error) { } if s.Details != "" { - str, err := human.Marshal(human.Capitalize(s.Details), &human.MarshalOpt{Title: "Details"}) + str, err := humanv2.Render(capitalize(s.Details), humanv2.Layout{Title: "Details"}) if err != nil { return "", err } @@ -60,7 +61,7 @@ func (s *CliError) MarshalHuman() (string, error) { } if s.Hint != "" { - str, err := human.Marshal(human.Capitalize(s.Hint), &human.MarshalOpt{Title: "Hint"}) + str, err := humanv2.Render(capitalize(s.Hint), humanv2.Layout{Title: "Hint"}) if err != nil { return "", err } @@ -96,3 +97,18 @@ func (s *CliError) MarshalJSON() ([]byte, error) { Hint: s.Hint, }) } + +// capitalize returns the given string with a first character in uppercase. +func capitalize(s string) string { + words := strings.Fields(s) + if len(words) > 0 && strings.Contains(words[0], "-") { + // First word is probably an id, don't capitalize + return s + } + + for i, c := range s { + return string(unicode.ToUpper(c)) + s[i+1:] + } + + return "" +} diff --git a/core/printer.go b/core/printer.go index 16dcd341cd..b3ea76a3b6 100644 --- a/core/printer.go +++ b/core/printer.go @@ -10,7 +10,7 @@ import ( "text/template" "github.com/ghodss/yaml" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/gofields" ) @@ -169,7 +169,7 @@ func setupWidePrinter(printer *Printer, opts string) { printer.printerType = PrinterTypeWide } -func (p *Printer) Print(data any, opt *human.MarshalOpt) error { +func (p *Printer) Print(data any, opt *humanv2.Layout) error { // No matter the printer type if data is a RawResult we should print it as is. if rawResult, isRawResult := data.(RawResult); isRawResult { _, err := p.stdout.Write(rawResult) @@ -203,14 +203,14 @@ func (p *Printer) Print(data any, opt *human.MarshalOpt) error { return nil } -func (p *Printer) printHuman(data any, opt *human.MarshalOpt) error { +func (p *Printer) printHuman(data any, opt *humanv2.Layout) error { _, isError := data.(error) - if !isError { - if opt == nil { - opt = &human.MarshalOpt{} - } + if opt == nil { + opt = &humanv2.Layout{} + } + if !isError { if len(p.humanFields) > 0 && reflect.TypeOf(data).Kind() != reflect.Slice { return p.printHuman( errors.New( @@ -221,19 +221,26 @@ func (p *Printer) printHuman(data any, opt *human.MarshalOpt) error { } if len(p.humanFields) > 0 { - opt.Fields = []*human.MarshalFieldOpt(nil) + fields := make([]humanv2.Field, 0, len(p.humanFields)) for _, field := range p.humanFields { - opt.Fields = append(opt.Fields, &human.MarshalFieldOpt{ - FieldName: field, - }) + fields = append(fields, humanv2.Field{Path: field}) + } + // v1's MarshalOpt.Fields only applied to the default (pathless) + // section; mirror that by replacing the main section's fields. + sections := []humanv2.Section{{Path: "", Fields: fields}} + for _, s := range opt.Sections { + if s.Path != "" { + sections = append(sections, s) + } } + opt.Sections = sections } opt.NoHeaders = p.noHeaders } - str, err := human.Marshal(data, opt) - if unknownFieldErr, ok := errors.AsType[*human.UnknownFieldError](err); ok { + str, err := humanv2.Render(data, *opt) + if unknownFieldErr, ok := errors.AsType[*humanv2.UnknownFieldError](err); ok { return p.printHuman(&CliError{ Err: fmt.Errorf("unknown field '%s' in output options", unknownFieldErr.FieldName), Hint: "Valid fields are: " + strings.Join(unknownFieldErr.ValidFields, ", "), @@ -258,12 +265,12 @@ func (p *Printer) printHuman(data any, opt *human.MarshalOpt) error { return err } -func (p *Printer) printWide(data any, opt *human.MarshalOpt) error { +func (p *Printer) printWide(data any, opt *humanv2.Layout) error { if opt != nil { - opt.DisableShrinking = true + opt.NoShrinking = true } else { - opt = &human.MarshalOpt{ - DisableShrinking: true, + opt = &humanv2.Layout{ + NoShrinking: true, } } diff --git a/core/result.go b/core/result.go index a5e2e67081..a67ab7f50b 100644 --- a/core/result.go +++ b/core/result.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/fatih/color" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/interactive" "github.com/scaleway/scaleway-cli/v2/internal/terminal" "github.com/scaleway/scaleway-sdk-go/strcase" @@ -83,7 +83,7 @@ type MultiResults []any func (mr *MultiResults) MarshalHuman() (string, error) { strs := []string(nil) for _, r := range *mr { - str, err := human.Marshal(r, nil) + str, err := humanv2.Render(r, humanv2.Layout{}) if err != nil { return "", err } diff --git a/core/shell.go b/core/shell.go index 61d4aac1e8..a0731fddd0 100644 --- a/core/shell.go +++ b/core/shell.go @@ -318,7 +318,7 @@ func shellExecutor(rootCmd *cobra.Command, printer *Printer, meta *Meta) func(s autoCompleteCache.Update(meta.command.Namespace) - printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt()) + printErr := printer.Print(meta.result, meta.command.getHumanLayout()) if printErr != nil { _, _ = fmt.Fprintln(os.Stderr, printErr) } diff --git a/core/testing.go b/core/testing.go index 7a0a3e3ded..1c09e62ecb 100644 --- a/core/testing.go +++ b/core/testing.go @@ -21,7 +21,7 @@ import ( "time" "github.com/hashicorp/go-version" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" args "github.com/scaleway/scaleway-cli/v2/internal/args" "github.com/scaleway/scaleway-cli/v2/internal/interactive" "github.com/scaleway/scaleway-cli/v2/internal/pkg/shlex" @@ -383,9 +383,8 @@ func Test(config *TestConfig) func(t *testing.T) { // Because human marshal of date is relative (e.g 3 minutes ago) we must make sure it stay consistent for golden to works. // Here we return a constant string. We may need to find a better place to put this. - human.RegisterMarshalerFunc( - time.Time{}, - func(_ any, _ *human.MarshalOpt) (string, error) { + humanv2.RegisterFormatFunc( + func(_ time.Time) (string, error) { return "few seconds ago", nil }, ) diff --git a/core/view.go b/core/view.go index 82d82969be..81719e5a5f 100644 --- a/core/view.go +++ b/core/view.go @@ -1,10 +1,10 @@ package core import ( - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" ) -// View hydrates human.MarshalOpt +// View hydrates humanv2.Layout type View struct { Title string Fields []*ViewField @@ -25,25 +25,29 @@ type ViewSection struct { HideIfEmpty bool } -func (v *View) getHumanMarshalerOpt() *human.MarshalOpt { +func (v *View) getHumanLayout() *humanv2.Layout { if v == nil { return nil } - opt := &human.MarshalOpt{} + + main := humanv2.Section{Path: ""} for _, field := range v.Fields { - opt.Fields = append(opt.Fields, &human.MarshalFieldOpt{ - FieldName: field.FieldName, - Label: field.Label, + main.Fields = append(main.Fields, humanv2.Field{ + Path: field.FieldName, + Label: field.Label, }) } + sections := []humanv2.Section{main} for _, section := range v.Sections { - opt.Sections = append(opt.Sections, &human.MarshalSection{ - Title: section.Title, - FieldName: section.FieldName, - HideIfEmpty: section.HideIfEmpty, + sections = append(sections, humanv2.Section{ + Title: section.Title, + Path: section.FieldName, + Omittable: section.HideIfEmpty, }) } - opt.Title = v.Title - return opt + return &humanv2.Layout{ + Title: v.Title, + Sections: sections, + } } From 9d24c92753f69610c66169cf7632836ee1d4bbde Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Tue, 3 Nov 2026 19:24:22 +0100 Subject: [PATCH 25/29] info custom layout --- core/human/v2/human.go | 2 +- internal/namespaces/info/custom.go | 32 +++++++++++++----------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 27a01301d9..9d4e346b0d 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -350,7 +350,7 @@ func (s Section) Resolve(data any, claimed map[string]bool) (Block, error) { case reflect.Map, reflect.Struct: return s.resolveList(value, block, claimed) default: - return block, fmt.Errorf("unresolvable type %T", value.Interface(), value.Kind().String()) + return block, fmt.Errorf("unresolvable type %T", value.Interface()) } } diff --git a/internal/namespaces/info/custom.go b/internal/namespaces/info/custom.go index 0c03d856e8..6a09f9bf4d 100644 --- a/internal/namespaces/info/custom.go +++ b/internal/namespaces/info/custom.go @@ -6,7 +6,6 @@ import ( "reflect" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -27,23 +26,6 @@ type infoResult struct { Settings []*setting `json:"settings"` } -func (i *infoResult) MarshalHuman() (string, error) { - type tmp infoResult - - return human.Marshal(tmp(*i), &human.MarshalOpt{ - Sections: []*human.MarshalSection{ - { - FieldName: "BuildInfo", - Title: "Build Info", - }, - { - FieldName: "Settings", - Title: "Settings", - }, - }, - }) -} - type setting struct { Key string `json:"key"` Value string `json:"value"` @@ -88,6 +70,20 @@ func infosRoot() *core.Command { }, }, nil }, + View: &core.View{ + Title: "", + Fields: []*core.ViewField{}, + Sections: []*core.ViewSection{ + { + FieldName: "BuildInfo", + Title: "Build Info", + }, + { + FieldName: "Settings", + Title: "Settings", + }, + }, + }, } } From fc9fe173aac9aa5fe8dcf401bb2191217a9c9723 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Wed, 4 Nov 2026 11:43:41 +0100 Subject: [PATCH 26/29] core --- core/arg_file_content_test.go | 2 +- core/error.go | 17 +++--- core/human/v2/format.go | 9 ++- core/human/v2/human.go | 33 +++++++++-- core/human/v2/human_test.go | 42 ++++++++++++++ core/result.go | 57 ++++++++++--------- core/result_test.go | 3 +- ...e-printer-human-simple-with-options.golden | 2 +- .../namespaces/object/v1/custom_marshaler.go | 3 +- internal/namespaces/rdb/v1/custom_acl.go | 3 +- internal/namespaces/rdb/v1/custom_endpoint.go | 3 +- 11 files changed, 126 insertions(+), 48 deletions(-) diff --git a/core/arg_file_content_test.go b/core/arg_file_content_test.go index 4faadac284..e4cf5dfccd 100644 --- a/core/arg_file_content_test.go +++ b/core/arg_file_content_test.go @@ -119,7 +119,7 @@ func Test_FileContentArgs(t *testing.T) { Cmd: "scw test file-content user-data.cloud-init=@does-not-exist.yml", Check: core.TestCheckCombine( core.TestCheckExitCode(1), - core.TestCheckStderrContains("Could not open requested file"), + core.TestCheckStderrContains("could not open requested file"), ), })) diff --git a/core/error.go b/core/error.go index f9ffe695c3..1bfca43287 100644 --- a/core/error.go +++ b/core/error.go @@ -2,11 +2,12 @@ package core import ( "encoding/json" - "fmt" "strings" "unicode" + "github.com/fatih/color" humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" + "github.com/scaleway/scaleway-cli/v2/internal/terminal" ) // CliError is an all-in-one error structure that can be used in commands to return useful errors to the user. @@ -35,21 +36,19 @@ func (s *CliError) Error() string { return s.Err.Error() } -func (s *CliError) MarshalHuman() (string, error) { +// FormatHuman lets humanv2 render a CliError as a leaf: a value receiver so +// the pointer is dereferenced before isFormattable sees the value type. +func (s CliError) FormatHuman() (string, error) { if s.Empty { return "", nil } sections := []string(nil) if s.Err != nil { - humanError := s.Err + message := s.Err.Error() if s.Message != "" { - humanError = fmt.Errorf("%s", s.Message) + message = s.Message } - str, err := humanv2.Render(humanError, humanv2.Layout{}) - if err != nil { - return "", err - } - sections = append(sections, str) + sections = append(sections, terminal.Style(capitalize(message), color.FgRed)) } if s.Details != "" { diff --git a/core/human/v2/format.go b/core/human/v2/format.go index 8fc277cd0a..7cd153435d 100644 --- a/core/human/v2/format.go +++ b/core/human/v2/format.go @@ -149,7 +149,8 @@ func init() { func Format(data any) (string, error) { // Dereference pointers until the underlying value is reached, letting any - // value in the chain escape through a registered function or FormatHuman. + // value in the chain escape through a registered function, FormatHuman or + // error.Error. value := reflect.ValueOf(data) for value.Kind() == reflect.Pointer { if value.IsNil() { @@ -161,6 +162,9 @@ func Format(data any) (string, error) { if f, ok := reflect.TypeAssert[Formatter](value); ok { return f.FormatHuman() } + if err, ok := value.Interface().(error); ok { + return err.Error(), nil + } value = value.Elem() } if !value.IsValid() { @@ -172,6 +176,9 @@ func Format(data any) (string, error) { if f, ok := reflect.TypeAssert[Formatter](value); ok { return f.FormatHuman() } + if err, ok := data.(error); ok { + return err.Error(), nil + } data = value.Interface() return fmt.Sprint(data), nil diff --git a/core/human/v2/human.go b/core/human/v2/human.go index 9d4e346b0d..f6340ddfb9 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -120,15 +120,18 @@ func isFormattable(t reflect.Type) bool { if _, ok := getFormatFunc(t); ok { return true } + if t.Implements(reflect.TypeFor[error]()) { + return true + } t = t.Elem() } switch { - // A struct is a table, unless it escapes through Formatter or a - // registered format function. + // A struct is a table, unless it escapes through Formatter, a registered + // format function or error.Error. case t.Kind() == reflect.Struct: _, hasFormatFunc := getFormatFunc(t) - return t.Implements(reflect.TypeFor[Formatter]()) || hasFormatFunc + return t.Implements(reflect.TypeFor[Formatter]()) || hasFormatFunc || t.Implements(reflect.TypeFor[error]()) // A slice of structs breaks into a table, unless the slice type itself // has a registered format function; a scalar slice is inline. case t.Kind() == reflect.Slice || t.Kind() == reflect.Array: @@ -186,7 +189,6 @@ func Render(data any, layout Layout) (string, error) { if !value.IsValid() { return "-", nil } - data = value.Interface() if reflect.ValueOf(layout).IsZero() { if l, ok := layouts.Load(value.Type()); ok { @@ -194,6 +196,8 @@ func Render(data any, layout Layout) (string, error) { } } + // Pass the raw data through: Resolve checks isFormattable on the pointer + // type, whose error and Formatter methods are often pointer-receiver. blocks, err := layout.Resolve(data) if err != nil { return "", err @@ -233,6 +237,18 @@ func Render(data any, layout Layout) (string, error) { // callers can pass them too; leaves and nil values fold into single-cell // blocks. func (l Layout) Resolve(data any) ([]Block, error) { + // A formattable value on its raw type escapes to a single-cell block: + // error and Formatter methods often hang on the pointer, so this has to + // run before pointers are dereferenced. + if reflect.ValueOf(data).IsValid() && isFormattable(reflect.TypeOf(data)) { + str, err := Format(data) + if err != nil { + return nil, err + } + + return []Block{{Rows: [][]string{{str}}}}, nil + } + // Dereference pointers until the underlying value is reached. value := reflect.ValueOf(data) for value.Kind() == reflect.Pointer { @@ -334,7 +350,9 @@ func (s Section) Resolve(data any, claimed map[string]bool) (Block, error) { block.Title = deriveTitle(s.Path) } - if !value.IsValid() || isFormattable(value.Type()) { + // isFormattable on the raw data type, not the dereferenced one: errors and + // Formatter methods often hang on the pointer. + if !value.IsValid() || isFormattable(reflect.TypeOf(data)) { str, err := Format(data) if err != nil { return Block{}, err @@ -662,6 +680,11 @@ func walk(value reflect.Value, keys string, f func(keys string, value reflect.Va case reflect.Struct: for i := range value.NumField() { + // Unexported fields cannot be turned into an interface by the + // visitor; reflect would panic on Interface(). + if !value.Field(i).CanInterface() { + continue + } name := value.Type().Field(i).Name err := walk(value.Field(i), joinKeys(keys, name), f) if err != nil { diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index a1c9333a09..87655dc8ad 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -1,6 +1,8 @@ package human_test import ( + "errors" + "fmt" "reflect" "testing" @@ -72,6 +74,20 @@ type DummyStruct struct { Bar int } +type UnexportedFieldStruct struct { + Exported string + hidden string +} + +type UnexportedFieldError struct { + Message string + hidden string +} + +func (e UnexportedFieldError) Error() string { + return e.Message +} + type RootLevelDummyStruct struct { RootLevelFieldA string RootLevelFieldB string @@ -993,6 +1009,32 @@ text`, data: RegistryNestedStruct{Root: "r", Sub: RegistryFormatted{Foo: "s"}}, want: "Root r\nSub registered-s", }, + { + // Unexported fields are skipped by walk: reflect panics if a visitor + // calls Interface() on them. + name: "unexported struct field skipped", + data: UnexportedFieldStruct{Exported: "v", hidden: "secret"}, + want: "Exported v", + }, + { + // Same for error values: an error struct is a leaf rendering its + // message, so unexported fields aren't the point here anymore. + name: "error struct with unexported field", + data: UnexportedFieldError{Message: "boom", hidden: "secret"}, + want: "boom", + }, + { + // A plain error renders its message through the error case. + name: "error message", + data: errors.New("boom"), + want: "boom", + }, + { + // Wrapped errors keep the chain in the message. + name: "wrapped error message", + data: fmt.Errorf("could not open file: %w", errors.New("no such file")), + want: "could not open file: no such file", + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Render(tc.data, tc.layout) diff --git a/core/result.go b/core/result.go index a67ab7f50b..800cd211fd 100644 --- a/core/result.go +++ b/core/result.go @@ -30,23 +30,39 @@ var standardSuccessMessages = map[string]string{ "delete": "%s has been successfully deleted.", } -func (s *SuccessResult) MarshalHuman() (string, error) { - if s.Empty { - return "", nil - } - message := s.getMessage() - if !strings.HasSuffix(message, ".") { - message += "." - } +// init wires v2's human engine to the result types: SuccessResult renders +// its leaf string and MultiResults composes one per element. +func init() { + humanv2.RegisterFormatFunc(func(s SuccessResult) (string, error) { + if s.Empty { + return "", nil + } + message := s.getMessage() + if !strings.HasSuffix(message, ".") { + message += "." + } - message = strcase.TitleFirstWord(message) - message = "✅ " + terminal.Style(message, color.FgGreen) + message = strcase.TitleFirstWord(message) + message = "✅ " + terminal.Style(message, color.FgGreen) - if s.Details != "" { - message += "\n" + interactive.Indent(s.Details, 2) - } + if s.Details != "" { + message += "\n" + interactive.Indent(s.Details, 2) + } + + return message, nil + }) + humanv2.RegisterFormatFunc(func(mr MultiResults) (string, error) { + strs := []string(nil) + for _, r := range mr { + str, err := humanv2.Render(r, humanv2.Layout{}) + if err != nil { + return "", err + } + strs = append(strs, str) + } - return message, nil + return strings.Join(strs, "\n"), nil + }) } func (s *SuccessResult) MarshalJSON() ([]byte, error) { @@ -79,16 +95,3 @@ func (s *SuccessResult) getMessage() string { } type MultiResults []any - -func (mr *MultiResults) MarshalHuman() (string, error) { - strs := []string(nil) - for _, r := range *mr { - str, err := humanv2.Render(r, humanv2.Layout{}) - if err != nil { - return "", err - } - strs = append(strs, str) - } - - return strings.Join(strs, "\n"), nil -} diff --git a/core/result_test.go b/core/result_test.go index 7ed2b04c45..e8368491fa 100644 --- a/core/result_test.go +++ b/core/result_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/scaleway/scaleway-cli/v2/core" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +18,7 @@ func TestResult(t *testing.T) { Verb: "dummy", } - humanOutput, err := result.MarshalHuman() + humanOutput, err := humanv2.Format(result) require.NoError(t, err) assert.Empty(t, humanOutput) jsonOutput, err := result.MarshalJSON() diff --git a/core/testdata/test-core-printer-human-simple-with-options.golden b/core/testdata/test-core-printer-human-simple-with-options.golden index 5f133a96c9..f930f63466 100644 --- a/core/testdata/test-core-printer-human-simple-with-options.golden +++ b/core/testdata/test-core-printer-human-simple-with-options.golden @@ -1,6 +1,6 @@ 🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 🟥🟥🟥 STDERR️️ 🟥🟥🟥️ -List of fields for human output is only supported for commands that return a list +list of fields for human output is only supported for commands that return a list 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "111111111-111111111", diff --git a/internal/namespaces/object/v1/custom_marshaler.go b/internal/namespaces/object/v1/custom_marshaler.go index 8ed57606a3..d207ccd8b7 100644 --- a/internal/namespaces/object/v1/custom_marshaler.go +++ b/internal/namespaces/object/v1/custom_marshaler.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -33,7 +34,7 @@ type BucketResponse struct { func bucketResponseMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { resp := i.(BucketResponse) - messageStr, err := resp.SuccessResult.MarshalHuman() + messageStr, err := humanv2.Format(resp.SuccessResult) if err != nil { return "", err } diff --git a/internal/namespaces/rdb/v1/custom_acl.go b/internal/namespaces/rdb/v1/custom_acl.go index 39c1020743..6bb9592f8f 100644 --- a/internal/namespaces/rdb/v1/custom_acl.go +++ b/internal/namespaces/rdb/v1/custom_acl.go @@ -8,6 +8,7 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/editor" "github.com/scaleway/scaleway-sdk-go/api/rdb/v1" "github.com/scaleway/scaleway-sdk-go/scw" @@ -39,7 +40,7 @@ type CustomACLResult struct { func rdbACLCustomResultMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { result := i.(CustomACLResult) - messageStr, err := result.Success.MarshalHuman() + messageStr, err := humanv2.Format(result.Success) if err != nil { return "", err } diff --git a/internal/namespaces/rdb/v1/custom_endpoint.go b/internal/namespaces/rdb/v1/custom_endpoint.go index b7a7eb6958..cc905f6267 100644 --- a/internal/namespaces/rdb/v1/custom_endpoint.go +++ b/internal/namespaces/rdb/v1/custom_endpoint.go @@ -8,6 +8,7 @@ import ( "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-sdk-go/api/rdb/v1" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -19,7 +20,7 @@ type rdbEndpointCustomResult struct { func rdbEndpointCustomResultMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { result := i.(rdbEndpointCustomResult) - messageStr, err := result.Success.MarshalHuman() + messageStr, err := humanv2.Format(result.Success) if err != nil { return "", err } From 629c0fab2cfe4319ee1ba0797a48d342bf1d54e6 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Wed, 4 Nov 2026 13:55:37 +0100 Subject: [PATCH 27/29] applesilicon --- .../applesilicon/v1alpha1/custom.go | 16 ------- .../applesilicon/v1alpha1/custom_os.go | 25 ++++------ .../v1alpha1/custom_server_types.go | 48 +++++++++---------- .../testdata/test-os-display-simple.golden | 4 +- .../test-server-type-list-base.golden | 10 ++-- 5 files changed, 39 insertions(+), 64 deletions(-) diff --git a/internal/namespaces/applesilicon/v1alpha1/custom.go b/internal/namespaces/applesilicon/v1alpha1/custom.go index f14972c6cf..34f4d0136a 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom.go @@ -2,8 +2,6 @@ package applesilicon import ( "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" - applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" ) func GetCommands() *core.Commands { @@ -16,20 +14,6 @@ func GetCommands() *core.Commands { cmds.MustFind("apple-silicon").Groups = []string{"baremetal"} - human.RegisterMarshalerFunc(applesilicon.ServerTypeCPU{}, cpuMarshalerFunc) - human.RegisterMarshalerFunc(applesilicon.ServerTypeDisk{}, diskMarshalerFunc) - human.RegisterMarshalerFunc(applesilicon.ServerTypeMemory{}, memoryMarshalerFunc) - human.RegisterMarshalerFunc(applesilicon.OS{}, OSMarshalerFunc) - - human.RegisterMarshalerFunc( - applesilicon.ServerStatus(""), - human.EnumMarshalFunc(serverStatusMarshalSpecs), - ) - human.RegisterMarshalerFunc( - applesilicon.ServerTypeStock(""), - human.EnumMarshalFunc(serverTypeStockMarshalSpecs), - ) - cmds.MustFind("apple-silicon", "server", "create").Override(serverCreateBuilder) cmds.MustFind("apple-silicon", "server", "reboot").Override(serverRebootBuilder) cmds.MustFind("apple-silicon", "server", "delete").Override(serverDeleteBuilder) diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_os.go b/internal/namespaces/applesilicon/v1alpha1/custom_os.go index c21a814a7d..196ad95f5a 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_os.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_os.go @@ -1,23 +1,18 @@ package applesilicon import ( - "github.com/scaleway/scaleway-cli/v2/core/human" + "reflect" + + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" ) -func OSMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { - type tmp applesilicon.OS - os := tmp(i.(applesilicon.OS)) - opt.Sections = []*human.MarshalSection{ - { - FieldName: "CompatibleServerTypes", - Title: "CompatibleServerTypes", +func init() { + humanv2.SetLayout(reflect.TypeFor[applesilicon.OS](), humanv2.Layout{ + Sections: []humanv2.Section{ + { + Path: "CompatibleServerTypes", + }, }, - } - str, err := human.Marshal(os, opt) - if err != nil { - return "", err - } - - return str, nil + }) } diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go b/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go index 39e48e7435..718bfd8a5b 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go @@ -8,11 +8,33 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" productcatalog "github.com/scaleway/scaleway-sdk-go/api/product_catalog/v2alpha1" "github.com/scaleway/scaleway-sdk-go/scw" ) +func init() { + humanv2.RegisterFormatFunc(func(cpu applesilicon.ServerTypeCPU) (string, error) { + return fmt.Sprintf("%s (%d cores)", cpu.Name, cpu.CoreCount), nil + }) + humanv2.RegisterFormatFunc(func(disk applesilicon.ServerTypeDisk) (string, error) { + return humanv2.Format(disk.Capacity) + }) + humanv2.RegisterFormatFunc(func(memory applesilicon.ServerTypeMemory) (string, error) { + return humanv2.Format(memory.Capacity) + }) + + human.RegisterMarshalerFunc( + applesilicon.ServerStatus(""), + human.EnumMarshalFunc(serverStatusMarshalSpecs), + ) + human.RegisterMarshalerFunc( + applesilicon.ServerTypeStock(""), + human.EnumMarshalFunc(serverTypeStockMarshalSpecs), + ) +} + var serverTypeStockMarshalSpecs = human.EnumMarshalSpecs{ applesilicon.ServerTypeStockLowStock: &human.EnumMarshalSpec{ Attribute: color.FgYellow, @@ -28,32 +50,6 @@ var serverTypeStockMarshalSpecs = human.EnumMarshalSpecs{ }, } -func cpuMarshalerFunc(i any, _ *human.MarshalOpt) (string, error) { - cpu := i.(applesilicon.ServerTypeCPU) - - return fmt.Sprintf("%s (%d cores)", cpu.Name, cpu.CoreCount), nil -} - -func diskMarshalerFunc(i any, _ *human.MarshalOpt) (string, error) { - disk := i.(applesilicon.ServerTypeDisk) - capacityStr, err := human.Marshal(disk.Capacity, nil) - if err != nil { - return "", err - } - - return capacityStr, nil -} - -func memoryMarshalerFunc(i any, _ *human.MarshalOpt) (string, error) { - memory := i.(applesilicon.ServerTypeMemory) - capacityStr, err := human.Marshal(memory.Capacity, nil) - if err != nil { - return "", err - } - - return capacityStr, nil -} - type customServerType struct { *applesilicon.ServerType KgCo2Equivalent *float32 `json:"kg_co2_equivalent"` diff --git a/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden b/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden index bfc7922dbc..8afa1cec92 100644 --- a/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden +++ b/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden @@ -10,9 +10,9 @@ Version 14.4 XcodeVersion 15 ReleaseNotesURL - Description - -Zone +Zone -CompatibleServerTypes: +Compatible Server Types: [M1-M M2-M M2-L] 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { diff --git a/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden b/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden index e9f96ee353..5231bafdb8 100644 --- a/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden +++ b/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden @@ -1,11 +1,11 @@ 🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 🟩🟩🟩 STDOUT️ 🟩🟩🟩️ Name CPU Memory Disk Stock Minimum Lease Duration CO2 (kg/hour) Water (m³/hour) -M1-M Apple M1 (8 cores) 8.0 GB 256 GB no stock 1 days 0.03006371 3.635257e-07 -M4-S Apple M4 (10 cores) 16 GB 256 GB no stock 1 days 0.037783757 6.0587615e-07 -M2-M Apple M2 (8 cores) 16 GB 256 GB no stock 1 days 0.03742678 5.592703e-07 -M4-SP Apple M4 (10 cores) 16 GB 512 GB no stock 1 days 0.03799304 6.0587615e-07 -M4-M Apple M4 (10 cores) 32 GB 1.0 TB no stock 1 days 0.038944334 6.0587615e-07 +M1-M Apple M1 (8 cores) 8.0 GB 256 GB no_stock 1 days 0.03006371 3.635257e-07 +M4-S Apple M4 (10 cores) 16 GB 256 GB no_stock 1 days 0.037783757 6.0587615e-07 +M2-M Apple M2 (8 cores) 16 GB 256 GB no_stock 1 days 0.03742678 5.592703e-07 +M4-SP Apple M4 (10 cores) 16 GB 512 GB no_stock 1 days 0.03799304 6.0587615e-07 +M4-M Apple M4 (10 cores) 32 GB 1.0 TB no_stock 1 days 0.038944334 6.0587615e-07 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 [ { From 7e439c1c1109e8b9ae14a07c08832f5385db8618 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Wed, 4 Nov 2026 13:55:51 +0100 Subject: [PATCH 28/29] autoscaling --- .../namespaces/autoscaling/v1alpha2/custom.go | 4 --- .../v1alpha2/custom_autoscaling.go | 31 ++++++------------- .../v1alpha2/custom_autoscaling_test.go | 2 +- .../testdata/test-group-create-ok.golden | 2 +- .../testdata/test-group-update.golden | 2 +- 5 files changed, 13 insertions(+), 28 deletions(-) diff --git a/internal/namespaces/autoscaling/v1alpha2/custom.go b/internal/namespaces/autoscaling/v1alpha2/custom.go index f557739c1d..f8e27e92ec 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom.go @@ -2,15 +2,11 @@ package autoscaling import ( "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" - autoscaling "github.com/scaleway/scaleway-sdk-go/api/autoscaling/v1alpha2" ) func GetCommands() *core.Commands { commands := GetGeneratedCommands() - human.RegisterMarshalerFunc(autoscaling.Group{}, groupMarshalerFunc) - commands.MustFind("autoscaling", "group", "create").Override(GroupCreateBuilder) commands.MustFind("autoscaling", "group", "update").Override(GroupUpdateBuilder) commands.MustFind("autoscaling", "group", "list").Override(GroupListBuilder) diff --git a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go index 274fa7da5e..fe5997ea8c 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go @@ -3,33 +3,22 @@ package autoscaling import ( "context" "fmt" + "reflect" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" autoscaling "github.com/scaleway/scaleway-sdk-go/api/autoscaling/v1alpha2" ) -func groupMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { - type tmp autoscaling.Group - template := tmp(i.(autoscaling.Group)) - - opt.Sections = []*human.MarshalSection{ - {FieldName: "Tags", HideIfEmpty: true}, - {FieldName: "ScalingPolicy", Title: "Scaling Policy"}, - {FieldName: "OpenAlerts", HideIfEmpty: true, Title: "Open Alerts"}, - { - FieldName: "LoadBalancerConfiguration", - HideIfEmpty: true, - Title: "LoadBalancer Configuration", +func init() { + humanv2.SetLayout(reflect.TypeFor[autoscaling.Group](), humanv2.Layout{ + Sections: []humanv2.Section{ + {Path: "Tags", Omittable: true}, + {Path: "ScalingPolicy", Title: "Scaling Policy"}, + {Path: "OpenAlerts", Omittable: true, Title: "Open Alerts"}, + {Path: "LoadBalancerConfiguration", Omittable: true, Title: "LoadBalancer Configuration"}, }, - } - - str, err := human.Marshal(template, opt) - if err != nil { - return "", err - } - - return str, nil + }) } func GroupCreateBuilder(c *core.Command) *core.Command { diff --git a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go index e078f081af..4b06180c0b 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go @@ -78,7 +78,7 @@ func Test_Group(t *testing.T) { " scaling-policy-spec.minimum-size=1 scaling-policy-spec.maximum-size=5", Check: core.TestCheckCombine( core.TestCheckStderrContains( - `At least one argument from the 'scaling-policy-target' group is required`, + `at least one argument from the 'scaling-policy-target' group is required`, ), core.TestCheckExitCode(1), ), diff --git a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden index df3a81bcd9..b072ed7d9f 100644 --- a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden +++ b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden @@ -10,7 +10,7 @@ Status active CurrentSize 0 TargetSize 0 TemplateID cca81e91-dce9-43fd-bf35-81572e1f4554 -Zone +Zone Tags: [cli-test-asg] diff --git a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden index a7cd6bc3ae..359d5ef988 100644 --- a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden +++ b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden @@ -10,7 +10,7 @@ Status active CurrentSize 0 TargetSize 0 TemplateID 04d0e5ad-78e5-4a97-b0f0-2cec1c0020df -Zone +Zone Tags: [tags were updated] From 0ba4a764ca9aef51b1353f9a204363d01e6793e4 Mon Sep 17 00:00:00 2001 From: Romain BAUGUE Date: Wed, 4 Nov 2026 16:34:19 +0100 Subject: [PATCH 29/29] enumformat --- core/human/v2/error.go | 2 +- core/human/v2/format.go | 42 ++- core/human/v2/format_test.go | 13 +- core/human/v2/human.go | 25 +- core/human/v2/human_test.go | 6 +- .../applesilicon/v1alpha1/custom.go | 20 ++ .../applesilicon/v1alpha1/custom_os.go | 15 +- .../applesilicon/v1alpha1/custom_server.go | 29 +- .../v1alpha1/custom_server_types.go | 40 +-- .../testdata/test-os-display-simple.golden | 2 +- .../test-server-type-list-base.golden | 10 +- .../namespaces/autoscaling/v1alpha2/custom.go | 6 + .../v1alpha2/custom_autoscaling.go | 17 +- internal/namespaces/baremetal/v1/custom.go | 19 +- .../namespaces/baremetal/v1/custom_offer.go | 99 +++--- .../namespaces/baremetal/v1/custom_server.go | 26 +- .../test-baremetal-get-offer-simple.golden | 20 +- .../test-baremetal-list-offer-simple.golden | 290 +++++++++--------- .../test-create-server-simple-default.golden | 2 +- ...-install-server-simple-all-ssh-keys.golden | 2 +- .../test-install-server-simple-with-id.golden | 2 +- 21 files changed, 342 insertions(+), 345 deletions(-) diff --git a/core/human/v2/error.go b/core/human/v2/error.go index 1c9d12c2d3..02830422f1 100644 --- a/core/human/v2/error.go +++ b/core/human/v2/error.go @@ -1,4 +1,4 @@ -package human +package humanv2 import "fmt" diff --git a/core/human/v2/format.go b/core/human/v2/format.go index 7cd153435d..2d02fba7c6 100644 --- a/core/human/v2/format.go +++ b/core/human/v2/format.go @@ -1,16 +1,19 @@ -package human +package humanv2 import ( "encoding/json" "fmt" "net" "reflect" + "strconv" "strings" "sync" "time" "github.com/dustin/go-humanize" + "github.com/fatih/color" version "github.com/hashicorp/go-version" + "github.com/scaleway/scaleway-cli/v2/internal/terminal" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -34,6 +37,29 @@ func RegisterFormatFunc[T any](f func(T) (string, error)) { }) } +type ( + EnumFormats[T comparable] map[T]EnumFormat + EnumFormat struct { + Color color.Attribute + Value string + } +) + +func RegisterEnumFormat[T comparable](formats EnumFormats[T]) { + RegisterFormatFunc(func(v T) (string, error) { + format, ok := formats[v] + if !ok { + return fmt.Sprint(v), nil + } + s := fmt.Sprint(v) + if format.Value != "" { + s = format.Value + } + + return terminal.Style(s, format.Color), nil + }) +} + // getFormatFunc returns the format function registered for t, if any. func getFormatFunc(t reflect.Type) (func(any) (string, error), bool) { v, ok := formatFuncs.Load(t) @@ -162,7 +188,7 @@ func Format(data any) (string, error) { if f, ok := reflect.TypeAssert[Formatter](value); ok { return f.FormatHuman() } - if err, ok := value.Interface().(error); ok { + if err, ok := reflect.TypeAssert[error](value); ok { return err.Error(), nil } value = value.Elem() @@ -176,9 +202,19 @@ func Format(data any) (string, error) { if f, ok := reflect.TypeAssert[Formatter](value); ok { return f.FormatHuman() } - if err, ok := data.(error); ok { + if err, ok := reflect.TypeAssert[error](value); ok { return err.Error(), nil } + if stringer, ok := reflect.TypeAssert[fmt.Stringer](value); ok { + return stringer.String(), nil + } + + // This case isn't ported to the isFormattable function intentionally, as this + // implements v1's behavior of printing the number of elements in a slice rather + // than the slice itself in table cells. + if value.Kind() == reflect.Slice || value.Kind() == reflect.Array { + return strconv.Itoa(value.Len()), nil + } data = value.Interface() return fmt.Sprint(data), nil diff --git a/core/human/v2/format_test.go b/core/human/v2/format_test.go index f8917ee1ef..d75e5c2974 100644 --- a/core/human/v2/format_test.go +++ b/core/human/v2/format_test.go @@ -1,4 +1,4 @@ -package human_test +package humanv2_test import ( "net" @@ -65,7 +65,7 @@ type RegistryNestedStruct struct { } func TestFormatRegistered(t *testing.T) { - human.RegisterFormatFunc[RegistryFormatted](func(v RegistryFormatted) (string, error) { + human.RegisterFormatFunc(func(v RegistryFormatted) (string, error) { return "registered-" + v.Foo, nil }) @@ -80,7 +80,7 @@ func TestFormatRegistered(t *testing.T) { } func TestFormatRegisteredPointer(t *testing.T) { - human.RegisterFormatFunc[*RegistryPointerFormatted](func(v *RegistryPointerFormatted) (string, error) { + human.RegisterFormatFunc(func(v *RegistryPointerFormatted) (string, error) { return "pointer-" + v.Foo, nil }) @@ -90,7 +90,7 @@ func TestFormatRegisteredPointer(t *testing.T) { } func TestFormatRegisteredPrecedence(t *testing.T) { - human.RegisterFormatFunc[RegistryPrecedenceFormatted](func(RegistryPrecedenceFormatted) (string, error) { + human.RegisterFormatFunc(func(RegistryPrecedenceFormatted) (string, error) { return "registered", nil }) @@ -265,6 +265,11 @@ func TestFormat(t *testing.T) { data: []byte{0, 1}, want: "AAE=", }, + { + name: "slices", + data: []int{1, 2, 3}, + want: "3", + }, } { t.Run(tc.name, func(t *testing.T) { got, err := human.Format(tc.data) diff --git a/core/human/v2/human.go b/core/human/v2/human.go index f6340ddfb9..3cc0e3d9a8 100644 --- a/core/human/v2/human.go +++ b/core/human/v2/human.go @@ -1,4 +1,4 @@ -package human +package humanv2 import ( "fmt" @@ -98,12 +98,12 @@ func (l Layout) ResolveSections() []Section { var layouts sync.Map -// SetLayout stores a custom layout for a particular type. This custom layout +// RegisterLayout stores a custom layout for a particular type. This custom layout // will override the default one, and be eventually overridden by the render // options themselves. This is useful because we don't want to have to // maintain the layouts in the SDK itself, nor wrap the types to implement // an interface. -func SetLayout(t reflect.Type, l Layout) { +func RegisterLayout(t reflect.Type, l Layout) { layouts.Store(t, l) } @@ -123,15 +123,30 @@ func isFormattable(t reflect.Type) bool { if t.Implements(reflect.TypeFor[error]()) { return true } + if t.Implements(reflect.TypeFor[fmt.Stringer]()) { + return true + } t = t.Elem() } switch { // A struct is a table, unless it escapes through Formatter, a registered // format function or error.Error. case t.Kind() == reflect.Struct: - _, hasFormatFunc := getFormatFunc(t) + if t.Implements(reflect.TypeFor[Formatter]()) { + return true + } + if _, ok := getFormatFunc(t); ok { + return true + } + if t.Implements(reflect.TypeFor[error]()) { + return true + } + if t.Implements(reflect.TypeFor[fmt.Stringer]()) { + return true + } + + return false - return t.Implements(reflect.TypeFor[Formatter]()) || hasFormatFunc || t.Implements(reflect.TypeFor[error]()) // A slice of structs breaks into a table, unless the slice type itself // has a registered format function; a scalar slice is inline. case t.Kind() == reflect.Slice || t.Kind() == reflect.Array: diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go index 87655dc8ad..ed219dffa1 100644 --- a/core/human/v2/human_test.go +++ b/core/human/v2/human_test.go @@ -1,4 +1,4 @@ -package human_test +package humanv2_test import ( "errors" @@ -163,7 +163,7 @@ type MapListDummyStruct struct { func init() { // Registered once for the whole table; only the two registry cases use // RegistryDummyStruct, so no other case is affected. - human.SetLayout(reflect.TypeFor[RegistryDummyStruct](), human.Layout{ + human.RegisterLayout(reflect.TypeFor[RegistryDummyStruct](), human.Layout{ Sections: []human.Section{ {Path: "RegistrySection", Title: "Custom"}, }, @@ -372,7 +372,7 @@ func TestLayout_Resolve(t *testing.T) { }, }, want: []human.Block{ - {Title: "Allowed", Rows: [][]string{{"[poweron backup]"}}}, + {Title: "Allowed", Rows: [][]string{{"2"}}}, }, }, { diff --git a/internal/namespaces/applesilicon/v1alpha1/custom.go b/internal/namespaces/applesilicon/v1alpha1/custom.go index 34f4d0136a..191f39e636 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom.go @@ -1,7 +1,12 @@ package applesilicon import ( + "fmt" + "reflect" + "github.com/scaleway/scaleway-cli/v2/core" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" + applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" ) func GetCommands() *core.Commands { @@ -20,5 +25,20 @@ func GetCommands() *core.Commands { cmds.MustFind("apple-silicon", "server-type", "list").Override(serverTypeBuilder) + humanv2.RegisterLayout(reflect.TypeFor[applesilicon.OS](), osLayout) + + humanv2.RegisterFormatFunc(func(cpu applesilicon.ServerTypeCPU) (string, error) { + return fmt.Sprintf("%s (%d cores)", cpu.Name, cpu.CoreCount), nil + }) + humanv2.RegisterFormatFunc(func(disk applesilicon.ServerTypeDisk) (string, error) { + return humanv2.Format(disk.Capacity) + }) + humanv2.RegisterFormatFunc(func(memory applesilicon.ServerTypeMemory) (string, error) { + return humanv2.Format(memory.Capacity) + }) + + humanv2.RegisterEnumFormat(serverStatusFormat) + humanv2.RegisterEnumFormat(serverTypeStockFormat) + return cmds } diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_os.go b/internal/namespaces/applesilicon/v1alpha1/custom_os.go index 196ad95f5a..e6924f26df 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_os.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_os.go @@ -1,18 +1,13 @@ package applesilicon import ( - "reflect" - humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" - applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" ) -func init() { - humanv2.SetLayout(reflect.TypeFor[applesilicon.OS](), humanv2.Layout{ - Sections: []humanv2.Section{ - { - Path: "CompatibleServerTypes", - }, +var osLayout = humanv2.Layout{ + Sections: []humanv2.Section{ + { + Path: "CompatibleServerTypes", }, - }) + }, } diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_server.go b/internal/namespaces/applesilicon/v1alpha1/custom_server.go index ba1f6f7a73..efe1b48c59 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_server.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_server.go @@ -11,7 +11,7 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -26,27 +26,12 @@ const ( serverActionReboot ) -var serverStatusMarshalSpecs = human.EnumMarshalSpecs{ - applesilicon.ServerStatusError: &human.EnumMarshalSpec{ - Attribute: color.FgRed, - Value: "error", - }, - applesilicon.ServerStatusReady: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "ready", - }, - applesilicon.ServerStatusRebooting: &human.EnumMarshalSpec{ - Attribute: color.FgBlue, - Value: "rebooting", - }, - applesilicon.ServerStatusStarting: &human.EnumMarshalSpec{ - Attribute: color.FgBlue, - Value: "starting", - }, - applesilicon.ServerStatusUpdating: &human.EnumMarshalSpec{ - Attribute: color.FgBlue, - Value: "updating", - }, +var serverStatusFormat = humanv2.EnumFormats[applesilicon.ServerStatus]{ + applesilicon.ServerStatusError: humanv2.EnumFormat{Color: color.FgRed, Value: "error"}, + applesilicon.ServerStatusReady: humanv2.EnumFormat{Color: color.FgGreen, Value: "ready"}, + applesilicon.ServerStatusRebooting: humanv2.EnumFormat{Color: color.FgBlue, Value: "rebooting"}, + applesilicon.ServerStatusStarting: humanv2.EnumFormat{Color: color.FgBlue, Value: "starting"}, + applesilicon.ServerStatusUpdating: humanv2.EnumFormat{Color: color.FgBlue, Value: "updating"}, } func serverCreateBuilder(c *core.Command) *core.Command { diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go b/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go index 718bfd8a5b..bbaa927ac5 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go @@ -2,52 +2,20 @@ package applesilicon import ( "context" - "fmt" "strings" "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" productcatalog "github.com/scaleway/scaleway-sdk-go/api/product_catalog/v2alpha1" "github.com/scaleway/scaleway-sdk-go/scw" ) -func init() { - humanv2.RegisterFormatFunc(func(cpu applesilicon.ServerTypeCPU) (string, error) { - return fmt.Sprintf("%s (%d cores)", cpu.Name, cpu.CoreCount), nil - }) - humanv2.RegisterFormatFunc(func(disk applesilicon.ServerTypeDisk) (string, error) { - return humanv2.Format(disk.Capacity) - }) - humanv2.RegisterFormatFunc(func(memory applesilicon.ServerTypeMemory) (string, error) { - return humanv2.Format(memory.Capacity) - }) - - human.RegisterMarshalerFunc( - applesilicon.ServerStatus(""), - human.EnumMarshalFunc(serverStatusMarshalSpecs), - ) - human.RegisterMarshalerFunc( - applesilicon.ServerTypeStock(""), - human.EnumMarshalFunc(serverTypeStockMarshalSpecs), - ) -} - -var serverTypeStockMarshalSpecs = human.EnumMarshalSpecs{ - applesilicon.ServerTypeStockLowStock: &human.EnumMarshalSpec{ - Attribute: color.FgYellow, - Value: "low stock", - }, - applesilicon.ServerTypeStockNoStock: &human.EnumMarshalSpec{ - Attribute: color.FgRed, - Value: "no stock", - }, - applesilicon.ServerTypeStockHighStock: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "high stock", - }, +var serverTypeStockFormat = humanv2.EnumFormats[applesilicon.ServerTypeStock]{ + applesilicon.ServerTypeStockLowStock: humanv2.EnumFormat{Color: color.FgYellow, Value: "low stock"}, + applesilicon.ServerTypeStockNoStock: humanv2.EnumFormat{Color: color.FgRed, Value: "no stock"}, + applesilicon.ServerTypeStockHighStock: humanv2.EnumFormat{Color: color.FgGreen, Value: "high stock"}, } type customServerType struct { diff --git a/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden b/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden index 8afa1cec92..909838598c 100644 --- a/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden +++ b/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden @@ -13,7 +13,7 @@ Description - Zone Compatible Server Types: -[M1-M M2-M M2-L] +3 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "e08d1e5d-b4b9-402a-9f9a-97732d17e374", diff --git a/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden b/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden index 5231bafdb8..e9f96ee353 100644 --- a/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden +++ b/internal/namespaces/applesilicon/v1alpha1/testdata/test-server-type-list-base.golden @@ -1,11 +1,11 @@ 🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 🟩🟩🟩 STDOUT️ 🟩🟩🟩️ Name CPU Memory Disk Stock Minimum Lease Duration CO2 (kg/hour) Water (m³/hour) -M1-M Apple M1 (8 cores) 8.0 GB 256 GB no_stock 1 days 0.03006371 3.635257e-07 -M4-S Apple M4 (10 cores) 16 GB 256 GB no_stock 1 days 0.037783757 6.0587615e-07 -M2-M Apple M2 (8 cores) 16 GB 256 GB no_stock 1 days 0.03742678 5.592703e-07 -M4-SP Apple M4 (10 cores) 16 GB 512 GB no_stock 1 days 0.03799304 6.0587615e-07 -M4-M Apple M4 (10 cores) 32 GB 1.0 TB no_stock 1 days 0.038944334 6.0587615e-07 +M1-M Apple M1 (8 cores) 8.0 GB 256 GB no stock 1 days 0.03006371 3.635257e-07 +M4-S Apple M4 (10 cores) 16 GB 256 GB no stock 1 days 0.037783757 6.0587615e-07 +M2-M Apple M2 (8 cores) 16 GB 256 GB no stock 1 days 0.03742678 5.592703e-07 +M4-SP Apple M4 (10 cores) 16 GB 512 GB no stock 1 days 0.03799304 6.0587615e-07 +M4-M Apple M4 (10 cores) 32 GB 1.0 TB no stock 1 days 0.038944334 6.0587615e-07 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 [ { diff --git a/internal/namespaces/autoscaling/v1alpha2/custom.go b/internal/namespaces/autoscaling/v1alpha2/custom.go index f8e27e92ec..120a2df474 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom.go @@ -1,7 +1,11 @@ package autoscaling import ( + "reflect" + "github.com/scaleway/scaleway-cli/v2/core" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" + autoscaling "github.com/scaleway/scaleway-sdk-go/api/autoscaling/v1alpha2" ) func GetCommands() *core.Commands { @@ -17,5 +21,7 @@ func GetCommands() *core.Commands { commands.MustFind("autoscaling", "logs", "list").Override(LogsListBuilder) commands.MustFind("autoscaling", "servers", "list").Override(ServersListBuilder) + humanv2.RegisterLayout(reflect.TypeFor[autoscaling.Group](), groupLayout) + return commands } diff --git a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go index fe5997ea8c..ee25277c26 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go @@ -3,22 +3,19 @@ package autoscaling import ( "context" "fmt" - "reflect" "github.com/scaleway/scaleway-cli/v2/core" humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" autoscaling "github.com/scaleway/scaleway-sdk-go/api/autoscaling/v1alpha2" ) -func init() { - humanv2.SetLayout(reflect.TypeFor[autoscaling.Group](), humanv2.Layout{ - Sections: []humanv2.Section{ - {Path: "Tags", Omittable: true}, - {Path: "ScalingPolicy", Title: "Scaling Policy"}, - {Path: "OpenAlerts", Omittable: true, Title: "Open Alerts"}, - {Path: "LoadBalancerConfiguration", Omittable: true, Title: "LoadBalancer Configuration"}, - }, - }) +var groupLayout = humanv2.Layout{ + Sections: []humanv2.Section{ + {Path: "Tags", Omittable: true}, + {Path: "ScalingPolicy", Title: "Scaling Policy"}, + {Path: "OpenAlerts", Omittable: true, Title: "Open Alerts"}, + {Path: "LoadBalancerConfiguration", Omittable: true, Title: "LoadBalancer Configuration"}, + }, } func GroupCreateBuilder(c *core.Command) *core.Command { diff --git a/internal/namespaces/baremetal/v1/custom.go b/internal/namespaces/baremetal/v1/custom.go index 64cc6c204f..38e0fec8e7 100644 --- a/internal/namespaces/baremetal/v1/custom.go +++ b/internal/namespaces/baremetal/v1/custom.go @@ -1,8 +1,10 @@ package baremetal import ( + "reflect" + "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" baremetalV3 "github.com/scaleway/scaleway-cli/v2/internal/namespaces/baremetal/v3" baremetal "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" ) @@ -17,17 +19,10 @@ func GetCommands() *core.Commands { serverAddFlexibleIP(), )) - human.RegisterMarshalerFunc( - baremetal.ServerPingStatus(""), - human.EnumMarshalFunc(serverPingStatusMarshalSpecs), - ) - human.RegisterMarshalerFunc( - baremetal.OfferStock(""), - human.EnumMarshalFunc(offerAvailabilityMarshalSpecs), - ) - human.RegisterMarshalerFunc(baremetal.Server{}, serverMarshalerFunc) - - human.RegisterMarshalerFunc(baremetal.Offer{}, listOfferMarshalerFunc) + humanv2.RegisterLayout(reflect.TypeFor[baremetal.Server](), serverLayout) + humanv2.RegisterLayout(reflect.TypeFor[baremetal.Offer](), offerLayout) + humanv2.RegisterEnumFormat(offerStockFormat) + humanv2.RegisterEnumFormat(pingStatusFormat) cmds.MustFind("baremetal", "server", "create").Override(serverCreateBuilder) cmds.MustFind("baremetal", "server", "install").Override(serverInstallBuilder) diff --git a/internal/namespaces/baremetal/v1/custom_offer.go b/internal/namespaces/baremetal/v1/custom_offer.go index 390135e72b..2b6c93c5c8 100644 --- a/internal/namespaces/baremetal/v1/custom_offer.go +++ b/internal/namespaces/baremetal/v1/custom_offer.go @@ -5,67 +5,46 @@ import ( "slices" "strings" + "github.com/dustin/go-humanize" "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" productcatalog "github.com/scaleway/scaleway-sdk-go/api/product_catalog/v2alpha1" "github.com/scaleway/scaleway-sdk-go/scw" ) -var offerAvailabilityMarshalSpecs = human.EnumMarshalSpecs{ - baremetal.OfferStockEmpty: &human.EnumMarshalSpec{Attribute: color.FgRed, Value: "empty"}, - baremetal.OfferStockLow: &human.EnumMarshalSpec{Attribute: color.FgYellow, Value: "low"}, - baremetal.OfferStockAvailable: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "available", +var offerStockFormat = humanv2.EnumFormats[baremetal.OfferStock]{ + baremetal.OfferStockEmpty: humanv2.EnumFormat{Color: color.FgRed, Value: "empty"}, + baremetal.OfferStockLow: humanv2.EnumFormat{Color: color.FgYellow, Value: "low"}, + baremetal.OfferStockAvailable: humanv2.EnumFormat{Color: color.FgGreen, Value: "available"}, +} + +var offerLayout = humanv2.Layout{ + Sections: []humanv2.Section{ + {Path: "Disks", Title: "Disks"}, + {Path: "CPUs", Title: "CPUs"}, + {Path: "Memories", Title: "Memories"}, + {Path: "Options", Title: "Options"}, + {Path: "Bandwidth", Title: "Bandwidth(Mbit/s)"}, + {Path: "PrivateBandwidth", Title: "PrivateBandwidth(Mbit/s)"}, }, } -func listOfferMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { - type tmp baremetal.Offer - baremetalOffer := tmp(i.(baremetal.Offer)) - opt.Sections = []*human.MarshalSection{ - { - FieldName: "Disks", - Title: "Disks", - }, - { - FieldName: "CPUs", - Title: "CPUs", - }, - { - FieldName: "Memories", - Title: "Memories", - }, - { - FieldName: "Options", - Title: "Options", - }, - { - FieldName: "Bandwidth", - Title: "Bandwidth(Mbit/s)", - }, - { - FieldName: "PrivateBandwidth", - Title: "PrivateBandwidth(Mbit/s)", - }, - } - baremetalOffer.PrivateBandwidth /= 1000000 - baremetalOffer.Bandwidth /= 1000000 - str, err := human.Marshal(baremetalOffer, opt) - if err != nil { - return "", err - } +type Bandwidth uint64 - return str, nil +func (b Bandwidth) String() string { + return humanize.Bytes(uint64(b)) + "/s" } type customOffer struct { *baremetal.Offer - KgCo2Equivalent *float32 `json:"kg_co2_equivalent"` - M3WaterUsage *float32 `json:"m3_water_usage"` - CloudInitSupported bool `json:"cloud_init_supported"` + Bandwidth Bandwidth `json:"bandwidth"` + MaxBandwidth Bandwidth `json:"max_bandwidth"` + PrivateBandwidth Bandwidth `json:"private_bandwidth"` + KgCo2Equivalent *float32 `json:"kg_co2_equivalent"` + M3WaterUsage *float32 `json:"m3_water_usage"` + CloudInitSupported bool `json:"cloud_init_supported"` } func serverOfferListBuilder(c *core.Command) *core.Command { @@ -134,25 +113,21 @@ func serverOfferListBuilder(c *core.Command) *core.Command { var customOfferRes []customOffer for _, offer := range offers.Offers { - cloudInitSupported := slices.Contains(offer.Tags, "cloud-init") + custom := customOffer{ + Offer: offer, + Bandwidth: Bandwidth(offer.Bandwidth), + MaxBandwidth: Bandwidth(offer.MaxBandwidth), + PrivateBandwidth: Bandwidth(offer.PrivateBandwidth), + CloudInitSupported: slices.Contains(offer.Tags, "cloud-init"), + } + impact, ok := impactMap[offer.Name] - if !ok { - customOfferRes = append(customOfferRes, customOffer{ - Offer: offer, - KgCo2Equivalent: nil, - M3WaterUsage: nil, - CloudInitSupported: cloudInitSupported, - }) - - continue + if ok { + custom.KgCo2Equivalent = impact.EnvironmentalImpactEstimation.KgCo2Equivalent + custom.M3WaterUsage = impact.EnvironmentalImpactEstimation.M3WaterUsage } - customOfferRes = append(customOfferRes, customOffer{ - Offer: offer, - KgCo2Equivalent: impact.EnvironmentalImpactEstimation.KgCo2Equivalent, - M3WaterUsage: impact.EnvironmentalImpactEstimation.M3WaterUsage, - CloudInitSupported: cloudInitSupported, - }) + customOfferRes = append(customOfferRes, custom) } return customOfferRes, nil diff --git a/internal/namespaces/baremetal/v1/custom_server.go b/internal/namespaces/baremetal/v1/custom_server.go index 02b4f0b40a..30ff7ac26c 100644 --- a/internal/namespaces/baremetal/v1/custom_server.go +++ b/internal/namespaces/baremetal/v1/custom_server.go @@ -10,6 +10,7 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" baremetal "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" "github.com/scaleway/scaleway-sdk-go/logger" "github.com/scaleway/scaleway-sdk-go/scw" @@ -19,19 +20,10 @@ const ( ServerActionTimeout = 20 * time.Minute ) -var serverPingStatusMarshalSpecs = human.EnumMarshalSpecs{ - baremetal.ServerPingStatusPingStatusDown: &human.EnumMarshalSpec{ - Attribute: color.FgRed, - Value: "down", - }, - baremetal.ServerPingStatusPingStatusUp: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "up", - }, - baremetal.ServerPingStatusPingStatusUnknown: &human.EnumMarshalSpec{ - Attribute: color.Faint, - Value: "unknown", - }, +var pingStatusFormat = humanv2.EnumFormats[baremetal.ServerPingStatus]{ + baremetal.ServerPingStatusPingStatusDown: humanv2.EnumFormat{Color: color.FgRed, Value: "down"}, + baremetal.ServerPingStatusPingStatusUp: humanv2.EnumFormat{Color: color.FgGreen, Value: "up"}, + baremetal.ServerPingStatusPingStatusUnknown: humanv2.EnumFormat{Color: color.Faint, Value: "unknown"}, } func serverWaitCommand() *core.Command { @@ -236,6 +228,14 @@ func serverListBuilder(c *core.Command) *core.Command { return c } +var serverLayout = humanv2.Layout{ + Sections: []humanv2.Section{ + {Path: "IPs", Title: "IPs"}, + {Path: "Options", Title: "Options", Omittable: true}, + {Path: "Install", Title: "Install", Omittable: true}, + }, +} + func serverMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { type tmp baremetal.Server baremetalServer := tmp(i.(baremetal.Server)) diff --git a/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden b/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden index 069d0b34f8..a059c222b8 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden @@ -47,20 +47,20 @@ CAPACITY TYPE FREQUENCY IS ECC ECC TYPE 96 GB DDR3 1600 true standard Options: -ID NAME ENABLED SUBSCRIPTION PERIOD PRICE MANAGEABLE OS ID -31f78309-e6e0-4005-99b3-b3de3958ca7d HDS false monthly € 15.00 false 00000000-0000-0000-0000-000000000000 -c498071f-4f8d-49f3-bc52-afc219a48fe0 Windows 2022 datacenter false monthly € 168.00 false 3422ae02-d6d7-4980-8a00-eb38c262fb41 -931df052-d713-4674-8b58-96a63244c8e2 Remote Access false hourly € 0.00 true 00000000-0000-0000-0000-000000000000 -cd4158d7-2d65-49be-8803-c4b8ab6f760c Private Network false hourly € 0.03 true 00000000-0000-0000-0000-000000000000 -f42a040b-6c91-4b11-beb0-b0b21e641fda Windows 2019 standard false monthly € 25.20 false 7c70ff80-dbfa-4e07-be73-1de0e60fe175 -f74a6322-070e-41dd-a13f-8165ea63aca8 Windows 2019 datacenter false monthly € 168.00 false e04414b9-16d4-4ffb-8ba1-6b80704c7d2a -83a8fb91-47b4-4c20-82f9-84c31d79bfd8 Windows 2022 standard false monthly € 25.20 false bf705b90-1548-4368-98ca-d17dbd8dd860 +ID NAME ENABLED SUBSCRIPTION PERIOD PRICE MANAGEABLE OS ID LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION +31f78309-e6e0-4005-99b3-b3de3958ca7d HDS false monthly € 15.00 false 00000000-0000-0000-0000-000000000000 - - - - {} +c498071f-4f8d-49f3-bc52-afc219a48fe0 Windows 2022 datacenter false monthly € 168.00 false 3422ae02-d6d7-4980-8a00-eb38c262fb41 {3422ae02-d6d7-4980-8a00-eb38c262fb41} - - - - +931df052-d713-4674-8b58-96a63244c8e2 Remote Access false hourly € 0.00 true 00000000-0000-0000-0000-000000000000 - - - {} - +cd4158d7-2d65-49be-8803-c4b8ab6f760c Private Network false hourly € 0.03 true 00000000-0000-0000-0000-000000000000 - - {1000000000} - - +f42a040b-6c91-4b11-beb0-b0b21e641fda Windows 2019 standard false monthly € 25.20 false 7c70ff80-dbfa-4e07-be73-1de0e60fe175 {7c70ff80-dbfa-4e07-be73-1de0e60fe175} - - - - +f74a6322-070e-41dd-a13f-8165ea63aca8 Windows 2019 datacenter false monthly € 168.00 false e04414b9-16d4-4ffb-8ba1-6b80704c7d2a {e04414b9-16d4-4ffb-8ba1-6b80704c7d2a} - - - - +83a8fb91-47b4-4c20-82f9-84c31d79bfd8 Windows 2022 standard false monthly € 25.20 false bf705b90-1548-4368-98ca-d17dbd8dd860 {bf705b90-1548-4368-98ca-d17dbd8dd860} - - - - Bandwidth(Mbit/s): -1000 +1000000000 PrivateBandwidth(Mbit/s): -1000 +1000000000 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "c5853302-63e4-40c7-a711-4a91629565c8", diff --git a/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden b/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden index 7393db2503..7cbba107bf 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden @@ -1,50 +1,48 @@ 🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 🟩🟩🟩 STDOUT️ 🟩🟩🟩️ -ID Name Stock Disks CPUs Memories Bandwidth PrivateBandwidth CloudInit supported CO2 (kg/hour) Water (m³/hour) Price per hour -8779d2c1-cd10-4a34-a006-cb5b1fb5cbc7 EM-A116X-SSD available 2 1 1 500000000 1000000000 true 0.01281002 5.890525e-07 € 0.077 -e1680dec-52aa-4cbc-b70c-ef32d863fdb0 EM-A610R-NVME empty 2 1 1 500000000 1000000000 true - - € 0.11 -bd757ca3-a71b-4158-9ef5-39436b6db2a4 EM-L101X-SATA low 2 1 1 1000000000 1000000000 true 0.0155900745 7.3156525e-07 € 0.122 -6ec5bbb2-8267-41cd-aaf6-d43ea1b59372 EM-A315X-SSD empty 2 1 1 1000000000 1000000000 true 0.015545019 7.695686e-07 € 0.153 -5a505e81-5f6a-42bd-acec-0b290be92697 EM-A410X-SSD empty 2 1 1 1000000000 1000000000 true 0.015003014 6.650593e-07 € 0.153 -c5853302-63e4-40c7-a711-4a91629565c8 EM-L105X-SATA available 4 1 1 1000000000 1000000000 true 0.016983224 7.600678e-07 € 0.213 -0964171b-4716-4403-9f6a-2f40d4230fa5 EM-B130E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.247 -8c2cf291-a3d3-4dc5-967f-f498a088411d EM-B111X-SATA available 2 2 1 1000000000 1000000000 true 0.022749875 1.1115991e-06 € 0.263 -a5065ba4-dde2-45f3-adec-1ebbb27b766b EM-B112X-SSD available 2 2 1 1000000000 1000000000 true 0.02332065 1.1115991e-06 € 0.263 -835ef36b-9659-450b-9110-8119deae0e15 EM-B130E-NVME-64G empty 2 1 1 3000000000 10000000000 true - - € 0.315 -c753f736-fbb4-4689-ae93-623f9d08dce5 EM-B211X-SATA empty 2 2 1 1000000000 1000000000 false 0.024065403 1.0450932e-06 € 0.321 -ddaf8ba6-b2b2-4279-8af3-51930fb602f8 EM-B212X-SSD empty 2 2 1 1000000000 1000000000 true 0.026313491 1.2351102e-06 € 0.321 -0f93f4cd-29e1-4f28-ae8b-3cfd9729cd65 EM-B230E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.329 -206ea234-9097-4ae1-af68-6d2be09f47ed EM-B220E-NVME empty 2 1 1 1000000000 10000000000 true - - € 0.333 -a204136d-656b-44b7-9735-88ca2f62cb1f EM-L110X-SATA available 4 1 1 1000000000 1000000000 true 0.016983224 7.600678e-07 € 0.366 -0e1f7c5b-05bf-4863-ba58-9ee07496d1f7 EM-I120E-NVME low 2 1 1 1000000000 25000000000 true - - € 0.37 -e9dee4f8-c37b-42a2-8bc1-1409218973d5 EM-B230E-NVME-128G empty 2 1 1 3000000000 10000000000 true - - € 0.411 -a60ae97c-268c-40cb-af5f-dd276e917ed7 EM-B320E-NVME empty 2 1 1 1000000000 10000000000 true - - € 0.417 -6fb2a065-b4fd-4436-aebe-da0d2ecaf641 EM-B330E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.438 -d14eef5e-3279-42db-bb57-bb2bbe6a5b28 EM-B420E-NVME low 2 1 1 1000000000 10000000000 true - - € 0.444 -faaf5d66-99d3-46d0-a948-5c1b65caf2a3 EM-B330E-NVME-128G empty 2 1 1 3000000000 10000000000 true - - € 0.521 -d73011fa-02e0-46bc-992c-8bb62c767e97 EM-B520E-NVME low 2 1 1 1000000000 10000000000 true - - € 0.528 -1b46dfad-ec29-4f6e-af14-6af753e15630 EM-B430E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.548 -fa9c839b-c6de-401e-9156-e9b9016d712b EM-B430E-NVME-128G empty 2 1 1 3000000000 10000000000 true - - € 0.63 -fd7605a7-7493-49ba-9019-0e9c2897d4a0 EM-I220E-NVME low 2 1 1 1000000000 25000000000 true - - € 0.63 -7e81d4d4-a84b-4dd0-9f44-f0ac34a09e3a EM-I210E-NVME empty 2 1 1 1000000000 10000000000 true - - € 0.667 -402efe20-4dcb-49af-bc80-1008bcc749a6 EM-L220E-NVME low 4 1 1 1000000000 10000000000 true - - € 0.685 -ddb09834-8680-4339-b5e3-19ca5205fa1f EM-I215E-NVME available 2 1 1 1000000000 10000000000 true - - € 0.904 -66cdd2c4-664c-47af-b0ad-a70e12999f0e EM-I320E-NVME available 2 1 1 1000000000 25000000000 true - - € 0.959 -18137894-b9a7-4e7b-b026-518ab8a3e41e EM-L420E-NVME low 4 1 1 1000000000 10000000000 true - - € 1.014 -6e905149-d776-49c1-824d-8161f40968e9 EM-I420E-NVME low 2 1 1 1000000000 25000000000 true - - € 1.233 -526f75ed-4482-4e57-9c53-82f2983129ba EM-L520E-NVME empty 4 1 1 1000000000 25000000000 true - - € 1.233 -b20ba18e-34e8-44aa-b909-93cbff7635b5 EM-I520E-NVME empty 2 1 1 1000000000 25000000000 true - - € 1.562 -51131a3a-7a9b-44c7-8228-2c071c185b77 EM-I620E-NVME empty 2 1 1 1000000000 25000000000 true - - € 1.863 -c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 1 1000000000 25000000000 true - - € 2.055 -49c3ff54-7949-4a53-8f52-f2ea17c451b1 EM-I525E-NVME empty 4 1 1 1000000000 25000000000 true - - € 2.877 +ID Name Stock Disks CPUs Memories Bandwidth PrivateBandwidth CloudInit supported CO2 (kg/hour) Water (m³/hour) Price per hour +8779d2c1-cd10-4a34-a006-cb5b1fb5cbc7 EM-A116X-SSD available 2 1 1 500 MB/s 1.0 GB/s true 0.01281002 5.890525e-07 € 0.077 +e1680dec-52aa-4cbc-b70c-ef32d863fdb0 EM-A610R-NVME empty 2 1 1 500 MB/s 1.0 GB/s true - - € 0.11 +bd757ca3-a71b-4158-9ef5-39436b6db2a4 EM-L101X-SATA low 2 1 1 1.0 GB/s 1.0 GB/s true 0.0155900745 7.3156525e-07 € 0.122 +6ec5bbb2-8267-41cd-aaf6-d43ea1b59372 EM-A315X-SSD empty 2 1 1 1.0 GB/s 1.0 GB/s true 0.015545019 7.695686e-07 € 0.153 +5a505e81-5f6a-42bd-acec-0b290be92697 EM-A410X-SSD empty 2 1 1 1.0 GB/s 1.0 GB/s true 0.015003014 6.650593e-07 € 0.153 +c5853302-63e4-40c7-a711-4a91629565c8 EM-L105X-SATA available 4 1 1 1.0 GB/s 1.0 GB/s true 0.016983224 7.600678e-07 € 0.213 +0964171b-4716-4403-9f6a-2f40d4230fa5 EM-B130E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.247 +8c2cf291-a3d3-4dc5-967f-f498a088411d EM-B111X-SATA available 2 2 1 1.0 GB/s 1.0 GB/s true 0.022749875 1.1115991e-06 € 0.263 +a5065ba4-dde2-45f3-adec-1ebbb27b766b EM-B112X-SSD available 2 2 1 1.0 GB/s 1.0 GB/s true 0.02332065 1.1115991e-06 € 0.263 +835ef36b-9659-450b-9110-8119deae0e15 EM-B130E-NVME-64G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.315 +c753f736-fbb4-4689-ae93-623f9d08dce5 EM-B211X-SATA empty 2 2 1 1.0 GB/s 1.0 GB/s false 0.024065403 1.0450932e-06 € 0.321 +ddaf8ba6-b2b2-4279-8af3-51930fb602f8 EM-B212X-SSD empty 2 2 1 1.0 GB/s 1.0 GB/s true 0.026313491 1.2351102e-06 € 0.321 +0f93f4cd-29e1-4f28-ae8b-3cfd9729cd65 EM-B230E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.329 +206ea234-9097-4ae1-af68-6d2be09f47ed EM-B220E-NVME empty 2 1 1 1.0 GB/s 10 GB/s true - - € 0.333 +a204136d-656b-44b7-9735-88ca2f62cb1f EM-L110X-SATA available 4 1 1 1.0 GB/s 1.0 GB/s true 0.016983224 7.600678e-07 € 0.366 +0e1f7c5b-05bf-4863-ba58-9ee07496d1f7 EM-I120E-NVME low 2 1 1 1.0 GB/s 25 GB/s true - - € 0.37 +e9dee4f8-c37b-42a2-8bc1-1409218973d5 EM-B230E-NVME-128G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.411 +a60ae97c-268c-40cb-af5f-dd276e917ed7 EM-B320E-NVME empty 2 1 1 1.0 GB/s 10 GB/s true - - € 0.417 +6fb2a065-b4fd-4436-aebe-da0d2ecaf641 EM-B330E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.438 +d14eef5e-3279-42db-bb57-bb2bbe6a5b28 EM-B420E-NVME low 2 1 1 1.0 GB/s 10 GB/s true - - € 0.444 +faaf5d66-99d3-46d0-a948-5c1b65caf2a3 EM-B330E-NVME-128G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.521 +d73011fa-02e0-46bc-992c-8bb62c767e97 EM-B520E-NVME low 2 1 1 1.0 GB/s 10 GB/s true - - € 0.528 +1b46dfad-ec29-4f6e-af14-6af753e15630 EM-B430E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.548 +fa9c839b-c6de-401e-9156-e9b9016d712b EM-B430E-NVME-128G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.63 +fd7605a7-7493-49ba-9019-0e9c2897d4a0 EM-I220E-NVME low 2 1 1 1.0 GB/s 25 GB/s true - - € 0.63 +7e81d4d4-a84b-4dd0-9f44-f0ac34a09e3a EM-I210E-NVME empty 2 1 1 1.0 GB/s 10 GB/s true - - € 0.667 +402efe20-4dcb-49af-bc80-1008bcc749a6 EM-L220E-NVME low 4 1 1 1.0 GB/s 10 GB/s true - - € 0.685 +ddb09834-8680-4339-b5e3-19ca5205fa1f EM-I215E-NVME available 2 1 1 1.0 GB/s 10 GB/s true - - € 0.904 +66cdd2c4-664c-47af-b0ad-a70e12999f0e EM-I320E-NVME available 2 1 1 1.0 GB/s 25 GB/s true - - € 0.959 +18137894-b9a7-4e7b-b026-518ab8a3e41e EM-L420E-NVME low 4 1 1 1.0 GB/s 10 GB/s true - - € 1.014 +6e905149-d776-49c1-824d-8161f40968e9 EM-I420E-NVME low 2 1 1 1.0 GB/s 25 GB/s true - - € 1.233 +526f75ed-4482-4e57-9c53-82f2983129ba EM-L520E-NVME empty 4 1 1 1.0 GB/s 25 GB/s true - - € 1.233 +b20ba18e-34e8-44aa-b909-93cbff7635b5 EM-I520E-NVME empty 2 1 1 1.0 GB/s 25 GB/s true - - € 1.562 +51131a3a-7a9b-44c7-8228-2c071c185b77 EM-I620E-NVME empty 2 1 1 1.0 GB/s 25 GB/s true - - € 1.863 +c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 1 1.0 GB/s 25 GB/s true - - € 2.055 +49c3ff54-7949-4a53-8f52-f2ea17c451b1 EM-I525E-NVME empty 4 1 1 1.0 GB/s 25 GB/s true - - € 2.877 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 [ { "id": "8779d2c1-cd10-4a34-a006-cb5b1fb5cbc7", "name": "EM-A116X-SSD", "stock": "available", - "bandwidth": 500000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -164,7 +162,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "remote_access": {} } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -175,6 +172,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 500000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.01281002, "m3_water_usage": 5.890525e-7, "cloud_init_supported": true @@ -183,8 +183,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "e1680dec-52aa-4cbc-b70c-ef32d863fdb0", "name": "EM-A610R-NVME", "stock": "empty", - "bandwidth": 500000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -422,7 +420,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "new", @@ -434,6 +431,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 500000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -442,8 +442,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "bd757ca3-a71b-4158-9ef5-39436b6db2a4", "name": "EM-L101X-SATA", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -615,7 +613,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "certification": {} } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -626,6 +623,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.0155900745, "m3_water_usage": 7.3156525e-7, "cloud_init_supported": true @@ -634,8 +634,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "6ec5bbb2-8267-41cd-aaf6-d43ea1b59372", "name": "EM-A315X-SSD", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -742,7 +740,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "remote_access": {} } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -753,6 +750,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.015545019, "m3_water_usage": 7.695686e-7, "cloud_init_supported": true @@ -761,8 +761,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "5a505e81-5f6a-42bd-acec-0b290be92697", "name": "EM-A410X-SSD", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -959,7 +957,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -970,6 +967,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.015003014, "m3_water_usage": 6.650593e-7, "cloud_init_supported": true @@ -978,8 +978,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "c5853302-63e4-40c7-a711-4a91629565c8", "name": "EM-L105X-SATA", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -1159,7 +1157,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1170,6 +1167,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.016983224, "m3_water_usage": 7.600678e-7, "cloud_init_supported": true @@ -1178,8 +1178,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "0964171b-4716-4403-9f6a-2f40d4230fa5", "name": "EM-B130E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -1450,7 +1448,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1461,6 +1458,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -1469,8 +1469,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "8c2cf291-a3d3-4dc5-967f-f498a088411d", "name": "EM-B111X-SATA", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -1700,7 +1698,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1711,6 +1708,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.022749875, "m3_water_usage": 0.0000011115991, "cloud_init_supported": true @@ -1719,8 +1719,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "a5065ba4-dde2-45f3-adec-1ebbb27b766b", "name": "EM-B112X-SSD", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -1950,7 +1948,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1961,6 +1958,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.02332065, "m3_water_usage": 0.0000011115991, "cloud_init_supported": true @@ -1969,8 +1969,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "835ef36b-9659-450b-9110-8119deae0e15", "name": "EM-B130E-NVME-64G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -2241,7 +2239,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -2252,6 +2249,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -2260,8 +2260,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "c753f736-fbb4-4689-ae93-623f9d08dce5", "name": "EM-B211X-SATA", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -2482,7 +2480,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [], "gpus": [], @@ -2490,6 +2487,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.024065403, "m3_water_usage": 0.0000010450932, "cloud_init_supported": false @@ -2498,8 +2498,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "ddaf8ba6-b2b2-4279-8af3-51930fb602f8", "name": "EM-B212X-SSD", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -2720,7 +2718,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -2731,6 +2728,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.026313491, "m3_water_usage": 0.0000012351102, "cloud_init_supported": true @@ -2739,8 +2739,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "0f93f4cd-29e1-4f28-ae8b-3cfd9729cd65", "name": "EM-B230E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -3011,7 +3009,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -3022,6 +3019,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -3030,8 +3030,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "206ea234-9097-4ae1-af68-6d2be09f47ed", "name": "EM-B220E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -3324,7 +3322,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -3335,6 +3332,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -3343,8 +3343,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "a204136d-656b-44b7-9735-88ca2f62cb1f", "name": "EM-L110X-SATA", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -3524,7 +3522,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -3535,6 +3532,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.016983224, "m3_water_usage": 7.600678e-7, "cloud_init_supported": true @@ -3543,8 +3543,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "0e1f7c5b-05bf-4863-ba58-9ee07496d1f7", "name": "EM-I120E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -3890,7 +3888,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -3902,6 +3899,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -3910,8 +3910,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "e9dee4f8-c37b-42a2-8bc1-1409218973d5", "name": "EM-B230E-NVME-128G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -4182,7 +4180,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "certification": {} } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -4193,6 +4190,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -4201,8 +4201,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "a60ae97c-268c-40cb-af5f-dd276e917ed7", "name": "EM-B320E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -4495,7 +4493,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -4506,6 +4503,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -4514,8 +4514,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "6fb2a065-b4fd-4436-aebe-da0d2ecaf641", "name": "EM-B330E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -4786,7 +4784,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -4797,6 +4794,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -4805,8 +4805,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "d14eef5e-3279-42db-bb57-bb2bbe6a5b28", "name": "EM-B420E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5099,7 +5097,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -5110,6 +5107,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -5118,8 +5118,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "faaf5d66-99d3-46d0-a948-5c1b65caf2a3", "name": "EM-B330E-NVME-128G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5390,7 +5388,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -5401,6 +5398,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -5409,8 +5409,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "d73011fa-02e0-46bc-992c-8bb62c767e97", "name": "EM-B520E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5703,7 +5701,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -5714,6 +5711,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -5722,8 +5722,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "1b46dfad-ec29-4f6e-af14-6af753e15630", "name": "EM-B430E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5994,7 +5992,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -6005,6 +6002,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6013,8 +6013,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "fa9c839b-c6de-401e-9156-e9b9016d712b", "name": "EM-B430E-NVME-128G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -6285,7 +6283,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -6296,6 +6293,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6304,8 +6304,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "fd7605a7-7493-49ba-9019-0e9c2897d4a0", "name": "EM-I220E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -6651,7 +6649,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -6663,6 +6660,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6671,8 +6671,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "7e81d4d4-a84b-4dd0-9f44-f0ac34a09e3a", "name": "EM-I210E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -6956,7 +6954,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -6967,6 +6964,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6975,8 +6975,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "402efe20-4dcb-49af-bc80-1008bcc749a6", "name": "EM-L220E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -7263,7 +7261,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -7274,6 +7271,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -7282,8 +7282,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "ddb09834-8680-4339-b5e3-19ca5205fa1f", "name": "EM-I215E-NVME", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -7581,7 +7579,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -7592,6 +7589,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -7600,8 +7600,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "66cdd2c4-664c-47af-b0ad-a70e12999f0e", "name": "EM-I320E-NVME", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -7947,7 +7945,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -7959,6 +7956,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -7967,8 +7967,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "18137894-b9a7-4e7b-b026-518ab8a3e41e", "name": "EM-L420E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -8255,7 +8253,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -8266,6 +8263,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -8274,8 +8274,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "6e905149-d776-49c1-824d-8161f40968e9", "name": "EM-I420E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -8621,7 +8619,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -8633,6 +8630,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -8641,8 +8641,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "526f75ed-4482-4e57-9c53-82f2983129ba", "name": "EM-L520E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -8934,7 +8932,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -8945,6 +8942,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -8953,8 +8953,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "b20ba18e-34e8-44aa-b909-93cbff7635b5", "name": "EM-I520E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -9300,7 +9298,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "certification": {} } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -9312,6 +9309,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -9320,8 +9320,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "51131a3a-7a9b-44c7-8228-2c071c185b77", "name": "EM-I620E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -9667,7 +9665,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "remote_access": {} } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -9679,6 +9676,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -9687,8 +9687,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "c0069aba-31a5-401d-9bdb-58bcdd7859e6", "name": "EM-L620E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -9980,7 +9978,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -9991,6 +9988,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -9999,8 +9999,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "49c3ff54-7949-4a53-8f52-f2ea17c451b1", "name": "EM-I525E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -10354,7 +10352,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -10366,6 +10363,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true diff --git a/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden b/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden index bbf88c4524..7b5a392e63 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden @@ -23,7 +23,7 @@ d1516a55-d00f-4e88-9730-97ac4cc92ce2 51.159.215.183 - df2977be-2932-4611-8c9f-421020a52359 2001:bc8:1201:629:569f:35ff:fe17:e970 - IPv6 unknown - Options: -ID NAME STATUS MANAGEABLE EXPIRES AT +ID NAME STATUS MANAGEABLE EXPIRES AT LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "43cbe10c-49ee-43de-b528-0f6d18ef04af", diff --git a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden index 68a7499b74..80cb082f81 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden @@ -23,7 +23,7 @@ ID ADDRESS REV 8ffde507-fb75-4e29-968b-cd278d581766 2001:bc8:1201:a2c:d604:e6ff:fee8:6290 - IPv6 unknown - Options: -ID NAME STATUS MANAGEABLE EXPIRES AT +ID NAME STATUS MANAGEABLE EXPIRES AT LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION Install: OsID b9a016fc-947f-4bbc-bf95-343c8524535a diff --git a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden index 354009aec4..a647b69b40 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden @@ -23,7 +23,7 @@ ID ADDRESS REVE dd054664-8ac6-4623-9c70-c548b6b1682d 195.154.218.61 - IPv4 unknown - Options: -ID NAME STATUS MANAGEABLE EXPIRES AT +ID NAME STATUS MANAGEABLE EXPIRES AT LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION Install: OsID b9a016fc-947f-4bbc-bf95-343c8524535a