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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This project uses [Semantic Versioning 2.0.0](http://semver.org/), the format is
- The new release notice now prints the release page URL for every installation method, colors the version numbers, and separates itself from the command output with a blank line above and below. `--no-color` and the `NO_COLOR` environment variable turn the color off. The `DNSIMPLE_NO_UPDATE_CHECK` environment variable, which turns the check off, is now documented in the README.
- Table output puts the header row in bold, and the pagination hints that go with a multi-page list are faint. `--no-color` and the `NO_COLOR` environment variable turn the color off, and redirected output stays plain.
- The update check no longer runs when the `BUILD_NUMBER` or the `RUN_ID` environment variable is set, and it now requires both the standard output stream and the standard error stream to be a terminal.
- Table output is cut to the width of the terminal. A long value, for example a DNSKEY record, no longer pushes the columns after it off the screen. Redirected output, `--json`, `--format`, and the single-resource `get` commands still return the full value.

## 0.10.0 - 2026-06-15

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ To discover available template fields, inspect the corresponding `--json` output

Table output puts the header row in bold, and the pagination hints that go with a multi-page list are faint. Pass `--no-color`, or set the `NO_COLOR` environment variable, to turn the colored output off. Redirected output is always plain.

A table is cut to the width of the terminal, and a value that does not fit ends with `...`. Redirected output is never cut, and `--json`, `--format`, and the single-resource `get` commands always return the full value.

### Sandbox Environment

We highly recommend testing against our [sandbox environment](https://developer.dnsimple.com/sandbox/) before using our production environment. This will allow you to avoid real purchases, live charges on your credit card, and reduce the chance of your running up against rate limits.
Expand Down
14 changes: 14 additions & 0 deletions internal/output/color.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ func IsTerminal(w io.Writer) bool {
return ok && term.IsTerminal(int(f.Fd()))
}

// terminalWidth returns the width of w in columns, or 0 when w has no width of
// its own.
func terminalWidth(w io.Writer) int {
f, ok := w.(*os.File)
if !ok {
return 0
}
width, _, err := term.GetSize(int(f.Fd()))
if err != nil {
return 0
}
return width
}

// ColorEnabled reports whether w accepts colored output. The noColor argument
// carries the --no-color flag.
func ColorEnabled(w io.Writer, noColor bool) bool {
Expand Down
4 changes: 4 additions & 0 deletions internal/output/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ type Printer struct {
Format Format
Template string
NoColor bool

// width bounds the table output. It is 0 in production, where the width
// comes from the terminal.
width int
}

// NewPrinter creates a new Printer with the given format settings.
Expand Down
88 changes: 88 additions & 0 deletions internal/output/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package output

import (
"bytes"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -88,6 +89,93 @@ func TestPrinterPrintTableEmptyHeaders(t *testing.T) {
assert.Zero(t, buf.Len())
}

func wideTable() *stubFormattable {
return &stubFormattable{
headers: []string{"ID", "CONTENT", "TTL"},
rows: [][]string{{"1", strings.Repeat("a", 60), "3600"}},
}
}

func TestPrinterPrintTableTruncatesToWidth(t *testing.T) {
var buf bytes.Buffer
p := &Printer{Writer: &buf, Format: FormatTable, width: 40}

err := p.Print(wideTable())
if !assert.NoError(t, err) {
return
}

// The CONTENT column gives up the space the TTL column needs, so every row
// still ends with its own TTL.
want := "ID CONTENT" + strings.Repeat(" ", 25) + "TTL\n" +
"1 " + strings.Repeat("a", 27) + "... 3600\n"
assert.Equal(t, want, buf.String())
}

func TestPrinterPrintTableKeepsTheLastColumn(t *testing.T) {
var buf bytes.Buffer
p := &Printer{Writer: &buf, Format: FormatTable, width: 40}

err := p.Print(&stubFormattable{
headers: []string{"FIELD", "VALUE"},
rows: [][]string{{"System Record", strings.Repeat("a", 60)}},
})
if !assert.NoError(t, err) {
return
}

// The last column never shrinks, so cutting the FIELD labels would lose
// values without bringing the table inside the width.
want := "FIELD VALUE\nSystem Record " + strings.Repeat("a", 60) + "\n"
assert.Equal(t, want, buf.String())
}

func TestPrinterPrintTableWithoutWidthKeepsEveryValue(t *testing.T) {
var buf bytes.Buffer
p := &Printer{Writer: &buf, Format: FormatTable}

err := p.Print(wideTable())
if !assert.NoError(t, err) {
return
}

want := "ID CONTENT" + strings.Repeat(" ", 55) + "TTL\n" +
"1 " + strings.Repeat("a", 60) + " 3600\n"
assert.Equal(t, want, buf.String())
}

func TestPrinterPrintTableUnderWidthIsUnchanged(t *testing.T) {
var buf bytes.Buffer
p := &Printer{Writer: &buf, Format: FormatTable, width: 80}

err := p.Print(&stubFormattable{
headers: []string{"NAME", "VALUE"},
rows: [][]string{
{"alpha", "1"},
{"beta", "22"},
},
})
if !assert.NoError(t, err) {
return
}

assert.Equal(t, "NAME VALUE\nalpha 1\nbeta 22\n", buf.String())
}

func TestPrinterPrintTableKeepsHeadersWhenWidthIsTooSmall(t *testing.T) {
var buf bytes.Buffer
p := &Printer{Writer: &buf, Format: FormatTable, width: 10}

err := p.Print(wideTable())
if !assert.NoError(t, err) {
return
}

// The table cannot fit, so it stops at the narrowest columns that still
// carry a whole header.
assert.Equal(t, "ID CONTENT TTL\n1 aaaaa... 3600\n", buf.String())
}

func listData() *stubFormattable {
return &stubFormattable{
headers: []string{"NAME"},
Expand Down
96 changes: 95 additions & 1 deletion internal/output/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ import (
"io"
"strings"
"text/tabwriter"
"unicode/utf8"

"github.com/fatih/color"
)

const (
// Cell padding printTable gives tabwriter.
tablePadding = 2
// Narrowest a column shrinks to, before its header is taken into account.
minColumnWidth = 8
ellipsis = "..."
)

func (p *Printer) printTable(data Formattable) error {
headers := data.TableHeaders()
rows := data.TableRows()
Expand All @@ -18,8 +27,10 @@ func (p *Printer) printTable(data Formattable) error {
return nil
}

rows = fitColumns(headers, rows, p.tableWidth())

var buf bytes.Buffer
w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
w := tabwriter.NewWriter(&buf, 0, 0, tablePadding, ' ', 0)

// Print header
fmt.Fprintln(w, strings.Join(headers, "\t"))
Expand All @@ -44,3 +55,86 @@ func (p *Printer) printTable(data Formattable) error {
_, err := io.WriteString(p.Writer, out)
return err
}

// tableWidth returns the width the table must fit in. It is 0 when the writer
// has no width of its own, which lets a redirect or a pipe carry every value in
// full.
func (p *Printer) tableWidth() int {
if p.width > 0 {
return p.width
}
return terminalWidth(p.Writer)
}

// fitColumns truncates the cells that make the table wider than limit. Only the
// columns before the last one shrink: tabwriter pads every cell of a row except
// the last, so a long value in the last column cannot move the columns before it.
func fitColumns(headers []string, rows [][]string, limit int) [][]string {
if limit <= 0 || len(headers) < 2 {
return rows
}

widths := make([]int, len(headers))
for i, header := range headers {
widths[i] = utf8.RuneCountInString(header)
}
floors := make([]int, len(headers)-1)
for i := range floors {
floors[i] = max(widths[i], minColumnWidth)
}
for _, row := range rows {
for i, cell := range row {
if i < len(widths) {
widths[i] = max(widths[i], utf8.RuneCountInString(cell))
}
}
}

total := tablePadding * (len(widths) - 1)
for _, width := range widths {
total += width
}

// Nothing to cut, or nothing to gain: a last column wider than the limit
// never shrinks, so the columns before it give up their values for nothing.
if total <= limit || widths[len(widths)-1] >= limit {
return rows
}

for total > limit {
widest := -1
for i := range floors {
if widths[i] > floors[i] && (widest == -1 || widths[i] > widths[widest]) {
widest = i
}
}
if widest == -1 {
break
}
widths[widest]--
total--
}

fitted := make([][]string, len(rows))
for i, row := range rows {
cells := make([]string, len(row))
copy(cells, row)
for j := 0; j < len(floors) && j < len(cells); j++ {
cells[j] = truncate(cells[j], widths[j])
}
fitted[i] = cells
}
return fitted
}

// truncate cuts s to width, giving the last characters to the ellipsis.
func truncate(s string, width int) string {
if len(s) <= width {
return s
}
runes := []rune(s)
if len(runes) <= width {
return s
}
return string(runes[:width-len(ellipsis)]) + ellipsis
}