Skip to content

Commit 9fc6373

Browse files
authored
Merge pull request #220 from flashcatcloud/fix/list-completeness-notes
fix(cli): state how complete every list output is
2 parents c5fc946 + 6a6db05 commit 9fc6373

62 files changed

Lines changed: 1276 additions & 354 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎internal/cli/alert.go‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,19 @@ func printAlertDetail(w io.Writer, a *flashduty.AlertItem) {
201201
}
202202

203203
func newAlertEventsCmd() *cobra.Command {
204-
return &cobra.Command{
204+
var limit, page int
205+
206+
cmd := &cobra.Command{
205207
Use: "events <alert_id>",
206208
Short: "List alert events",
207209
Long: curatedLong("List the individual events that compose a given alert.", "Alerts", "ReadEventList"),
208210
Args: requireArgs("alert_id"),
209211
RunE: func(cmd *cobra.Command, args []string) error {
210212
return runCommand(cmd, args, func(ctx *RunContext) error {
211-
result, _, err := ctx.Client.Alerts.ReadEventList(cmdContext(ctx.Cmd), &flashduty.AlertEventListRequest{
212-
AlertID: ctx.Args[0],
213-
})
213+
req := &flashduty.AlertEventListRequest{AlertID: ctx.Args[0]}
214+
req.Limit = limit
215+
req.Page = page
216+
result, _, err := ctx.Client.Alerts.ReadEventList(cmdContext(ctx.Cmd), req)
214217
if err != nil {
215218
return err
216219
}
@@ -228,10 +231,21 @@ func newAlertEventsCmd() *cobra.Command {
228231
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }},
229232
}
230233

231-
return ctx.PrintTotal(result.Items, cols, len(result.Items))
234+
// Flags left unset are not sent; the statement is judged against
235+
// the schema defaults the server then applies.
236+
defaults := listDefaultsFor("Alerts", "ReadEventList")
237+
win := requestWindow(ctx.Cmd, nil, defaults)
238+
return ctx.PrintPage(result.Items, cols, listPage{
239+
Count: len(result.Items), Page: win.page, Base: win.base, Limit: win.limit, Total: int(result.Total), More: moreOf(result.HasNextPage),
240+
})
232241
})
233242
},
234243
}
244+
245+
cmd.Flags().IntVar(&limit, "limit", 0, "Max events (max 100; server default 20)")
246+
cmd.Flags().IntVar(&page, "page", 0, "Page number (starts at 1)")
247+
248+
return cmd
235249
}
236250

237251
func newAlertTimelineCmd() *cobra.Command {
@@ -287,7 +301,9 @@ func newAlertTimelineCmd() *cobra.Command {
287301
}},
288302
}
289303

290-
return ctx.Printer.Print(result.Items, cols)
304+
return ctx.PrintPage(result.Items, cols, listPage{
305+
Count: len(result.Items), Page: page, Base: pageOne, Limit: limit, Total: -1, More: moreOf(result.HasNextPage),
306+
})
291307
})
292308
},
293309
}

‎internal/cli/automation.go‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ the rule under that team. The scope is immutable after creation.
111111
if err != nil {
112112
return err
113113
}
114-
return printGenericResult(ctx, out)
114+
return printGenericResult(ctx, out, nil, listDefaults{})
115115
})
116116
},
117117
}
@@ -167,7 +167,7 @@ func newAutomationListCmd() *cobra.Command {
167167
if err != nil {
168168
return err
169169
}
170-
return printGenericResult(ctx, out)
170+
return printGenericResult(ctx, out, nil, listDefaultsFor("Automations", "RuleReadList"))
171171
})
172172
},
173173
}
@@ -194,7 +194,7 @@ func newAutomationGetCmd() *cobra.Command {
194194
if err != nil {
195195
return err
196196
}
197-
return printGenericResult(ctx, out)
197+
return printGenericResult(ctx, out, nil, listDefaults{})
198198
})
199199
},
200200
}
@@ -327,7 +327,7 @@ func newAutomationUpdateCmd() *cobra.Command {
327327
if err != nil {
328328
return err
329329
}
330-
return printGenericResult(ctx, out)
330+
return printGenericResult(ctx, out, nil, listDefaults{})
331331
})
332332
},
333333
}
@@ -423,7 +423,7 @@ func newAutomationRunsCmd() *cobra.Command {
423423
if err != nil {
424424
return err
425425
}
426-
return printGenericResult(ctx, out)
426+
return printGenericResult(ctx, out, nil, listDefaultsFor("Automations", "RunReadList"))
427427
})
428428
},
429429
}
@@ -452,7 +452,7 @@ func newAutomationTemplatesCmd() *cobra.Command {
452452
if err != nil {
453453
return err
454454
}
455-
return printGenericResult(ctx, out)
455+
return printGenericResult(ctx, out, nil, listDefaults{})
456456
})
457457
},
458458
}
@@ -490,7 +490,7 @@ The trigger authenticates with its one-time token, not the account app key. Pass
490490
if err != nil {
491491
return err
492492
}
493-
return printGenericResult(ctx, out)
493+
return printGenericResult(ctx, out, nil, listDefaults{})
494494
})
495495
},
496496
}

‎internal/cli/channel.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func newChannelListCmd() *cobra.Command {
9696
{Header: "CREATOR", Field: func(v any) string { return v.(channelRow).CreatorName }},
9797
}
9898

99-
return ctx.PrintTotal(rows, cols, int(result.Total))
99+
return ctx.PrintList(rows, cols, len(rows), page, limit, int(result.Total))
100100
})
101101
},
102102
}
@@ -225,7 +225,7 @@ func newChannelEscalateRuleListCmd() *cobra.Command {
225225
}
226226
proj = bounded.([]map[string]any)
227227
noteProjectionBound(cmd, bound)
228-
return ctx.PrintTotal(proj, nil, len(proj))
228+
return ctx.PrintPage(proj, nil, listPage{Count: len(proj), Total: -1})
229229
}
230230

231231
cols := []output.Column{
@@ -235,7 +235,7 @@ func newChannelEscalateRuleListCmd() *cobra.Command {
235235
{Header: "PRIORITY", Field: func(v any) string { return strconv.FormatInt(v.(flashduty.EscalateRuleItem).Priority, 10) }},
236236
{Header: "UPDATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.EscalateRuleItem).UpdatedAt) }},
237237
}
238-
return ctx.PrintTotal(out.Items, cols, len(out.Items))
238+
return ctx.PrintPage(out.Items, cols, listPage{Count: len(out.Items), Total: -1})
239239
})
240240
},
241241
}

‎internal/cli/command.go‎

Lines changed: 200 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,38 +46,217 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro
4646
return fn(ctx)
4747
}
4848

49-
// PrintList prints items as a table and appends a "Showing N results (page P, total T)." footer.
50-
// In structured mode the footer is suppressed to keep stdout byte-pure for
51-
// jq/toon pipelines, so a page with more rows beyond it is announced on
52-
// stderr instead — without it a consumer sees a partial page
53-
// (e.g. the default --limit 20 of a far larger total) as the whole set. The
54-
// judgment accounts for the page offset: on the last page
55-
// ((page-1)*limit+count reaches total) there is no rest, so no note.
56-
func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, limit, total int) error {
57-
if err := ctx.Printer.Print(items, cols); err != nil {
58-
return err
49+
// moreState is what a list response says about whether rows exist beyond the
50+
// ones returned.
51+
type moreState int8
52+
53+
const (
54+
moreUnknown moreState = iota // the API gave no signal
55+
moreNo // has_next_page=false / empty next cursor
56+
moreYes // has_next_page=true / non-empty next cursor
57+
)
58+
59+
// moreOf maps a response's has_next_page flag to a moreState.
60+
func moreOf(hasNext bool) moreState {
61+
if hasNext {
62+
return moreYes
5963
}
60-
if ctx.Structured() {
61-
if (page-1)*limit+count < total {
62-
_, _ = 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)
64+
return moreNo
65+
}
66+
67+
// pageBase says how a command numbers its pages. The zero value means the
68+
// command has no page notion at all.
69+
type pageBase int8
70+
71+
const (
72+
pageNone pageBase = iota // no page number (top-N lists, one-shot lists)
73+
pageOne // pages start at 1
74+
pageZero // pages start at 0
75+
pageUnknown // pages by number, base not declared by the API schema
76+
)
77+
78+
// listDefaults is what an API's request schema declares for a list: the page
79+
// size served when the request sets none (0 when undeclared) and how pages are
80+
// numbered.
81+
type listDefaults struct {
82+
Limit int
83+
PageBase pageBase
84+
}
85+
86+
// listDefaultsFor returns the schema-declared defaults of an SDK Service.Method.
87+
func listDefaultsFor(service, method string) listDefaults {
88+
return listDefaultsBySDKMethod[service+"."+method]
89+
}
90+
91+
// listPage is everything a list response tells us about its own completeness.
92+
// It is the single input to the completeness statement every list output makes,
93+
// so a partial page is never mistaken for the whole set.
94+
type listPage struct {
95+
Count int // rows shown
96+
Page int // page number as sent to the API (0 when the request set none)
97+
Base pageBase // how Page is numbered
98+
Limit int // effective page size: requested, else the schema default; 0 when unknown
99+
Total int // total matching rows; negative when the API returned none
100+
More moreState // has_next_page / next cursor, when the API returned one
101+
}
102+
103+
// totalKnown reports whether Total is a usable server total. A total below the
104+
// shown count is an absent field, not a real total.
105+
func (p listPage) totalKnown() bool { return p.Total >= 0 && p.Total >= p.Count }
106+
107+
// offset returns how many rows precede this page, when that is knowable: always
108+
// for the first page, and for later pages only with a known page size and base.
109+
func (p listPage) offset() (int, bool) {
110+
switch {
111+
case p.Base == pageNone, p.Page == 0 && p.Base != pageOne, p.Base == pageOne && p.Page <= 1:
112+
return 0, true
113+
case p.Limit <= 0:
114+
return 0, false
115+
case p.Base == pageOne:
116+
return max(p.Page-1, 0) * p.Limit, true
117+
case p.Base == pageZero:
118+
return p.Page * p.Limit, true
119+
}
120+
return 0, false
121+
}
122+
123+
// nextPage returns the page number after this one, when the base is known.
124+
func (p listPage) nextPage() (int, bool) {
125+
if p.Base != pageOne && p.Base != pageZero {
126+
return 0, false
127+
}
128+
if p.Base == pageOne {
129+
return max(p.Page, 1) + 1, true
130+
}
131+
return p.Page + 1, true
132+
}
133+
134+
// results renders a row count with its noun.
135+
func results(n int) string {
136+
if n == 1 {
137+
return "1 result"
138+
}
139+
return fmt.Sprintf("%d results", n)
140+
}
141+
142+
// statement applies the completeness rules in order and returns the table
143+
// footer and the structured-mode stderr note ("" when no warning applies):
144+
//
145+
// a. total known: "showing N of T", plus a next-page hint when rows remain;
146+
// b. else has_next_page/cursor known: true warns, false is complete;
147+
// c. else a full page (N == limit) cannot tell: warn that more may exist;
148+
// d. else only the plain count.
149+
//
150+
// A count is never labelled a total, and "rows remain" is claimed from a page
151+
// offset only when that offset is known.
152+
func (p listPage) statement(cmd *cobra.Command) (footer, note string) {
153+
pageText := ""
154+
if p.Base != pageNone && (p.Page > 0 || p.Base == pageOne || p.Base == pageZero) {
155+
pageText = fmt.Sprintf("page %d", p.Page)
156+
}
157+
paren := func(extra string) string {
158+
switch {
159+
case pageText != "" && extra != "":
160+
return " (" + pageText + ", " + extra + ")"
161+
case pageText != "":
162+
return " (" + pageText + ")"
163+
case extra != "":
164+
return " (" + extra + ")"
63165
}
64-
return nil
166+
return ""
65167
}
66-
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
67-
return nil
168+
hint := moreHint(cmd, p)
169+
// warn builds the footer and note for a partial list: "<shown>, <why>: <how>".
170+
warn := func(shown, why string) (string, string) {
171+
body := "showing " + shown
172+
if why != "" {
173+
body += ", " + why
174+
}
175+
if hint != "" {
176+
body += ": " + hint
177+
}
178+
return "S" + body[1:] + ".", "note: " + body
179+
}
180+
switch {
181+
case p.totalKnown():
182+
footer = fmt.Sprintf("Showing %s%s.", results(p.Count), paren(fmt.Sprintf("total %d", p.Total)))
183+
if off, ok := p.offset(); ok && off+p.Count < p.Total {
184+
body := fmt.Sprintf("showing %d of %d total results%s", p.Count, p.Total, paren(""))
185+
if hint != "" {
186+
body += ": " + hint
187+
}
188+
note = "note: " + body
189+
}
190+
return footer, note
191+
case p.More == moreYes:
192+
return warn(results(p.Count)+paren(""), "more exist")
193+
case p.More == moreNo:
194+
return fmt.Sprintf("Showing %s%s.", results(p.Count), paren("")), ""
195+
case p.Limit > 0 && p.Count == p.Limit:
196+
return warn(results(p.Count)+paren(fmt.Sprintf("= limit %d", p.Limit)), "more may exist")
197+
}
198+
return fmt.Sprintf("Showing %s%s.", results(p.Count), paren("")), ""
68199
}
69200

70-
// PrintTotal prints items as a table and appends a "Total: N" footer.
71-
func (ctx *RunContext) PrintTotal(items any, cols []output.Column, total int) error {
201+
// moreHint names the flags this command actually has for reaching further rows.
202+
// A page number is offered only when the page base is known.
203+
func moreHint(cmd *cobra.Command, p listPage) string {
204+
has := func(name string) bool { return cmd.Flags().Lookup(name) != nil }
205+
paged := p.Base != pageNone && has("page")
206+
next, nextKnown := p.nextPage()
207+
pageFlag := "--page"
208+
if nextKnown {
209+
pageFlag = fmt.Sprintf("--page %d", next)
210+
}
211+
switch {
212+
case paged && has("limit"):
213+
return "raise --limit or use " + pageFlag
214+
case paged:
215+
return "use " + pageFlag
216+
case has("search-after-ctx"):
217+
return "pass the response's search_after_ctx as --search-after-ctx"
218+
case has("cursor"):
219+
return "pass the response's next_cursor as --cursor"
220+
case has("limit"):
221+
return "raise --limit"
222+
}
223+
return ""
224+
}
225+
226+
// announce states how complete the list just printed is: a footer on stdout in
227+
// table mode, a stderr note (only when rows may be missing) in structured mode
228+
// so stdout stays byte-pure for jq/toon pipelines.
229+
func (ctx *RunContext) announce(pg listPage) {
230+
if ctx.Structured() {
231+
ctx.noteIncomplete(pg)
232+
return
233+
}
234+
footer, _ := pg.statement(ctx.Cmd)
235+
_, _ = fmt.Fprintln(ctx.Writer, footer)
236+
}
237+
238+
// noteIncomplete writes the stderr note when rows may be missing. Commands that
239+
// own their output format (session list) call it directly.
240+
func (ctx *RunContext) noteIncomplete(pg listPage) {
241+
if _, note := pg.statement(ctx.Cmd); note != "" {
242+
_, _ = fmt.Fprintln(ctx.Cmd.ErrOrStderr(), note)
243+
}
244+
}
245+
246+
// PrintPage prints items and then states how complete they are (see listPage).
247+
func (ctx *RunContext) PrintPage(items any, cols []output.Column, pg listPage) error {
72248
if err := ctx.Printer.Print(items, cols); err != nil {
73249
return err
74250
}
75-
if !ctx.Structured() {
76-
_, _ = fmt.Fprintf(ctx.Writer, "Total: %d\n", total)
77-
}
251+
ctx.announce(pg)
78252
return nil
79253
}
80254

255+
// PrintList is PrintPage for a response that carried a total.
256+
func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, limit, total int) error {
257+
return ctx.PrintPage(items, cols, listPage{Count: count, Page: page, Base: pageOne, Limit: limit, Total: total})
258+
}
259+
81260
// WriteResult prints a success message as plain text or JSON.
82261
func (ctx *RunContext) WriteResult(message string) {
83262
writeResult(ctx.Writer, message)

0 commit comments

Comments
 (0)