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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.2
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/x/term v0.2.2
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/pb33f/libopenapi v0.34.2
github.com/spf13/cobra v1.10.2
Expand All @@ -28,7 +29,6 @@ require (
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
Expand Down
120 changes: 120 additions & 0 deletions internal/output/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,126 @@ func TestCSVFormatterComplexValues(t *testing.T) {
}
}

// TestTruncate verifies the truncate helper.
func TestTruncate(t *testing.T) {
cases := []struct {
name string
s string
max int
want string
}{
{"fits", "hello", 10, "hello"},
{"exact", "hello", 5, "hello"},
{"trunc", "hello world", 5, "hell\u2026"},
{"min", "hello", 1, "h"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := truncate(tc.s, tc.max)
if got != tc.want {
t.Errorf("truncate(%q, %d) = %q, want %q", tc.s, tc.max, got, tc.want)
}
})
}
}

// TestBudgetColumns verifies column budget distribution.
func TestBudgetColumns(t *testing.T) {
t.Run("fits", func(t *testing.T) {
widths := []int{10, 10, 10}
budgetColumns(widths, 80)
total := widths[0] + widths[1] + widths[2] + 4 // 2 gaps * 2
if total > 80 {
t.Errorf("expected total <= 80, got %d", total)
}
// Should be unchanged since they fit.
if widths[0] != 10 || widths[1] != 10 || widths[2] != 10 {
t.Errorf("expected no shrinking, got %v", widths)
}
})

t.Run("shrinks widest", func(t *testing.T) {
widths := []int{10, 50, 10}
budgetColumns(widths, 40)
total := widths[0] + widths[1] + widths[2] + 4
if total > 40 {
t.Errorf("expected total <= 40, got %d", total)
}
})

t.Run("respects minimum", func(t *testing.T) {
widths := []int{10, 10, 10}
budgetColumns(widths, 10) // impossibly tight
for _, w := range widths {
if w < colMin {
t.Errorf("column shrunk below colMin: %d", w)
}
}
})
}

// TestTableFormatterTruncatesLongValues verifies table output truncates long nested values.
func TestTableFormatterTruncatesLongValues(t *testing.T) {
var buf bytes.Buffer
const testWidth = 80
f := &TableFormatter{
w: &buf,
fields: []string{"id", "address"},
widthFn: func() int { return testWidth },
}

longAddress := map[string]any{
"line1": "122 E Houston St",
"city": "San Antonio",
"state": "TX",
"postal_code": "78205",
"country": "US",
}
resp := &spec.APIResponse{
StatusCode: 200,
Success: true,
Data: []any{
map[string]any{"id": "1", "address": longAddress},
},
}

if err := f.Format(resp); err != nil {
t.Fatalf("Format returned error: %v", err)
}

out := buf.String()
lines := strings.Split(strings.TrimSpace(out), "\n")
for _, line := range lines {
// Strip ANSI escape sequences and measure rune count.
plain := stripAnsi(line)
runeCount := len([]rune(plain))
if runeCount > testWidth {
t.Errorf("line exceeds %d runes (%d): %s", testWidth, runeCount, plain)
}
}
}

// stripAnsi removes ANSI escape sequences for measuring plain text width.
func stripAnsi(s string) string {
var out strings.Builder
inEsc := false
for _, r := range s {
if r == '\033' {
inEsc = true
continue
}
if inEsc {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
inEsc = false
}
continue
}
out.WriteRune(r)
}
return out.String()
}

// TestExtractRows verifies the extractRows helper handles different data shapes.
func TestExtractRows(t *testing.T) {
t.Run("slice of any", func(t *testing.T) {
Expand Down
24 changes: 22 additions & 2 deletions internal/output/rows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package output
import (
"encoding/json"
"fmt"
"sort"
)

// extractRows normalizes data into a slice of row maps and an ordered list of
Expand All @@ -28,26 +29,45 @@ func extractRows(data any, selectedFields []string) ([]map[string]any, []string)
case map[string]any:
rows = []map[string]any{v}
default:
// Unsupported shape return a single row with the stringified value.
// Unsupported shape -- return a single row with the stringified value.
rows = []map[string]any{{"value": fmt.Sprintf("%v", data)}}
}

// Determine column order.
fields := selectedFields
if len(fields) == 0 && len(rows) > 0 {
// Auto-detect from the first row, preserving insertion order.
// Auto-detect from the first row. Sort scalars before complex types
// so the most useful columns appear first in the table.
seen := map[string]bool{}
for k := range rows[0] {
if !seen[k] {
fields = append(fields, k)
seen[k] = true
}
}
first := rows[0]
sort.SliceStable(fields, func(i, j int) bool {
iComplex := isComplex(first[fields[i]])
jComplex := isComplex(first[fields[j]])
if iComplex != jComplex {
return !iComplex // scalars first
}
return false // preserve relative order
})
}

return rows, fields
}

// isComplex returns true for maps and slices (nested/array values).
func isComplex(v any) bool {
switch v.(type) {
case map[string]any, []any:
return true
}
return false
}

// formatValue converts a value to a human-readable string suitable for table
// and CSV output. Maps and slices are serialized as compact JSON instead of
// Go's default fmt representation. Nil values render as an empty string.
Expand Down
Loading
Loading