diff --git a/go.mod b/go.mod index b821d9d..8bc6053 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index f6e42c3..6f3e8db 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -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) { diff --git a/internal/output/rows.go b/internal/output/rows.go index b8d506a..90ca479 100644 --- a/internal/output/rows.go +++ b/internal/output/rows.go @@ -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 @@ -28,14 +29,15 @@ 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] { @@ -43,11 +45,29 @@ func extractRows(data any, selectedFields []string) ([]map[string]any, []string) 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. diff --git a/internal/output/table.go b/internal/output/table.go index d8e0173..28fde5e 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -4,17 +4,28 @@ package output import ( "fmt" "io" + "os" "strings" + "unicode/utf8" "charm.land/lipgloss/v2" "github.com/apideck-io/cli/internal/spec" "github.com/apideck-io/cli/internal/ui" + "github.com/charmbracelet/x/term" +) + +const ( + colGap = 2 // spaces between columns + colAbsMax = 40 // hard cap per column before budget distribution + colMin = 6 // minimum usable column width + defaultTTY = 120 ) // TableFormatter formats an APIResponse as a styled terminal table. type TableFormatter struct { - w io.Writer - fields []string + w io.Writer + fields []string + widthFn func() int // optional; returns terminal width — nil means auto-detect } // Format writes the response data as a lipgloss-styled table. @@ -26,34 +37,50 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { return nil } + tw := f.getWidth() + + // When fields weren't explicitly selected, drop complex (nested object/array) + // columns if there are too many columns to display readably. + if len(f.fields) == 0 && len(rows) > 0 { + fields = pruneComplexFields(fields, rows[0], tw) + } + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(ui.ColorPrimary()) dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim()) - // Calculate column widths (minimum = header width). + // Calculate natural column widths in runes (minimum = header width), capped at colAbsMax. colWidths := make([]int, len(fields)) for i, h := range fields { - colWidths[i] = len(h) + colWidths[i] = utf8.RuneCountInString(h) } for _, row := range rows { for i, field := range fields { cell := formatValue(row[field]) - if len(cell) > colWidths[i] { - colWidths[i] = len(cell) + if n := utf8.RuneCountInString(cell); n > colWidths[i] { + colWidths[i] = n } } } + for i := range colWidths { + if colWidths[i] > colAbsMax { + colWidths[i] = colAbsMax + } + } + + // Shrink columns to fit the terminal width. + budgetColumns(colWidths, tw) // Build and print header. headerCells := make([]string, len(fields)) for i, h := range fields { - headerCells[i] = headerStyle.Render(padRight(h, colWidths[i])) + headerCells[i] = headerStyle.Render(padRight(truncate(h, colWidths[i]), colWidths[i])) } fmt.Fprintln(f.w, strings.Join(headerCells, " ")) // Separator line. sepParts := make([]string, len(fields)) for i, w := range colWidths { - sepParts[i] = dimStyle.Render(strings.Repeat("─", w)) + sepParts[i] = dimStyle.Render(strings.Repeat("\u2500", w)) } fmt.Fprintln(f.w, strings.Join(sepParts, " ")) @@ -62,7 +89,7 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { cells := make([]string, len(fields)) for i, field := range fields { cell := formatValue(row[field]) - cells[i] = padRight(cell, colWidths[i]) + cells[i] = padRight(truncate(cell, colWidths[i]), colWidths[i]) } fmt.Fprintln(f.w, strings.Join(cells, " ")) } @@ -70,10 +97,106 @@ func (f *TableFormatter) Format(resp *spec.APIResponse) error { return nil } -// padRight pads s with trailing spaces until it reaches width w. +// getWidth returns the terminal width to use for this formatter. +// If a custom widthFn was injected it is used; otherwise the width is derived +// from f.w when it is an *os.File TTY, falling back to defaultTTY. +func (f *TableFormatter) getWidth() int { + if f.widthFn != nil { + return f.widthFn() + } + if file, ok := f.w.(*os.File); ok { + if w, _, err := term.GetSize(file.Fd()); err == nil && w > 0 { + return w + } + } + return defaultTTY +} + +// pruneComplexFields removes complex (nested object/array) fields when the +// total number of columns would make the table unreadable. It keeps all scalar +// fields and only adds complex fields if there's room for at least colReadable +// characters per column. +func pruneComplexFields(fields []string, sampleRow map[string]any, tw int) []string { + const colReadable = 10 // minimum chars per column to be useful + + // Separate scalar and complex fields. + var scalar, complex_ []string + for _, f := range fields { + if isComplex(sampleRow[f]) { + complex_ = append(complex_, f) + } else { + scalar = append(scalar, f) + } + } + + // Start with scalars. Add complex fields one by one if they fit. + result := scalar + for _, f := range complex_ { + n := len(result) + 1 + available := tw - colGap*(n-1) + if available/n >= colReadable { + result = append(result, f) + } + } + + // If we pruned something, the result is already good. + // If nothing was pruned, return original to preserve order. + if len(result) == len(fields) { + return fields + } + return result +} + +// budgetColumns shrinks column widths so the total table fits within maxWidth. +// It repeatedly trims the widest column by one until the table fits, but never +// shrinks a column below colMin. +func budgetColumns(widths []int, maxWidth int) { + n := len(widths) + if n == 0 { + return + } + gapTotal := colGap * (n - 1) + + for { + total := gapTotal + for _, w := range widths { + total += w + } + if total <= maxWidth { + return + } + + // Find the widest column that is still above colMin. + widest := -1 + for i, w := range widths { + if w > colMin && (widest == -1 || w > widths[widest]) { + widest = i + } + } + if widest == -1 { + return // all columns at minimum, nothing more to shrink + } + widths[widest]-- + } +} + +// truncate shortens s to fit within max runes, adding "\u2026" when trimmed. +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + if max <= 1 { + return string(runes[:max]) + } + return string(runes[:max-1]) + "\u2026" +} + +// padRight pads s with trailing spaces until it reaches w runes. func padRight(s string, w int) string { - if len(s) >= w { + n := utf8.RuneCountInString(s) + if n >= w { return s } - return s + strings.Repeat(" ", w-len(s)) + return s + strings.Repeat(" ", w-n) }