Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/base/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions cmd/base/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions cmd/base/hooks_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
22 changes: 20 additions & 2 deletions internal/output/entities/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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).
Expand Down
36 changes: 36 additions & 0 deletions internal/output/entities/interface_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
36 changes: 36 additions & 0 deletions internal/output/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand All @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions internal/output/utils_test.go
Original file line number Diff line number Diff line change
@@ -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"}))
}