Skip to content

Commit 5491bdf

Browse files
committed
fix(cli): announce truncated structured list pages on stderr
In json/toon mode PrintList suppresses the "Showing N results" footer to keep stdout byte-pure for pipelines, so a page short of the server total (e.g. the default --limit 20 of a much larger total) was indistinguishable from the whole set. PrintList now prints a "note: showing N of T total results (page P)" line on stderr when the page does not cover the total; table mode is unchanged. Also pin, at both the command and the built-binary level, that a compact list projection which cannot fit its byte budget fails hard: non-zero exit, the refusal on stderr, and nothing on stdout — so a pipeline reading stdout sees a failed call rather than an empty page.
1 parent ae99505 commit 5491bdf

3 files changed

Lines changed: 184 additions & 2 deletions

File tree

cmd/flashduty/main_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package main
33
import (
44
"bytes"
55
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
69
"os/exec"
710
"path/filepath"
811
"runtime"
@@ -108,3 +111,49 @@ func TestSetVersionInfoBeforeExecute(t *testing.T) {
108111
}
109112
}
110113
}
114+
115+
// Test 79: When a compact list projection overflows its byte budget, the
116+
// binary exits non-zero, writes nothing to stdout, and reports the error on
117+
// stderr — a pipeline reading stdout must see a failed call, never an empty
118+
// page masquerading as "no data".
119+
func TestProjectionOverflowFailsHard(t *testing.T) {
120+
binPath := buildTestBinary(t, "")
121+
122+
// Stub the alert-event list endpoint with a page whose projection stays
123+
// over the 16 KiB budget even after value shortening.
124+
var body strings.Builder
125+
body.WriteString(`{"request_id":"r","error":{"code":"OK","message":""},"data":{"total":100,"items":[`)
126+
for i := 0; i < 100; i++ {
127+
if i > 0 {
128+
body.WriteByte(',')
129+
}
130+
fmt.Fprintf(&body, `{"event_id":"%024x","alert_id":"%024x","event_severity":"Warning","event_status":"Triggered","event_time":1712000000,"title":%q}`,
131+
i, i+1_000_000, strings.Repeat("x", 200))
132+
}
133+
body.WriteString(`]}}`)
134+
135+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+
w.Header().Set("Content-Type", "application/json")
137+
_, _ = w.Write([]byte(body.String()))
138+
}))
139+
defer srv.Close()
140+
141+
run := exec.Command(binPath, "alert-event", "list", "--limit", "100",
142+
"--output-format", "json", "--app-key", "test-key", "--base-url", srv.URL)
143+
// Isolate HOME so the test never reads the developer's real CLI config.
144+
run.Env = append(os.Environ(), "HOME="+t.TempDir())
145+
var stdout, stderr bytes.Buffer
146+
run.Stdout = &stdout
147+
run.Stderr = &stderr
148+
149+
err := run.Run()
150+
if err == nil {
151+
t.Fatalf("[#79] expected non-zero exit code for an over-budget projection, got success; stderr:\n%s", stderr.String())
152+
}
153+
if stdout.Len() != 0 {
154+
t.Errorf("[#79] a failed projection must write nothing to stdout, got %d bytes:\n%s", stdout.Len(), stdout.String())
155+
}
156+
if !strings.Contains(stderr.String(), "Error: projected list is") || !strings.Contains(stderr.String(), "exceeds the 16384-byte limit") {
157+
t.Errorf("[#79] stderr should report the byte-limit refusal, got:\n%s", stderr.String())
158+
}
159+
}

internal/cli/command.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,21 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro
4848
}
4949

5050
// PrintList prints items as a table and appends a "Showing N results (page P, total T)." footer.
51+
// In structured mode the footer is suppressed to keep stdout byte-pure for
52+
// jq/toon pipelines, so a page that doesn't cover the total is announced on
53+
// stderr instead — without it a consumer sees a partial page
54+
// (e.g. the default --limit 20 of a far larger total) as the whole set.
5155
func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, total int) error {
5256
if err := ctx.Printer.Print(items, cols); err != nil {
5357
return err
5458
}
55-
if !ctx.Structured() {
56-
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
59+
if ctx.Structured() {
60+
if total > count {
61+
_, _ = fmt.Fprintf(ctx.Cmd.ErrOrStderr(), "note: showing %d of %d total results (page %d); raise --limit or use --page for the rest\n", count, total, page)
62+
}
63+
return nil
5764
}
65+
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
5866
return nil
5967
}
6068

internal/cli/command_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,131 @@ type readerFunc func([]byte) (int, error)
15941594

15951595
func (f readerFunc) Read(p []byte) (int, error) { return f(p) }
15961596

1597+
// ---------------------------------------------------------------------------
1598+
// Structured list truncation indicator
1599+
// ---------------------------------------------------------------------------
1600+
1601+
// TestCommandAlertListStructuredAnnouncesTruncation pins that a structured
1602+
// list page which doesn't cover the server-reported total says so on stderr:
1603+
// stdout is reserved for the jq/toon pipeline, so without the note a consumer
1604+
// sees the default --limit page as the whole set. Table mode keeps its
1605+
// "Showing N results" footer on stdout and emits no stderr note.
1606+
func TestCommandAlertListStructuredAnnouncesTruncation(t *testing.T) {
1607+
twoOfFive := map[string]any{"items": []any{alertRow(), alertRow()}, "total": 5}
1608+
1609+
t.Run("json page short of total", func(t *testing.T) {
1610+
saveAndResetGlobals(t)
1611+
stub := newGFStub(t)
1612+
stub.data = twoOfFive
1613+
1614+
out, stderrText, err := execCommandSplit("alert", "list", "--output-format", "json")
1615+
if err != nil {
1616+
t.Fatalf("execCommandSplit: %v", err)
1617+
}
1618+
var rows []map[string]any
1619+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
1620+
t.Fatalf("stdout must stay parseable JSON: %v\n%s", err, out)
1621+
}
1622+
if len(rows) != 2 {
1623+
t.Fatalf("got %d rows, want the 2 the page carries", len(rows))
1624+
}
1625+
if !strings.Contains(stderrText, "note: showing 2 of 5 total results (page 1)") {
1626+
t.Errorf("truncated structured page should announce itself on stderr, got:\n%s", stderrText)
1627+
}
1628+
})
1629+
1630+
t.Run("json page covers total", func(t *testing.T) {
1631+
saveAndResetGlobals(t)
1632+
stub := newGFStub(t)
1633+
stub.data = map[string]any{"items": []any{alertRow(), alertRow()}, "total": 2}
1634+
1635+
_, stderrText, err := execCommandSplit("alert", "list", "--output-format", "json")
1636+
if err != nil {
1637+
t.Fatalf("execCommandSplit: %v", err)
1638+
}
1639+
if strings.Contains(stderrText, "note: showing") {
1640+
t.Errorf("a page covering the total must not cry truncation, got:\n%s", stderrText)
1641+
}
1642+
})
1643+
1644+
t.Run("table keeps the footer on stdout", func(t *testing.T) {
1645+
saveAndResetGlobals(t)
1646+
stub := newGFStub(t)
1647+
stub.data = twoOfFive
1648+
1649+
out, stderrText, err := execCommandSplit("alert", "list")
1650+
if err != nil {
1651+
t.Fatalf("execCommandSplit: %v", err)
1652+
}
1653+
if !strings.Contains(out, "Showing 2 results (page 1, total 5).") {
1654+
t.Errorf("table mode should keep the stdout footer, got:\n%s", out)
1655+
}
1656+
if strings.Contains(stderrText, "note: showing") {
1657+
t.Errorf("table mode already footers the count; no stderr note wanted, got:\n%s", stderrText)
1658+
}
1659+
})
1660+
}
1661+
1662+
// ---------------------------------------------------------------------------
1663+
// Projection overflow is a hard failure
1664+
// ---------------------------------------------------------------------------
1665+
1666+
// TestCommandListProjectionOverflowFails pins that a compact list projection
1667+
// which cannot fit the byte budget fails the command instead of emitting
1668+
// anything: Execute returns the error and stdout stays empty, so a pipeline
1669+
// reading stdout sees a failed call, never an empty page masquerading as
1670+
// "no data".
1671+
func TestCommandListProjectionOverflowFails(t *testing.T) {
1672+
t.Run("incident list", func(t *testing.T) {
1673+
saveAndResetGlobals(t)
1674+
stub := newGFStub(t)
1675+
items := make([]any, 100)
1676+
for i := range items {
1677+
row := incidentRow()
1678+
row["incident_id"] = fmt.Sprintf("inc-%024d", i)
1679+
row["title"] = strings.Repeat("x", 200)
1680+
items[i] = row
1681+
}
1682+
stub.data = map[string]any{"items": items, "total": len(items)}
1683+
1684+
out, stderrText, err := execCommandSplit("incident", "list", "--limit", "100", "--output-format", "json")
1685+
if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") {
1686+
t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err)
1687+
}
1688+
if out != "" {
1689+
t.Errorf("a failed projection must write nothing to stdout, got %d bytes", len(out))
1690+
}
1691+
if strings.Contains(stderrText, "exceeds the") {
1692+
t.Errorf("the error is returned for the entrypoint to report, not printed mid-run, got:\n%s", stderrText)
1693+
}
1694+
})
1695+
1696+
t.Run("alert-event list", func(t *testing.T) {
1697+
saveAndResetGlobals(t)
1698+
stub := newGFStub(t)
1699+
items := make([]any, 100)
1700+
for i := range items {
1701+
items[i] = map[string]any{
1702+
"event_id": fmt.Sprintf("%024x", i),
1703+
"alert_id": fmt.Sprintf("%024x", i+1_000_000),
1704+
"event_severity": "Warning",
1705+
"event_status": "Triggered",
1706+
"event_time": 1712000000 + i,
1707+
"title": strings.Repeat("x", 200),
1708+
}
1709+
}
1710+
stub.data = map[string]any{"items": items, "total": len(items)}
1711+
1712+
out, _, err := execCommandSplit("alert-event", "list", "--limit", "100", "--output-format", "json")
1713+
if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") {
1714+
t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err)
1715+
}
1716+
if out != "" {
1717+
t.Errorf("a failed projection must write nothing to stdout, got %d bytes", len(out))
1718+
}
1719+
})
1720+
}
1721+
15971722
// ---------------------------------------------------------------------------
15981723
// Helpers
15991724
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)