Files
pad/internal/cli/format_ansi_test.go
T
David Barkhausen b9381bf5f1 feat(cli): markdown output on the remaining list surfaces; broaden ANSI stripping (#1080)
Completes #898 and fixes #1076.

Markdown on the seven surfaces left out of #1070, so `--format markdown` is now
honestly global and the flag help collapses to "table, json, markdown":

- `item comments`, `item deps`, `project activity`, `attachment list`,
  `library list`, `role list`, `workspace members`.

Two of those are not tabular, and markdown follows the terminal shape rather
than forcing a table onto them:

- `item comments` keeps the attribution-line-then-body form, and the body is
  emitted VERBATIM. A comment body is authored as markdown; escaping it would
  turn its lists and code fences into literal text. Only the attribution line,
  which we construct, is sanitized.
- `item deps` keeps its two sections as `## Blocks` / `## Blocked by` lists.
  Colour carried the direction in the terminal (yellow out, red in); headings
  carry it here.

New shared spine: `cli.RenderMarkdownTable(w, headers, rows)`. Every cell is
escaped, and ragged rows are padded or truncated to the header width so a short
or long row can't shift the column count and break the table. Wiring a surface
is now naming columns and mapping rows.

#1076 — ANSI stripping covered only SGR (`ESC[…m`), so non-SGR CSI sequences,
OSC-8 hyperlinks, and stray C0 controls survived, both in the table width maths
and in markdown output whose doc comment promised escape-free text. Replaced
`sgrPattern` with `ansiPattern` + `stripANSI` covering OSC, CSI, two-character
Fe escapes, and stray C0/DEL, with TAB/LF/CR deliberately preserved for callers
that normalize them. `displayWidth` now uses it too: a control sequence is
zero-width, so counting it was a column-alignment bug of the same family.

Tests: 12 stripping cases, 4 table-helper cases (including ragged rows), 4
renderer cases for the two non-tabular surfaces, and the routing test extended
to 8 subtests — one per surface, driven through cobra against an httptest
server. Also covers the two gaps named in #1076: `item starred` and the scoped
`item list <collection>` path. Each new guard was proven by mutating the source
and watching it fail, not just by passing.

Gates: go build ./... PASS; go vet PASS; gofmt clean; golangci-lint 0 issues.
Both touched packages show the same 6+2 pre-existing Windows failures as clean
main under an identical sandboxed run.
2026-08-14 22:35:23 -04:00

83 lines
2.4 KiB
Go

package cli
import (
"strings"
"testing"
)
// #1076: "ANSI stripping" covered only SGR (ESC[…m), so non-SGR CSI sequences,
// OSC-8 hyperlinks, and stray C0 controls survived — including through the
// markdown renderers, whose doc comments promise ANSI is stripped.
func TestStripANSI_NonSGRSequences(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"SGR colour", "\x1b[1;31mred\x1b[0m", "red"},
{"CSI erase line", "before\x1b[2Kafter", "beforeafter"},
{"CSI cursor move", "a\x1b[10;20Hb", "ab"},
{"CSI private mode", "a\x1b[?25lb", "ab"},
{"OSC-8 hyperlink", "\x1b]8;;http://example.com\x07label\x1b]8;;\x07", "label"},
{"OSC window title with ST", "a\x1b]0;title\x1b\\b", "ab"},
{"stray C0 control", "a\x01b\x7fc", "abc"},
{"vertical tab and form feed", "a\x0bb\x0cc", "abc"},
{"bare ESC", "a\x1bb", "ab"},
{"plain text untouched", "nothing to strip", "nothing to strip"},
{"newlines preserved for the caller to handle", "a\nb", "a\nb"},
{"tabs preserved", "a\tb", "a\tb"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stripANSI(tc.in); got != tc.want {
t.Errorf("stripANSI(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// displayWidth drives the table column maths, so a zero-width control sequence
// must not be counted as visible columns.
func TestDisplayWidth_IgnoresNonSGRSequences(t *testing.T) {
cases := []struct {
name string
in string
want int
}{
{"CSI erase line", "abc\x1b[2K", 3},
{"OSC-8 hyperlink", "\x1b]8;;http://example.com\x07abc\x1b]8;;\x07", 3},
{"stray C0", "ab\x01c", 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := displayWidth(tc.in); got != tc.want {
t.Errorf("displayWidth(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
// The markdown path promises no ANSI in its output; that promise has to hold for
// non-SGR sequences too.
func TestSanitizeMarkdownText_StripsNonSGRSequences(t *testing.T) {
in := "Tasks\x1b[2K\x1b]8;;http://example.com\x07"
got := SanitizeMarkdownText(in)
if strings.ContainsRune(got, 0x1b) {
t.Errorf("escape byte survived: %q", got)
}
if got != "Tasks" {
t.Errorf("SanitizeMarkdownText(%q) = %q, want %q", in, got, "Tasks")
}
}
func TestEscapeMarkdownCell_StripsNonSGRSequences(t *testing.T) {
got := escapeMarkdownCell("a\x1b[2Kb")
if got != "ab" {
t.Errorf("escapeMarkdownCell = %q, want %q", got, "ab")
}
}