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
13 changes: 2 additions & 11 deletions cmd/kosli/getControl.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,8 @@ func printControlAsTable(raw string, out io.Writer, page int) error {
rows = append(rows, fmt.Sprintf("Created at:\t%s", createdAtFormatted))
}

if tags, ok := control["tags"].(map[string]any); ok && len(tags) > 0 {
tagKeys := make([]string, 0, len(tags))
for key := range tags {
tagKeys = append(tagKeys, key)
}
sort.Strings(tagKeys)
tagPairs := make([]string, 0, len(tags))
for _, key := range tagKeys {
tagPairs = append(tagPairs, fmt.Sprintf("%s=%s", key, tags[key]))
}
rows = append(rows, fmt.Sprintf("Tags:\t%s", strings.Join(tagPairs, ", ")))
if tagsOutput := formatPlainTags(control["tags"]); tagsOutput != "" {
rows = append(rows, fmt.Sprintf("Tags:\t%s", tagsOutput))
}

if links, ok := control["links"].(map[string]any); ok && len(links) > 0 {
Expand Down
8 changes: 1 addition & 7 deletions cmd/kosli/getEnvironment.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"io"
"net/http"
"net/url"
"strings"

"github.com/kosli-dev/cli/internal/output"
"github.com/kosli-dev/cli/internal/requests"
Expand Down Expand Up @@ -86,12 +85,7 @@ func printEnvironmentAsTable(raw string, out io.Writer, page int) error {
state = "NON-COMPLIANT"
}

tags := env["tags"].(map[string]any)
tagsOutput := ""
for key, value := range tags {
tagsOutput += fmt.Sprintf("[%s=%s], ", key, value)
}
tagsOutput = strings.TrimSuffix(tagsOutput, ", ")
tagsOutput := formatTags(env["tags"])
if tagsOutput == "" {
tagsOutput = "None"
}
Expand Down
9 changes: 1 addition & 8 deletions cmd/kosli/getFlow.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,7 @@ func printFlowAsTable(raw string, out io.Writer, page int) error {
template = strings.ReplaceAll(template, " ", ", ")
}

tagsOutput := ""
if flow["tags"] != nil {
tags := flow["tags"].(map[string]any)
for key, value := range tags {
tagsOutput += fmt.Sprintf("[%s=%s], ", key, value)
}
}
tagsOutput = strings.TrimSuffix(tagsOutput, ", ")
tagsOutput := formatTags(flow["tags"])
if tagsOutput == "" {
tagsOutput = "None"
}
Expand Down
24 changes: 1 addition & 23 deletions cmd/kosli/getRepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"io"
"net/http"
neturl "net/url"
"sort"
"strings"

"github.com/kosli-dev/cli/internal/output"
"github.com/kosli-dev/cli/internal/requests"
Expand Down Expand Up @@ -163,7 +161,7 @@ func printRepoAsTable(raw string, out io.Writer, page int) error {
return err
}

tagsOutput := formatRepoTags(repo["tags"])
tagsOutput := formatPlainTags(repo["tags"])
if tagsOutput == "" {
tagsOutput = "None"
}
Expand All @@ -178,23 +176,3 @@ func printRepoAsTable(raw string, out io.Writer, page int) error {
tabFormattedPrint(out, []string{}, rows)
return nil
}

// formatRepoTags renders a repo's tags map as sorted "key=value" pairs, or ""
// when there are no tags. Following the flow/env table conventions, list
// columns show the empty string while the get repo detail view shows "None".
func formatRepoTags(rawTags any) string {
tags, ok := rawTags.(map[string]any)
if !ok || len(tags) == 0 {
return ""
}
keys := make([]string, 0, len(tags))
for key := range tags {
keys = append(keys, key)
}
sort.Strings(keys)
pairs := make([]string, 0, len(tags))
for _, key := range keys {
pairs = append(pairs, fmt.Sprintf("%s=%v", key, tags[key]))
}
return strings.Join(pairs, ", ")
}
7 changes: 0 additions & 7 deletions cmd/kosli/getRepo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,6 @@ func TestGetRepoCommandTestSuite(t *testing.T) {
suite.Run(t, new(GetRepoCommandTestSuite))
}

func TestFormatRepoTags(t *testing.T) {
require.Equal(t, "", formatRepoTags(nil))
require.Equal(t, "", formatRepoTags(map[string]any{}))
require.Equal(t, "", formatRepoTags("not-a-map"))
require.Equal(t, "a=1, b=x", formatRepoTags(map[string]any{"b": "x", "a": float64(1)}))
}

func TestPrintRepoAsTableRendersNonStringValues(t *testing.T) {
// ids and tag values are rendered with %v so a numeric id from the
// server prints as a number instead of a %!s(float64=...) artifact
Expand Down
9 changes: 1 addition & 8 deletions cmd/kosli/listEnvironments.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/kosli-dev/cli/internal/output"
Expand Down Expand Up @@ -202,13 +201,7 @@ func printEnvListAsTable(raw string, out io.Writer, page int) error {
last_modified_str = time.Unix(int64(last_modified_at.(float64)), 0).Format(time.RFC3339)
}

tagsOutput := ""
if tags, ok := env["tags"].(map[string]any); ok {
for key, value := range tags {
tagsOutput += fmt.Sprintf("[%s=%s], ", key, value)
}
tagsOutput = strings.TrimSuffix(tagsOutput, ", ")
}
tagsOutput := formatTags(env["tags"])

var policies []any
if env["policies"] != nil {
Expand Down
8 changes: 1 addition & 7 deletions cmd/kosli/listFlows.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,7 @@ func printFlowsListAsTable(raw string, out io.Writer, page int) error {
header := []string{"NAME", "DESCRIPTION", "TAGS"}
rows := []string{}
for _, flow := range flows {
tagsOutput := ""
if tags, ok := flow["tags"].(map[string]any); ok {
for key, value := range tags {
tagsOutput += fmt.Sprintf("[%s=%s], ", key, value)
}
}
tagsOutput = strings.TrimSuffix(tagsOutput, ", ")
tagsOutput := formatTags(flow["tags"])
row := fmt.Sprintf("%s\t%s\t%s", flow["name"], flow["description"], tagsOutput)
rows = append(rows, row)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/kosli/listRepos.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func printReposListAsTable(raw string, out io.Writer, page int) error {
header := []string{"NAME", "URL", "PROVIDER", "TAGS"}
rows := []string{}
for _, repo := range response.Repos {
row := fmt.Sprintf("%v\t%v\t%v\t%s", repo["name"], repo["url"], repo["provider"], formatRepoTags(repo["tags"]))
row := fmt.Sprintf("%v\t%v\t%v\t%s", repo["name"], repo["url"], repo["provider"], formatPlainTags(repo["tags"]))
rows = append(rows, row)
}

Expand Down
45 changes: 45 additions & 0 deletions cmd/kosli/tableHelpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"fmt"
"slices"
"strings"
)

// sortedTagPairs renders a tags map as "key=value" pairs ordered by key, so map
// iteration order cannot leak into table output. A missing or non-map value
// yields no pairs.
func sortedTagPairs(rawTags any) []string {
tags, ok := rawTags.(map[string]any)
if !ok || len(tags) == 0 {
return nil
}
keys := make([]string, 0, len(tags))
for key := range tags {
keys = append(keys, key)
}
slices.Sort(keys)
pairs := make([]string, 0, len(tags))
for _, key := range keys {
pairs = append(pairs, fmt.Sprintf("%s=%v", key, tags[key]))
}
return pairs
}

// formatTags renders a tags map as "[key=value], [key=value]" ordered by key,
// or "" when there are no tags. Detail views substitute "None" for the empty
// string; list columns leave it blank.
func formatTags(rawTags any) string {
pairs := sortedTagPairs(rawTags)
bracketed := make([]string, 0, len(pairs))
for _, pair := range pairs {
bracketed = append(bracketed, "["+pair+"]")
}
return strings.Join(bracketed, ", ")
}

// formatPlainTags renders a tags map as "key=value, key=value" ordered by key,
// or "" when there are no tags.
func formatPlainTags(rawTags any) string {
return strings.Join(sortedTagPairs(rawTags), ", ")
}
130 changes: 130 additions & 0 deletions cmd/kosli/tableHelpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package main

import (
"bytes"
"testing"

"github.com/stretchr/testify/require"
)

func TestSortedTagPairs(t *testing.T) {
for _, tc := range []struct {
name string
tags any
want []string
}{
{
name: "pairs are ordered by key, not by map iteration order",
tags: map[string]any{"team": "platform", "env": "prod", "app": "api"},
want: []string{"app=api", "env=prod", "team=platform"},
},
{
name: "non-string values are rendered as values, not as %!s verbs",
tags: map[string]any{"replicas": float64(3), "critical": true},
want: []string{"critical=true", "replicas=3"},
},
{
name: "nil tags yield no pairs",
tags: nil,
want: nil,
},
{
name: "a non-map yields no pairs",
tags: "team=platform",
want: nil,
},
{
name: "an empty map yields no pairs",
tags: map[string]any{},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, sortedTagPairs(tc.tags))
})
}
}

func TestFormatTags(t *testing.T) {
for _, tc := range []struct {
name string
tags any
want string
}{
{
name: "tags are bracketed, comma separated and ordered by key",
tags: map[string]any{"team": "platform", "env": "prod"},
want: "[env=prod], [team=platform]",
},
{
name: "no tags yields the empty string",
tags: nil,
want: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, formatTags(tc.tags))
})
}
}

// Each subtest asserts the full rendering, tab padding included, so a change in
// column width cannot pass silently.
func TestTagRenderingIsSortedAcrossPrinters(t *testing.T) {
const tags = `{"team":"platform","env":"prod","app":"api"}`

t.Run("printFlowAsTable", func(t *testing.T) {
raw := `{"name":"backend","description":"Backend service","template":"artifact","last_deployment_at":null,"tags":` + tags + `}`
var buf bytes.Buffer
require.NoError(t, printFlowAsTable(raw, &buf, 0))
require.Equal(t, "Name: backend\n"+
"Description: Backend service\n"+
"Template: artifact\n"+
"Last Deployment At: N/A\n"+
"Tags: [app=api], [env=prod], [team=platform]\n", buf.String())
})

t.Run("printEnvironmentAsTable", func(t *testing.T) {
raw := `{"name":"prod","type":"K8S","description":"","state":true,"last_reported_at":null,"tags":` + tags + `}`
var buf bytes.Buffer
require.NoError(t, printEnvironmentAsTable(raw, &buf, 0))
require.Equal(t, "Name: prod\n"+
"Type: K8S\n"+
"Description: \n"+
"State: COMPLIANT\n"+
"Last Reported At: N/A\n"+
"Tags: [app=api], [env=prod], [team=platform]\n"+
"Policies: []\n", buf.String())
})

t.Run("printFlowsListAsTable", func(t *testing.T) {
raw := `[{"name":"backend","description":"Backend service","tags":` + tags + `}]`
var buf bytes.Buffer
require.NoError(t, printFlowsListAsTable(raw, &buf, 1))
require.Equal(t, "NAME DESCRIPTION TAGS\n"+
"backend Backend service [app=api], [env=prod], [team=platform]\n", buf.String())
})

t.Run("printEnvListAsTable", func(t *testing.T) {
raw := `[{"name":"prod","type":"K8S","last_reported_at":null,"last_modified_at":null,"tags":` + tags + `}]`
var buf bytes.Buffer
require.NoError(t, printEnvListAsTable(raw, &buf, 1))
require.Equal(t, "NAME TYPE LAST REPORT LAST MODIFIED TAGS POLICIES\n"+
"prod K8S [app=api], [env=prod], [team=platform] []\n", buf.String())
})
}

// An absent or null tags value must render as "None" rather than panic.
func TestPrintEnvironmentAsTableWithoutTags(t *testing.T) {
raw := `{"name":"prod","type":"K8S","description":"","state":true,"last_reported_at":null}`
var buf bytes.Buffer
require.NoError(t, printEnvironmentAsTable(raw, &buf, 0))
require.Contains(t, buf.String(), "Tags: None\n")
}

func TestFormatPlainTags(t *testing.T) {
require.Equal(t, "", formatPlainTags(nil))
require.Equal(t, "", formatPlainTags(map[string]any{}))
require.Equal(t, "", formatPlainTags("not-a-map"))
require.Equal(t, "a=1, b=x", formatPlainTags(map[string]any{"b": "x", "a": float64(1)}))
}
Loading