Output Table Formatter Specification¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
- Date
- 26 March 2026
- Status
- IMPLEMENTED
Overview¶
pkg/output provides markdown rendering via glamour and a JSON response envelope (Response/Emit), but lacks structured tabular output. CLI tools frequently need to display lists of resources (services, configs, versions, connections) in a kubectl get-style table with aligned columns, optional sorting, and selectable output formats.
Today, commands that need tabular output either build ad-hoc fmt.Fprintf tables (inconsistent alignment, no machine-readable fallback) or dump raw JSON. This specification adds a TableWriter that produces aligned, width-aware tables for human consumption and cleanly switches to JSON, YAML, or CSV for machine consumption, integrating with the existing output.Writer and output.Format patterns.
Design Decisions¶
Functional options for table configuration: Following the pkg/http/client.go pattern, table creation uses TableOption functions. This keeps the API extensible (future: colour, borders, header styles) without breaking callers.
Separate TableWriter rather than extending Writer: Tables have fundamentally different semantics from the existing Write method (which takes data + textFunc). A dedicated TableWriter keeps responsibilities clear and avoids overloading the Writer API.
Terminal width detection and truncation: Tables detect terminal width via charmbracelet/x/term (already a dependency for RenderMarkdown). Columns are truncated with ellipsis when the table exceeds the terminal width, prioritising the first N columns. A --wide or WithNoTruncation option disables truncation for piped output.
Format extension: Two new Format constants (FormatYAML, FormatCSV) are added alongside the existing FormatText and FormatJSON. The --output flag already exists on the root command; table-aware commands simply respect the wider set of values.
Struct tag-based column definition: Column names and extraction are derived from struct tags (table:"NAME,sortable") on the row data type. This eliminates manual column definition for the common case while allowing explicit Column definitions for complex scenarios.
Public API Changes¶
New Types in pkg/output¶
// Column defines a single table column.
type Column struct {
// Header is the display name shown in the table header row.
Header string
// Field is the struct field name or map key to extract the value from.
Field string
// Width is the fixed column width. Zero means auto-sized to content.
Width int
// Sortable indicates this column can be used as a sort key.
Sortable bool
// Formatter is an optional function to format the cell value.
Formatter func(any) string
}
// TableWriter renders structured data as an aligned table or machine-readable format.
type TableWriter struct {
// unexported fields
}
// TableOption configures the TableWriter.
type TableOption func(*tableConfig)
Constructor and Options¶
// NewTableWriter creates a TableWriter that writes to the given io.Writer.
func NewTableWriter(w io.Writer, format Format, opts ...TableOption) *TableWriter
// WithColumns explicitly defines the table columns. When not provided,
// columns are derived from struct tags on the row data type.
func WithColumns(cols ...Column) TableOption
// WithSortBy sets the column to sort rows by. The column must be marked Sortable.
func WithSortBy(field string) TableOption
// WithSortDescending reverses the sort order.
func WithSortDescending() TableOption
// WithNoHeader suppresses the header row in text table output.
func WithNoHeader() TableOption
// WithNoTruncation disables terminal-width truncation.
// Useful when output is piped to a file or another process.
func WithNoTruncation() TableOption
// WithMaxWidth overrides automatic terminal width detection.
func WithMaxWidth(width int) TableOption
Writing Data¶
// WriteRows renders the provided slice as a table.
// T must be a struct type (for tag-based columns) or []map[string]any.
// For JSON/YAML formats, the raw data is marshalled directly.
// For CSV format, columns are used as the header row.
// For text format, an aligned table with padding is produced.
func (t *TableWriter) WriteRows(rows any) error
Format Constants¶
const (
FormatText Format = "text" // existing
FormatJSON Format = "json" // existing
FormatYAML Format = "yaml" // NEW
FormatCSV Format = "csv" // NEW
)
Struct Tag Convention¶
type ServiceStatus struct {
Name string `json:"name" table:"NAME,sortable"`
Status string `json:"status" table:"STATUS"`
Port int `json:"port" table:"PORT,sortable"`
Uptime string `json:"uptime" table:"UPTIME"`
}
Usage Example¶
services := []ServiceStatus{
{Name: "api", Status: "running", Port: 8080, Uptime: "3d2h"},
{Name: "worker", Status: "stopped", Port: 0, Uptime: "0s"},
}
format := output.Format(cmd.Flag("output").Value.String())
tw := output.NewTableWriter(cmd.OutOrStdout(), format,
output.WithSortBy("NAME"),
)
if err := tw.WriteRows(services); err != nil {
return err
}
// Text output:
// NAME STATUS PORT UPTIME
// api running 8080 3d2h
// worker stopped 0 0s
// JSON output: [{"name":"api","status":"running","port":8080,"uptime":"3d2h"}, ...]
// CSV output: NAME,STATUS,PORT,UPTIME\napi,running,8080,3d2h\n...
Internal Implementation¶
Table Rendering (Text Format)¶
func (t *TableWriter) renderText(columns []Column, rows [][]string) error {
// Calculate column widths (max of header and all cell values)
widths := calculateWidths(columns, rows)
// Apply terminal width truncation
if !t.cfg.noTruncation {
termWidth := t.detectTerminalWidth()
widths = truncateWidths(widths, termWidth)
}
// Render header
if !t.cfg.noHeader {
t.renderRow(columns, widths, headerStyle)
}
// Render data rows
for _, row := range rows {
t.renderRow(row, widths, dataStyle)
}
return nil
}
Struct Tag Parsing¶
func columnsFromStruct(v any) ([]Column, error) {
t := reflect.TypeOf(v)
if t.Kind() == reflect.Slice {
t = t.Elem()
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil, errors.New("WriteRows requires a slice of structs")
}
var cols []Column
for i := range t.NumField() {
tag := t.Field(i).Tag.Get("table")
if tag == "" || tag == "-" {
continue
}
parts := strings.Split(tag, ",")
col := Column{
Header: parts[0],
Field: t.Field(i).Name,
Sortable: slices.Contains(parts[1:], "sortable"),
}
cols = append(cols, col)
}
return cols, nil
}
Sorting¶
func sortRows(rows [][]string, colIdx int, descending bool) {
sort.SliceStable(rows, func(i, j int) bool {
// Attempt numeric comparison first, fall back to string
if descending {
return rows[i][colIdx] > rows[j][colIdx]
}
return rows[i][colIdx] < rows[j][colIdx]
})
}
JSON / YAML / CSV Rendering¶
- JSON:
json.NewEncoderwith indentation (matching existingWriter.Writebehaviour). - YAML:
gopkg.in/yaml.v3marshalling. This is a new dependency but lightweight and widely used. - CSV:
encoding/csvfrom the standard library. Column headers from thetabletag become the CSV header row.
Project Structure¶
pkg/output/
โโโ output.go โ MODIFIED: add FormatYAML, FormatCSV constants
โโโ output_test.go โ UNCHANGED
โโโ table.go โ NEW: TableWriter, TableOption, Column, WriteRows
โโโ table_test.go โ NEW: table rendering tests
Testing Strategy¶
| Test | Scenario |
|---|---|
TestTableWriter_TextFormat |
Struct slice → aligned text table with headers |
TestTableWriter_JSONFormat |
Struct slice → indented JSON array |
TestTableWriter_YAMLFormat |
Struct slice → YAML list |
TestTableWriter_CSVFormat |
Struct slice → CSV with header row |
TestTableWriter_SortBy |
Rows sorted by specified column ascending |
TestTableWriter_SortDescending |
Rows sorted descending |
TestTableWriter_SortNonSortable |
Sort by non-sortable column → error |
TestTableWriter_NoHeader |
Text output without header row |
TestTableWriter_Truncation |
Long values truncated to terminal width with ellipsis |
TestTableWriter_NoTruncation |
WithNoTruncation → full values rendered |
TestTableWriter_MaxWidth |
WithMaxWidth overrides detected width |
TestTableWriter_EmptyRows |
Empty slice → header only (text) or empty array (JSON) |
TestTableWriter_StructTags |
Columns derived from table struct tags |
TestTableWriter_ExplicitColumns |
WithColumns overrides struct tags |
TestTableWriter_CustomFormatter |
Column.Formatter applied to cell values |
TestTableWriter_MapSlice |
[]map[string]any input with explicit columns |
TestColumnsFromStruct_NoTags |
Struct without table tags → error |
TestColumnsFromStruct_SkipDash |
Fields tagged table:"-" are excluded |
Coverage¶
- Target: 90%+ for
table.go.
Linting¶
golangci-lint run --fixmust pass.- No new
nolintdirectives.
Documentation¶
- Godoc for
TableWriter,Column, allTableOptionfunctions, andWriteRows. - Update
docs/components/output.mdwith table formatting usage, struct tag reference, and format selection examples. - Add examples showing each output format (text, JSON, YAML, CSV) for the same data.
Backwards Compatibility¶
- No breaking changes.
FormatTextandFormatJSONretain their existing values.FormatYAMLandFormatCSVare new constants. - The existing
Writertype andEmit/EmitErrorfunctions are unchanged. - The
--outputflag on the root command already accepts a string; commands that support table output document the additional accepted values.
Future Considerations¶
- Colour and styling: Header row in bold, status columns with colour coding (green for running, red for stopped). Would use
charmbracelet/lipglosswhich is already an indirect dependency. - Interactive table: Bubble Tea-based scrollable table for large datasets, leveraging the existing
pkg/formsTUI infrastructure. - Custom delimiters: TSV or pipe-separated output for specific pipeline integrations.
- Column selection: A
--columnsflag to select which columns to display, similar tokubectl get -o custom-columns.
Implementation Phases¶
Phase 1 โ Core Table Writer¶
- Define
Column,TableWriter,TableOptiontypes - Implement text table rendering with auto-sized columns
- Implement struct tag parsing for column derivation
- Add text format tests
Phase 2 โ Machine-Readable Formats¶
- Add
FormatYAMLandFormatCSVconstants - Implement JSON, YAML, and CSV rendering paths
- Add format-specific tests
Phase 3 โ Sorting and Truncation¶
- Implement sort-by-column with ascending/descending
- Implement terminal width detection and truncation
- Add sorting and truncation tests
Phase 4 โ Integration¶
- Wire into an existing command (e.g.,
doctor,version) as a reference implementation - Update documentation with usage examples
Verification¶
go build ./...
go test -race ./pkg/output/...
go test ./...
golangci-lint run --fix
# Verify new types exist
grep -n 'type TableWriter struct' pkg/output/table.go
grep -n 'func NewTableWriter' pkg/output/table.go
grep -n 'FormatYAML\|FormatCSV' pkg/output/output.go