diff --git a/README.md b/README.md index 7097a57..1f27c37 100644 --- a/README.md +++ b/README.md @@ -304,12 +304,23 @@ to both operations, it is present afterward. **Human-readable** (default) — tabular output to stdout: ``` -$ uploadcare file list --limit 3 -UUID SIZE FILENAME STORED UPLOADED -a1b2c3d4-e5f6-7890-abcd-ef1234567890 1258000 photo.jpg true 2026-03-01T00:00:00Z -b2c3d4e5-f6a7-8901-bcde-f12345678901 348160 document.pdf false 2026-03-02T00:00:00Z +$ uploadcare file list --limit 2 +UUID SIZE FILENAME STORED UPLOADED +a1b2c3d4-e5f6-7890-abcd-ef1234567890 1258000 vacation-p...nal-v3.jpg true 2026-03-01T00:00:00Z +b2c3d4e5-f6a7-8901-bcde-f12345678901 348160 document.pdf false 2026-03-02T00:00:00Z ``` +Long filenames, URLs, and paths are shortened to keep one record per line, +marked with `...` and keeping the file extension visible. UUIDs and timestamps +are never shortened, so they stay copy-pasteable. Redirected or piped output is +never shortened: + +```bash +uploadcare file list | less # full filenames +``` + +Use `--json` for values you intend to parse. + **JSON** — activated with `--json all` (all fields) or `--json field1,field2` (specific fields): ```bash diff --git a/go.mod b/go.mod index 470ba49..9ddcdba 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.26.1 require ( github.com/fatih/color v1.19.0 github.com/itchyny/gojq v0.12.18 + github.com/rivo/uniseg v0.4.7 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/uploadcare/uploadcare-go/v2 v2.1.0 go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/term v0.45.0 ) require ( @@ -26,6 +28,6 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 04f3ad6..014dfd5 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -58,8 +60,10 @@ github.com/uploadcare/uploadcare-go/v2 v2.1.0/go.mod h1:nVtcYFEeUnxMjXbEsXzDefko go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/file.go b/internal/cmd/file.go index 1b200f1..a376d36 100644 --- a/internal/cmd/file.go +++ b/internal/cmd/file.go @@ -215,9 +215,9 @@ original_file_url, metadata, appdata (with --include-appdata).`, var table *output.TableData if includeAppData { - table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED", "APPDATA") + table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED", "APPDATA").Flexible(2) } else { - table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED") + table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED").Flexible(2) } for _, f := range result.Files { row := []string{ @@ -262,12 +262,12 @@ func runFileListAll(cmd *cobra.Command, svc service.FileService, listOpts servic } if includeAppData { _, err := fmt.Fprintf(w, "%s\t%d\t%s\t%v\t%s\t%s\n", - f.UUID, f.Size, f.Filename, f.IsStored, formatTime(f.DatetimeUploaded), + f.UUID, f.Size, output.SanitizeCell(f.Filename), f.IsStored, formatTime(f.DatetimeUploaded), truncateAppData(f.AppData, 50)) return err } _, err := fmt.Fprintf(w, "%s\t%d\t%s\t%v\t%s\n", - f.UUID, f.Size, f.Filename, f.IsStored, formatTime(f.DatetimeUploaded)) + f.UUID, f.Size, output.SanitizeCell(f.Filename), f.IsStored, formatTime(f.DatetimeUploaded)) return err }) } @@ -299,11 +299,7 @@ func truncateAppData(data json.RawMessage, maxLen int) string { if len(data) == 0 { return "" } - s := string(data) - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." + return output.TruncateEnd(string(data), maxLen) } func formatTime(t time.Time) string { diff --git a/internal/cmd/file_batch.go b/internal/cmd/file_batch.go index 20c348d..a25a1d0 100644 --- a/internal/cmd/file_batch.go +++ b/internal/cmd/file_batch.go @@ -195,7 +195,7 @@ func runBatchCommand( return err } } else { - table := output.NewTableData("UUID", "FILENAME", "STATUS") + table := output.NewTableData("UUID", "FILENAME", "STATUS").Flexible(1, 2) for _, f := range merged.Files { table.AddRow(f.UUID, f.Filename, "ok") } @@ -261,7 +261,7 @@ func runBatchDryRun( return err } } else { - table := output.NewTableData("UUID", "FILENAME", "STATUS") + table := output.NewTableData("UUID", "FILENAME", "STATUS").Flexible(1, 2) for _, e := range entries { table.AddRow(e.UUID, e.Filename, e.Status) } diff --git a/internal/cmd/file_download.go b/internal/cmd/file_download.go index 1b0229e..0eb47bf 100644 --- a/internal/cmd/file_download.go +++ b/internal/cmd/file_download.go @@ -616,7 +616,7 @@ func expandNameTemplate(tmpl string, f *service.File, effects string) (string, e } func downloadRowsTable(rows []downloadRow) *output.TableData { - t := output.NewTableData("UUID", "PATH", "SIZE", "STATUS") + t := output.NewTableData("UUID", "PATH", "SIZE", "STATUS").Flexible(1) for _, r := range rows { t.AddRow(r.UUID, r.Path, strconv.FormatInt(r.Size, 10), r.Status) } diff --git a/internal/cmd/file_search.go b/internal/cmd/file_search.go index 0b19000..2ffeed6 100644 --- a/internal/cmd/file_search.go +++ b/internal/cmd/file_search.go @@ -278,7 +278,7 @@ func fileSearchTable(files []service.File, includeAppData bool) *output.TableDat if includeAppData { headers = append(headers, "APPDATA") } - table := output.NewTableData(headers...) + table := output.NewTableData(headers...).Flexible(2) for _, file := range files { row := []string{file.UUID, strconv.FormatInt(file.Size, 10), file.Filename, file.MimeType, formatTime(file.DatetimeUploaded)} if includeAppData { @@ -301,12 +301,12 @@ func runFileSearchAll(cmd *cobra.Command, svc service.FileService, searchOpts se } if includeAppData { _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%d\t%s\t%s\t%s\t%s\n", - file.UUID, file.Size, file.Filename, file.MimeType, formatTime(file.DatetimeUploaded), + file.UUID, file.Size, output.SanitizeCell(file.Filename), file.MimeType, formatTime(file.DatetimeUploaded), truncateAppData(file.AppData, 50)) return err } _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%d\t%s\t%s\t%s\n", - file.UUID, file.Size, file.Filename, file.MimeType, formatTime(file.DatetimeUploaded)) + file.UUID, file.Size, output.SanitizeCell(file.Filename), file.MimeType, formatTime(file.DatetimeUploaded)) return err }) if err != nil { diff --git a/internal/cmd/file_test.go b/internal/cmd/file_test.go index ad37770..2b12ed4 100644 --- a/internal/cmd/file_test.go +++ b/internal/cmd/file_test.go @@ -650,7 +650,8 @@ func TestTruncateAppData(t *testing.T) { {"empty", nil, 50, ""}, {"short", json.RawMessage(`{"ok":true}`), 50, `{"ok":true}`}, {"exact", json.RawMessage(`12345`), 5, `12345`}, - {"over", json.RawMessage(`{"uc_clamav_virus_scan":{"data":{"infected":false}}}`), 20, `{"uc_clamav_virus_sc...`}, + {"over", json.RawMessage(`{"uc_clamav_virus_scan":{"data":{"infected":false}}}`), 20, `{"uc_clamav_virus...`}, + {"multibyte", json.RawMessage(`{"note":"日本語テキストです"}`), 12, `{"note":"...`}, } for _, tt := range tests { @@ -662,3 +663,44 @@ func TestTruncateAppData(t *testing.T) { }) } } + +// A filename may come back from the API with a literal tab or newline in it. +// Left alone it splits the record across lines and, in the streaming path, +// shifts every field after it. See issue #4. +func TestFileList_ControlCharsInFilename(t *testing.T) { + f := testFile() + f.Filename = "two\nlines\tand\ttabs.jpg" + + for _, tt := range []struct { + name string + args []string + lines int + }{ + {"table", []string{"file", "list"}, 2}, + {"stream", []string{"file", "list", "--page-all"}, 1}, + } { + t.Run(tt.name, func(t *testing.T) { + mock := &mockFileService{ + listFunc: func(ctx context.Context, opts service.FileListOptions) (*service.FileListResult, error) { + return &service.FileListResult{Files: []service.File{*f}, Total: 1}, nil + }, + iterateFunc: func(ctx context.Context, opts service.FileListOptions, fn func(service.File) error) error { + return fn(*f) + }, + } + + stdout, _, err := executeCommand(t, newTestRoot(mock), tt.args...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := strings.Split(strings.TrimRight(stdout, "\n"), "\n") + if len(got) != tt.lines { + t.Errorf("expected %d lines, got %d:\n%s", tt.lines, len(got), stdout) + } + if strings.Count(got[len(got)-1], "\t") > 4 { + t.Errorf("filename must not introduce extra fields:\n%q", got[len(got)-1]) + } + }) + } +} diff --git a/internal/cmd/file_upload.go b/internal/cmd/file_upload.go index 836660d..df65cf7 100644 --- a/internal/cmd/file_upload.go +++ b/internal/cmd/file_upload.go @@ -223,7 +223,7 @@ is_ready, datetime_uploaded, original_file_url, metadata, tags.`, return formatter.Format(cmd.OutOrStdout(), results) } - table := output.NewTableData("UUID", "SIZE", "FILENAME") + table := output.NewTableData("UUID", "SIZE", "FILENAME").Flexible(2) for _, r := range results { table.AddRow(r.UUID, strconv.FormatInt(r.Size, 10), r.Filename) } @@ -270,7 +270,7 @@ func runUploadDryRun(cmd *cobra.Command, entries []uploadFileEntry, tags []strin if len(tags) > 0 { headers = append(headers, "TAGS") } - table := output.NewTableData(headers...) + table := output.NewTableData(headers...).Flexible(0) for _, e := range dryEntries { row := []string{e.Path, strconv.FormatInt(e.Size, 10), e.ContentType} if len(tags) > 0 { diff --git a/internal/cmd/file_upload_from_url.go b/internal/cmd/file_upload_from_url.go index 6c5aea0..0b61af7 100644 --- a/internal/cmd/file_upload_from_url.go +++ b/internal/cmd/file_upload_from_url.go @@ -153,7 +153,7 @@ is_ready, datetime_uploaded, original_file_url, metadata, tags.`, return formatter.Format(cmd.OutOrStdout(), results) } - table := output.NewTableData("UUID", "SIZE", "FILENAME") + table := output.NewTableData("UUID", "SIZE", "FILENAME").Flexible(2) for _, r := range results { table.AddRow(r.UUID, strconv.FormatInt(r.Size, 10), r.Filename) } @@ -197,7 +197,7 @@ func runUploadFromURLDryRun(cmd *cobra.Command, urls, tags []string, opts output if len(tags) > 0 { headers = append(headers, "TAGS") } - table := output.NewTableData(headers...) + table := output.NewTableData(headers...).Flexible(0) for _, e := range entries { row := []string{e.URL, e.Status} if len(tags) > 0 { diff --git a/internal/cmd/metadata.go b/internal/cmd/metadata.go index 91a8db4..211ed84 100644 --- a/internal/cmd/metadata.go +++ b/internal/cmd/metadata.go @@ -77,7 +77,7 @@ is specified. Returns "No metadata found" when the file has no metadata.`, return nil } - table := output.NewTableData("KEY", "VALUE") + table := output.NewTableData("KEY", "VALUE").Flexible(0, 1) for k, v := range meta { table.AddRow(k, v) } diff --git a/internal/cmd/project.go b/internal/cmd/project.go index ccaff11..85221d9 100644 --- a/internal/cmd/project.go +++ b/internal/cmd/project.go @@ -196,7 +196,7 @@ filesize_limit, autostore_enabled.`, return formatter.Format(cmd.OutOrStdout(), result.Projects) } - table := output.NewTableData("PUB_KEY", "NAME", "SHARED", "BLOCKED") + table := output.NewTableData("PUB_KEY", "NAME", "SHARED", "BLOCKED").Flexible(1) for _, p := range result.Projects { blocked := "" if p.IsBlocked != nil && *p.IsBlocked { diff --git a/internal/cmd/schema.go b/internal/cmd/schema.go index 2149e8e..a111ab2 100644 --- a/internal/cmd/schema.go +++ b/internal/cmd/schema.go @@ -83,6 +83,7 @@ No authentication required.`, "All timestamps are in RFC 3339 / UTC format.", "For batch operations (file store, file delete), exit code 1 means partial success — check the 'problems' field in JSON output.", "When piping between commands, use --json uuid or --jq '.uuid' to emit just the UUID for --from-stdin consumption.", + "Human-readable tables shorten long filenames, URLs, and paths to fit the terminal, marking the cut with '...'. Piped or redirected output is never shortened; use --json for values you intend to parse.", "For file download, use --output-dir for multiple UUIDs or --from-stdin; --output - streams bytes to stdout and cannot be combined with --json.", "File search requires at least one query or filter. Search results can lag recent file, metadata, and tag changes. The API serves at most the first 1000 matches of a search.", "Tag flags are repeatable. Tag update applies all --delete values before all --add values. Tag mutations with --dry-run add \"status\": \"would change\" to their JSON output.", diff --git a/internal/cmd/webhook.go b/internal/cmd/webhook.go index be6162b..1347921 100644 --- a/internal/cmd/webhook.go +++ b/internal/cmd/webhook.go @@ -88,7 +88,7 @@ datetime_created, datetime_updated.`, return formatter.Format(cmd.OutOrStdout(), webhooks) } - table := output.NewTableData("ID", "TARGET_URL", "EVENT", "ACTIVE", "CREATED") + table := output.NewTableData("ID", "TARGET_URL", "EVENT", "ACTIVE", "CREATED").Flexible(1) for _, w := range webhooks { table.AddRow( strconv.Itoa(w.ID), diff --git a/internal/output/table.go b/internal/output/table.go index e63955e..0f4e5f3 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -1,19 +1,26 @@ package output import ( - "bytes" "fmt" "io" + "sort" "strings" - "text/tabwriter" "github.com/fatih/color" ) +// colGap is the number of spaces between columns. +const colGap = 2 + // TableFormatter writes data as a human-readable table. -type TableFormatter struct{} +type TableFormatter struct { + // Width is the line width to fit rows into. Zero means detect it from the + // writer, which yields 0 for anything that is not a terminal — piped output + // is never truncated. + Width int +} -// Format writes data as a tab-aligned table. +// Format writes data as an aligned table. // data must be a TableData value. func (f *TableFormatter) Format(w io.Writer, data any) error { td, ok := data.(*TableData) @@ -23,44 +30,138 @@ func (f *TableFormatter) Format(w io.Writer, data any) error { return err } - // Write through a buffer so tabwriter aligns plain text first, - // then apply bold to the header line. Coloring before tabwriter - // breaks alignment because ANSI escapes inflate byte counts. - var buf bytes.Buffer - tw := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + headers, rows := td.sanitized() - // Header - if len(td.Headers) > 0 { - _, _ = fmt.Fprintln(tw, strings.Join(td.Headers, "\t")) + width := f.Width + if width == 0 { + width = terminalWidth(w) + } + if width > 0 { + fitColumns(headers, rows, td.flex, width) } - // Rows - for _, row := range td.Rows { - _, _ = fmt.Fprintln(tw, strings.Join(row, "\t")) + // Pad in terminal columns rather than deferring to text/tabwriter, which + // measures cells in runes and so misaligns — and overflows the width + // budget on — CJK and emoji text. + cols := columnWidths(headers, rows) + + var buf strings.Builder + if len(headers) > 0 { + bold := color.New(color.Bold) + buf.WriteString(bold.Sprint(renderRow(headers, cols))) + buf.WriteByte('\n') + } + for _, row := range rows { + buf.WriteString(renderRow(row, cols)) + buf.WriteByte('\n') } - if err := tw.Flush(); err != nil { - return err + _, err := io.WriteString(w, buf.String()) + return err +} + +// columnWidths returns the width in terminal columns of each column's widest +// cell, header included. +func columnWidths(headers []string, rows [][]string) []int { + n := len(headers) + for _, row := range rows { + n = max(n, len(row)) + } + widths := make([]int, n) + for i, h := range headers { + widths[i] = max(widths[i], displayWidth(h)) + } + for _, row := range rows { + for i, cell := range row { + widths[i] = max(widths[i], displayWidth(cell)) + } + } + return widths +} + +// renderRow pads every cell but the last to its column width. The final cell +// is left unpadded so rows carry no trailing whitespace. +func renderRow(cells []string, cols []int) string { + var b strings.Builder + for i, cell := range cells { + b.WriteString(cell) + if i == len(cells)-1 { + break + } + b.WriteString(strings.Repeat(" ", cols[i]-displayWidth(cell)+colGap)) + } + return b.String() +} + +// lineWidth is the width of the widest rendered row for the given columns. +func lineWidth(cols []int) int { + if len(cols) == 0 { + return 0 } + w := colGap * (len(cols) - 1) + for _, c := range cols { + w += c + } + return w +} - out := buf.String() +// fitColumns shortens the flexible columns in place until the widest line fits +// within width. They are shrunk widest-first and never below minFlexWidth; when +// that is not enough the line is left to wrap. Columns the caller did not mark +// flexible are never touched, so fixed-format values — UUIDs, timestamps — stay +// intact and copy-pasteable at any terminal width. +func fitColumns(headers []string, rows [][]string, flex []int, width int) { + if len(flex) == 0 { + return + } + + cols := columnWidths(headers, rows) + line := lineWidth(cols) + if line <= width { + return + } - // Bold the first line (header) after alignment is done. - if len(td.Headers) > 0 { - if i := strings.IndexByte(out, '\n'); i >= 0 { - bold := color.New(color.Bold) - out = bold.Sprint(out[:i]) + out[i:] + candidates := make([]int, 0, len(flex)) + for _, c := range flex { + if c >= 0 && c < len(cols) { + candidates = append(candidates, c) } } + sort.SliceStable(candidates, func(a, b int) bool { + return cols[candidates[a]] > cols[candidates[b]] + }) - _, err := io.WriteString(w, out) - return err + for _, c := range candidates { + if line <= width { + return + } + floor := minFlexWidth + if c < len(headers) { + floor = max(floor, displayWidth(headers[c])) + } + cut := min(cols[c]-floor, line-width) + if cut <= 0 { + continue + } + target := cols[c] - cut + for _, row := range rows { + if c < len(row) { + row[c] = TruncateMiddle(row[c], target) + } + } + // A wide cluster may not divide evenly into the target, so re-measure + // rather than assuming the column now sits exactly at it. + cols = columnWidths(headers, rows) + line = lineWidth(cols) + } } // TableData is the structured input for the table formatter. type TableData struct { Headers []string Rows [][]string + + flex []int } // NewTableData creates a TableData with the given headers. @@ -72,3 +173,29 @@ func NewTableData(headers ...string) *TableData { func (td *TableData) AddRow(values ...string) { td.Rows = append(td.Rows, values) } + +// Flexible marks the columns holding user-controlled text — filenames, URLs, +// paths — that may be shortened to fit the terminal. Columns left unmarked are +// printed in full. Detail tables deliberately mark nothing: their value column +// is what the user came to copy. +func (td *TableData) Flexible(cols ...int) *TableData { + td.flex = cols + return td +} + +// sanitized returns copies of the headers and rows with control characters +// replaced, so callers' data is never mutated by fitting. +func (td *TableData) sanitized() ([]string, [][]string) { + headers := make([]string, len(td.Headers)) + for i, h := range td.Headers { + headers[i] = SanitizeCell(h) + } + rows := make([][]string, len(td.Rows)) + for i, row := range td.Rows { + rows[i] = make([]string, len(row)) + for j, cell := range row { + rows[i][j] = SanitizeCell(cell) + } + } + return headers, rows +} diff --git a/internal/output/table_test.go b/internal/output/table_test.go new file mode 100644 index 0000000..802222c --- /dev/null +++ b/internal/output/table_test.go @@ -0,0 +1,263 @@ +package output + +import ( + "bytes" + "strings" + "testing" + "unicode/utf8" +) + +func TestTruncateMiddle(t *testing.T) { + tests := []struct { + name string + s string + max int + want string + }{ + {"under", "photo.jpg", 20, "photo.jpg"}, + {"exact", "photo.jpg", 9, "photo.jpg"}, + {"keeps extension", "vacation-photos-iceland-2026-final-v3.jpg", 20, "vacation-...l-v3.jpg"}, + {"multibyte", "日本語のファイル名です.png", 10, "日本...png"}, + {"below ellipsis", "photo.jpg", 2, "ph"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateMiddle(tt.s, tt.max) + if got != tt.want { + t.Errorf("TruncateMiddle(%q, %d) = %q, want %q", tt.s, tt.max, got, tt.want) + } + if n := displayWidth(got); n > tt.max { + t.Errorf("result is %d columns, exceeds max %d", n, tt.max) + } + if !utf8.ValidString(got) { + t.Errorf("result %q is not valid UTF-8", got) + } + }) + } +} + +func TestTruncateEnd(t *testing.T) { + if got := TruncateEnd("photo.jpg", 20); got != "photo.jpg" { + t.Errorf("under limit should be unchanged, got %q", got) + } + got := TruncateEnd("日本語のファイル名です", 8) + if want := "日本..."; got != want { + t.Errorf("TruncateEnd = %q, want %q", got, want) + } + if !utf8.ValidString(got) { + t.Errorf("result %q is not valid UTF-8", got) + } +} + +func TestSanitizeCell(t *testing.T) { + got := SanitizeCell("bad\tname\nwith\rcontrol\x00chars\x7f") + if want := "bad name with control chars "; got != want { + t.Errorf("SanitizeCell = %q, want %q", got, want) + } + if got := SanitizeCell("日本語 ok"); got != "日本語 ok" { + t.Errorf("printable runes should survive, got %q", got) + } + // C1 controls: \u009b is CSI, which a terminal may act on. + if got := SanitizeCell("csi\u009b31mred\u0085next"); got != "csi 31mred next" { + t.Errorf("C1 controls should be replaced, got %q", got) + } +} + +// longName is longer than any terminal, like the filenames in issue #4. +var longName = strings.Repeat("a-very-long-file-name-", 15) + "final.jpg" + +func fileListTable() *TableData { + td := NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED").Flexible(2) + td.AddRow("a1b2c3d4-e5f6-7890-abcd-ef1234567890", "1258000", longName, "true", "2026-03-01T00:00:00Z") + td.AddRow("b2c3d4e5-f6a7-8901-bcde-f12345678901", "348160", "doc.pdf", "false", "2026-03-02T00:00:00Z") + return td +} + +func lines(t *testing.T, out string) []string { + t.Helper() + return strings.Split(strings.TrimRight(out, "\n"), "\n") +} + +func TestTableFormatter_FitsWidth(t *testing.T) { + var buf bytes.Buffer + f := &TableFormatter{Width: 120} + + if err := f.Format(&buf, fileListTable()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := lines(t, buf.String()) + if len(got) != 3 { + t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(got), buf.String()) + } + for i, line := range got { + if n := displayWidth(line); n > 120 { + t.Errorf("line %d is %d columns, exceeds width 120: %q", i, n, line) + } + } + if strings.Contains(buf.String(), longName) { + t.Error("long filename should have been shortened") + } + if !strings.Contains(got[1], "a1b2c3d4-e5f6-7890-abcd-ef1234567890") { + t.Errorf("UUID must stay intact, got:\n%s", got[1]) + } + if !strings.Contains(got[1], "2026-03-01T00:00:00Z") { + t.Errorf("timestamp must stay intact, got:\n%s", got[1]) + } + if !strings.Contains(got[1], "...") { + t.Errorf("shortened cell should be marked with an ellipsis, got:\n%s", got[1]) + } +} + +func TestTableFormatter_NotATerminalKeepsFullValues(t *testing.T) { + var buf bytes.Buffer + f := &TableFormatter{} // Width 0: a bytes.Buffer is never a terminal. + + if err := f.Format(&buf, fileListTable()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), longName) { + t.Error("piped output must not be truncated") + } +} + +func TestTableFormatter_NarrowWidthProtectsFixedColumns(t *testing.T) { + var buf bytes.Buffer + f := &TableFormatter{Width: 40} + + if err := f.Format(&buf, fileListTable()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // 40 columns cannot hold a UUID plus a timestamp, so the row still + // overflows — but only the flexible column was shortened, and it stopped + // at minFlexWidth rather than eating into the UUID. + out := buf.String() + if !strings.Contains(out, "a1b2c3d4-e5f6-7890-abcd-ef1234567890") { + t.Errorf("UUID must not be shortened to make room, got:\n%s", out) + } + row := lines(t, out)[1] + fields := strings.Fields(row) + if n := displayWidth(fields[2]); n > minFlexWidth { + t.Errorf("flexible column is %d columns, want at most %d: %q", n, minFlexWidth, fields[2]) + } +} + +func TestTableFormatter_UnmarkedColumnsAreNeverShortened(t *testing.T) { + var buf bytes.Buffer + f := &TableFormatter{Width: 40} + + // A detail table marks nothing flexible: the value is what the user copies. + td := NewTableData() + td.AddRow("URL:", "https://ucarecdn.com/a1b2c3d4-e5f6-7890-abcd-ef1234567890/"+longName) + + if err := f.Format(&buf, td); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), longName) { + t.Error("unmarked column must be printed in full") + } +} + +func TestTableFormatter_ControlCharsKeepOneRowPerRecord(t *testing.T) { + var buf bytes.Buffer + f := &TableFormatter{} + + td := NewTableData("UUID", "FILENAME", "STORED").Flexible(1) + td.AddRow("a1b2c3d4-e5f6-7890-abcd-ef1234567890", "two\nlines\tand\ttabs.jpg", "true") + + if err := f.Format(&buf, td); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := lines(t, buf.String()); len(got) != 2 { + t.Errorf("expected 2 lines (header + 1 row), got %d:\n%s", len(got), buf.String()) + } +} + +func TestTableFormatter_DoesNotMutateInput(t *testing.T) { + td := fileListTable() + var buf bytes.Buffer + + if err := (&TableFormatter{Width: 60}).Format(&buf, td); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if td.Rows[0][2] != longName { + t.Errorf("formatting must not modify the caller's data, got %q", td.Rows[0][2]) + } +} + +// A CJK filename draws two terminal columns per character. Measured in runes it +// slips under the budget and then wraps, which is the very thing the fitter +// exists to prevent. +func TestTableFormatter_WideCharsFitTheBudget(t *testing.T) { + for _, width := range []int{72, 80, 100, 140} { + var buf bytes.Buffer + td := NewTableData("UUID", "SIZE", "FILENAME", "STORED").Flexible(2) + td.AddRow("a1b2c3d4-e5f6-7890-abcd-ef1234567890", "1258000", "日本語の写真ファイル名です-最終版.jpg", "true") + td.AddRow("b2c3d4e5-f6a7-8901-bcde-f12345678901", "348160", "ascii-document-name-here.pdf", "true") + + if err := (&TableFormatter{Width: width}).Format(&buf, td); err != nil { + t.Fatalf("width %d: unexpected error: %v", width, err) + } + + got := lines(t, buf.String()) + if len(got) != 3 { + t.Errorf("width %d: expected 3 lines, got %d:\n%s", width, len(got), buf.String()) + } + for i, line := range got { + if n := displayWidth(line); n > width { + t.Errorf("width %d: line %d draws %d columns:\n%s", width, i, n, line) + } + } + } +} + +// Columns must line up in drawn position, not in rune position. +func TestTableFormatter_WideCharsStayAligned(t *testing.T) { + var buf bytes.Buffer + td := NewTableData("FILENAME", "STORED").Flexible(0) + td.AddRow("日本語のファイル.jpg", "true") + td.AddRow("ascii.jpg", "false") + td.AddRow("mixed-日本-name.png", "true") + + if err := (&TableFormatter{Width: 100}).Format(&buf, td); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var want int + for i, line := range lines(t, buf.String()) { + col := displayWidth(line[:strings.LastIndex(line, " ")+2]) + if i == 0 { + want = col + continue + } + if col != want { + t.Errorf("line %d starts its last column at %d, want %d:\n%s", i, col, want, line) + } + } +} + +// Cutting between the two regional indicators of a flag, or between a letter +// and its combining accent, produces a mangled character. +func TestTruncateMiddle_KeepsGraphemeClustersWhole(t *testing.T) { + const flag = "\U0001F1EF\U0001F1F5" // 🇯🇵, two runes drawn as one glyph + combining := "cafe\u0301-photo-archive-final.jpg" + + for _, max := range []int{4, 8, 12, 13, 14, 15, 16, 20} { + got := TruncateMiddle("trip-"+flag+"-photos-final.jpg", max) + if strings.ContainsRune(got, 0x1F1EF) != strings.ContainsRune(got, 0x1F1F5) { + t.Errorf("max %d: split the flag emoji: %q", max, got) + } + if n := displayWidth(got); n > max { + t.Errorf("max %d: result draws %d columns: %q", max, n, got) + } + + got = TruncateMiddle(combining, max) + if strings.ContainsRune(got, 0x0301) && !strings.Contains(got, "e\u0301") { + t.Errorf("max %d: detached the combining accent: %q", max, got) + } + } +} diff --git a/internal/output/width.go b/internal/output/width.go new file mode 100644 index 0000000..539d36c --- /dev/null +++ b/internal/output/width.go @@ -0,0 +1,149 @@ +package output + +import ( + "io" + "os" + "strings" + "unicode" + + "github.com/rivo/uniseg" + "golang.org/x/term" +) + +const ( + // defaultWidth is assumed when stdout is a terminal of unreported size. + defaultWidth = 80 + + // minFlexWidth is the narrowest a shrinkable column may become. Below this + // a truncated value carries no information worth the line it occupies. + minFlexWidth = 12 + + ellipsis = "..." +) + +// terminalWidth reports the usable width of w, or 0 when w is not a terminal +// (piped, redirected, or a test buffer) and output must not be truncated. +func terminalWidth(w io.Writer) int { + f, ok := w.(*os.File) + if !ok { + return 0 + } + fd := int(f.Fd()) + if !term.IsTerminal(fd) { + return 0 + } + width, _, err := term.GetSize(fd) + if err != nil || width <= 0 { + return defaultWidth + } + return width +} + +// displayWidth reports how many terminal columns s occupies. Counting runes is +// not enough: CJK characters and most emoji draw two columns wide, while +// combining marks and joiners draw none. +func displayWidth(s string) int { + return uniseg.StringWidth(s) +} + +// cluster is one grapheme cluster and the columns it draws. Cutting anywhere +// other than a cluster boundary splits a character: a flag emoji becomes a lone +// regional indicator, an accent detaches from its letter. +type cluster struct { + text string + width int +} + +func clusters(s string) []cluster { + out := make([]cluster, 0, len(s)) + state := -1 + for len(s) > 0 { + var c string + var w int + c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state) + out = append(out, cluster{c, w}) + } + return out +} + +// prefixLen returns how many leading clusters of cs fit in max columns. +func prefixLen(cs []cluster, max int) int { + w, n := 0, 0 + for _, c := range cs { + if w+c.width > max { + break + } + w += c.width + n++ + } + return n +} + +// suffixLen returns how many trailing clusters of cs fit in max columns. +func suffixLen(cs []cluster, max int) int { + w, n := 0, 0 + for i := len(cs) - 1; i >= 0; i-- { + if w+cs[i].width > max { + break + } + w += cs[i].width + n++ + } + return n +} + +func join(cs []cluster) string { + var b strings.Builder + for _, c := range cs { + b.WriteString(c.text) + } + return b.String() +} + +// TruncateMiddle shortens s to at most max terminal columns, keeping both head +// and tail so a file extension stays visible: +// "vacation-photos-ice...-final-v3.jpg". +func TruncateMiddle(s string, max int) string { + if displayWidth(s) <= max { + return s + } + cs := clusters(s) + if max <= len(ellipsis) { + return join(cs[:prefixLen(cs, max)]) + } + keep := max - len(ellipsis) + head := (keep + 1) / 2 + + h := prefixLen(cs, head) + // Scan the tail only over what the head left behind, so a zero-width + // cluster cannot be emitted twice. + t := suffixLen(cs[h:], keep-head) + return join(cs[:h]) + ellipsis + join(cs[len(cs)-t:]) +} + +// TruncateEnd shortens s to at most max terminal columns, marking the cut with +// a trailing "...". +func TruncateEnd(s string, max int) string { + if displayWidth(s) <= max { + return s + } + cs := clusters(s) + if max <= len(ellipsis) { + return join(cs[:prefixLen(cs, max)]) + } + return join(cs[:prefixLen(cs, max-len(ellipsis))]) + ellipsis +} + +// SanitizeCell replaces control characters with spaces so that one record +// always renders as one line. Filenames and metadata come back from the API +// unfiltered, and a literal tab or newline in a cell splits the row. This +// covers C1 as well as C0, so a filename cannot smuggle U+009B — the one-byte +// form of CSI — past the formatter and into the terminal. +func SanitizeCell(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, s) +}