From c927e04f6c7e851b1455e3afb7858454fba8817b Mon Sep 17 00:00:00 2001 From: Oleg Isakov Date: Tue, 18 Aug 2026 13:59:59 +0300 Subject: [PATCH] improve-fields-format fields format improve: support deltas via +/- --- cmd/base/flags.go | 2 +- cmd/base/hooks.go | 19 ++++++ cmd/base/hooks_test.go | 16 +++++ internal/output/entities/interface.go | 22 ++++++- internal/output/entities/interface_test.go | 36 ++++++++++ internal/output/utils.go | 36 ++++++++++ internal/output/utils_test.go | 76 ++++++++++++++++++++++ 7 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 cmd/base/hooks_test.go create mode 100644 internal/output/entities/interface_test.go create mode 100644 internal/output/utils_test.go diff --git a/cmd/base/flags.go b/cmd/base/flags.go index 3baeed1..af31153 100644 --- a/cmd/base/flags.go +++ b/cmd/base/flags.go @@ -15,7 +15,7 @@ func AddGlobalFlags(cmd *cobra.Command) { } func AddFormatFlags(cmd *cobra.Command) { - cmd.PersistentFlags().StringArrayP("field", "f", []string{}, "output only these fields, can be specified multiple times") + cmd.PersistentFlags().StringArrayP("field", "f", []string{}, "output only these fields, can be specified multiple times; prefix with + or - to add/remove from the default fields instead of replacing them") cmd.PersistentFlags().Bool("field-list", false, "list available fields") cmd.PersistentFlags().Bool("page-view", false, "use page view format") cmd.PersistentFlags().StringP("template", "t", "", "go template string to output in specified format") diff --git a/cmd/base/hooks.go b/cmd/base/hooks.go index 13c7a9f..f76efff 100644 --- a/cmd/base/hooks.go +++ b/cmd/base/hooks.go @@ -114,6 +114,9 @@ func CheckFormatterFlags(cmdContext *CmdContext, entities map[string]entities.En return err } if len(fields) > 0 { + if err := validateFieldPrefixConsistency(fields); err != nil { + return err + } if err := entity.Validate(fields); err != nil { return err } @@ -123,6 +126,22 @@ func CheckFormatterFlags(cmdContext *CmdContext, entities map[string]entities.En } } +// validateFieldPrefixConsistency ensures +/- prefixed field names (add/remove +// relative to the default set) are not mixed with plain field names +// (full replacement) in the same --field invocation. +func validateFieldPrefixConsistency(fields []string) error { + prefixed := 0 + for _, f := range fields { + if entities.HasFieldPrefix(f) { + prefixed++ + } + } + if prefixed > 0 && prefixed != len(fields) { + return fmt.Errorf("cannot mix plain field names with +/- prefixed field names in --field") + } + return nil +} + // CheckEmptyContexts returns error if no contexts found func CheckEmptyContexts(cmdContext *CmdContext) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { diff --git a/cmd/base/hooks_test.go b/cmd/base/hooks_test.go new file mode 100644 index 0000000..9f59345 --- /dev/null +++ b/cmd/base/hooks_test.go @@ -0,0 +1,16 @@ +package base + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestValidateFieldPrefixConsistency(t *testing.T) { + g := NewWithT(t) + + g.Expect(validateFieldPrefixConsistency([]string{"Name", "Fingerprint"})).To(BeNil()) + g.Expect(validateFieldPrefixConsistency([]string{"+Name", "-Fingerprint"})).To(BeNil()) + g.Expect(validateFieldPrefixConsistency([]string{"Name", "+Fingerprint"})).To(HaveOccurred()) + g.Expect(validateFieldPrefixConsistency([]string{"+Name", "Fingerprint"})).To(HaveOccurred()) +} diff --git a/internal/output/entities/interface.go b/internal/output/entities/interface.go index 142d34c..91ec378 100644 --- a/internal/output/entities/interface.go +++ b/internal/output/entities/interface.go @@ -113,14 +113,17 @@ func (e *Entity) GetFields() []Field { return e.fields } -// Validate checks that all fields match available ones +// Validate checks that all fields match available ones. A leading +/- +// prefix (used to add/remove a field relative to the default set) is +// stripped before matching. func (e *Entity) Validate(fields []string) error { availableFields := e.GetFields() for _, f := range fields { + id := StripFieldPrefix(f) found := false for _, af := range availableFields { - if af.ID == f || e.fieldInChildFields(af.ChildFields, f) { + if af.ID == id || e.fieldInChildFields(af.ChildFields, id) { found = true break } @@ -133,6 +136,21 @@ func (e *Entity) Validate(fields []string) error { return nil } +// HasFieldPrefix reports whether the field name is prefixed with + or -, +// meaning it should be added to/removed from the default field set +// instead of replacing it outright. +func HasFieldPrefix(name string) bool { + return len(name) > 0 && (name[0] == '+' || name[0] == '-') +} + +// StripFieldPrefix removes a leading +/- prefix from a field name, if present. +func StripFieldPrefix(name string) string { + if HasFieldPrefix(name) { + return name[1:] + } + return name +} + // GetCmdDefaultFields returns the default field IDs to show for the given // command. If the command has an explicit set in cmdDefaultFields it is returned, otherwise it // falls back to the registration-time defaults (fields marked Field.Default). diff --git a/internal/output/entities/interface_test.go b/internal/output/entities/interface_test.go new file mode 100644 index 0000000..5e5dec3 --- /dev/null +++ b/internal/output/entities/interface_test.go @@ -0,0 +1,36 @@ +package entities + +import ( + "testing" + + . "github.com/onsi/gomega" + serverscom "github.com/serverscom/serverscom-go-client/pkg" +) + +func TestHasFieldPrefix(t *testing.T) { + g := NewWithT(t) + + g.Expect(HasFieldPrefix("+Name")).To(BeTrue()) + g.Expect(HasFieldPrefix("-Name")).To(BeTrue()) + g.Expect(HasFieldPrefix("Name")).To(BeFalse()) + g.Expect(HasFieldPrefix("")).To(BeFalse()) +} + +func TestStripFieldPrefix(t *testing.T) { + g := NewWithT(t) + + g.Expect(StripFieldPrefix("+Name")).To(Equal("Name")) + g.Expect(StripFieldPrefix("-Name")).To(Equal("Name")) + g.Expect(StripFieldPrefix("Name")).To(Equal("Name")) +} + +func TestEntityValidateWithFieldPrefix(t *testing.T) { + g := NewWithT(t) + + entity, err := Registry.GetEntityFromValue(serverscom.SSHKey{}) + g.Expect(err).To(BeNil()) + + g.Expect(entity.Validate([]string{"+Fingerprint"})).To(BeNil()) + g.Expect(entity.Validate([]string{"-Name"})).To(BeNil()) + g.Expect(entity.Validate([]string{"+Unknown"})).To(HaveOccurred()) +} diff --git a/internal/output/utils.go b/internal/output/utils.go index 7f41f10..187ffec 100644 --- a/internal/output/utils.go +++ b/internal/output/utils.go @@ -43,6 +43,9 @@ func (f *Formatter) printField(w *tabwriter.Writer, field entities.Field) { // getOrderedFields returns ordered fields based on the configuration. // Returns default fields if no fieldsToShow are provided for list mode. // Returns all available fields if no fieldsToShow are provided for page-view mode. +// If fieldsToShow are +/- prefixed, they are applied as additions/removals to +// the command's default fields instead of replacing them outright; this is a +// no-op in page-view mode, which always shows all fields. func (f *Formatter) getOrderedFields(entity entities.EntityInterface) []entities.Field { fieldsToShow := f.fieldsToShow @@ -51,6 +54,11 @@ func (f *Formatter) getOrderedFields(entity entities.EntityInterface) []entities return entity.GetFields() } fieldsToShow = entity.GetCmdDefaultFields(f.cmdName) + } else if entities.HasFieldPrefix(fieldsToShow[0]) { + if f.pageView { + return entity.GetFields() + } + fieldsToShow = applyFieldDeltas(entity.GetCmdDefaultFields(f.cmdName), fieldsToShow) } availableFields := entity.GetFields() @@ -65,6 +73,34 @@ func (f *Formatter) getOrderedFields(entity entities.EntityInterface) []entities return orderedFields } +// applyFieldDeltas applies +/- prefixed field deltas on top of the base +// field list, preserving the base order and appending additions at the end +// in the order they were given. +func applyFieldDeltas(base []string, deltas []string) []string { + result := append([]string{}, base...) + + for _, d := range deltas { + id := entities.StripFieldPrefix(d) + idx := -1 + for i, existing := range result { + if existing == id { + idx = i + break + } + } + + if d[0] == '-' { + if idx >= 0 { + result = append(result[:idx], result[idx+1:]...) + } + } else if idx < 0 { + result = append(result, id) + } + } + + return result +} + // findFieldByID recursively finds a field by its ID in the given fields slice. func (f *Formatter) findFieldByID(fields []entities.Field, fieldID string) *entities.Field { for _, field := range fields { diff --git a/internal/output/utils_test.go b/internal/output/utils_test.go new file mode 100644 index 0000000..5f4d436 --- /dev/null +++ b/internal/output/utils_test.go @@ -0,0 +1,76 @@ +package output + +import ( + "testing" + + . "github.com/onsi/gomega" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + "github.com/serverscom/srvctl/internal/output/entities" +) + +func sshKeyEntity(g *WithT) entities.EntityInterface { + entity, err := entities.Registry.GetEntityFromValue(serverscom.SSHKey{}) + g.Expect(err).To(BeNil()) + return entity +} + +func fieldIDs(fields []entities.Field) []string { + ids := make([]string, 0, len(fields)) + for _, f := range fields { + ids = append(ids, f.ID) + } + return ids +} + +func TestGetOrderedFieldsPlain(t *testing.T) { + g := NewWithT(t) + entity := sshKeyEntity(g) + + f := &Formatter{cmdName: "list"} + g.Expect(fieldIDs(f.getOrderedFields(entity))).To(Equal([]string{"Name", "Fingerprint"})) + + f = &Formatter{cmdName: "list", fieldsToShow: []string{"Fingerprint"}} + g.Expect(fieldIDs(f.getOrderedFields(entity))).To(Equal([]string{"Fingerprint"})) +} + +func TestGetOrderedFieldsAdd(t *testing.T) { + g := NewWithT(t) + entity := sshKeyEntity(g) + + f := &Formatter{cmdName: "list", fieldsToShow: []string{"+Created"}} + g.Expect(fieldIDs(f.getOrderedFields(entity))).To(Equal([]string{"Name", "Fingerprint", "Created"})) +} + +func TestGetOrderedFieldsRemove(t *testing.T) { + g := NewWithT(t) + entity := sshKeyEntity(g) + + f := &Formatter{cmdName: "list", fieldsToShow: []string{"-Fingerprint"}} + g.Expect(fieldIDs(f.getOrderedFields(entity))).To(Equal([]string{"Name"})) +} + +func TestGetOrderedFieldsAddAndRemove(t *testing.T) { + g := NewWithT(t) + entity := sshKeyEntity(g) + + f := &Formatter{cmdName: "list", fieldsToShow: []string{"+Created", "-Fingerprint"}} + g.Expect(fieldIDs(f.getOrderedFields(entity))).To(Equal([]string{"Name", "Created"})) +} + +func TestGetOrderedFieldsAddIgnoredInPageView(t *testing.T) { + g := NewWithT(t) + entity := sshKeyEntity(g) + + f := &Formatter{cmdName: "list", pageView: true, fieldsToShow: []string{"+Created"}} + g.Expect(fieldIDs(f.getOrderedFields(entity))).To(Equal(fieldIDs(entity.GetFields()))) +} + +func TestApplyFieldDeltas(t *testing.T) { + g := NewWithT(t) + + g.Expect(applyFieldDeltas([]string{"A", "B"}, []string{"+C"})).To(Equal([]string{"A", "B", "C"})) + g.Expect(applyFieldDeltas([]string{"A", "B"}, []string{"-A"})).To(Equal([]string{"B"})) + g.Expect(applyFieldDeltas([]string{"A", "B"}, []string{"+B"})).To(Equal([]string{"A", "B"})) + g.Expect(applyFieldDeltas([]string{"A", "B"}, []string{"-C"})).To(Equal([]string{"A", "B"})) + g.Expect(applyFieldDeltas([]string{"A", "B"}, []string{"-A", "+A"})).To(Equal([]string{"B", "A"})) +}