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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
go 1.26.0

require (
github.com/flashcatcloud/go-flashduty v0.15.16
github.com/flashcatcloud/go-flashduty v0.15.17
github.com/mattn/go-runewidth v0.0.30
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/flashcatcloud/go-flashduty v0.15.16 h1:h04Z0NGxB4ujlfKliVPD8QhgLgBObeAoRMz9rXJpnes=
github.com/flashcatcloud/go-flashduty v0.15.16/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.17 h1:AkWSRYEUdMMYAI7WJbzHIHBnZ+FqcbMAwK8aInOOLpI=
github.com/flashcatcloud/go-flashduty v0.15.17/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ type listPage struct {
// TotalCapped marks an endpoint that stops counting at serverCountCap; its
// callers must also set More, the only "rows remain" signal past the cap.
TotalCapped bool
// Siblings names the response fields beside the rows that are not
// pagination metadata; a table shows only the rows.
Siblings []string
}

// totalKnown reports whether Total is a usable server total. A total below the
Expand Down
8 changes: 5 additions & 3 deletions internal/cli/gen_support.go
Original file line number Diff line number Diff line change
Expand Up @@ -698,9 +698,11 @@ func narrowNumbers(value any) any {

// listEnvelopeKey reports whether value is a paginated list envelope — exactly
// one array field named items/docs/list with only scalar siblings (total,
// has_next_page, search_after_ctx, …) — and returns the row array's key. It
// mirrors cligen's listEnvelope (internal/cmd/cligen), which classifies the
// same shape when generating these commands.
// has_next_page, search_after_ctx, …) — and returns the row array's key. It is
// stricter than cligen's listEnvelope, which also accepts object or array
// siblings: those cannot be shortened to fit the output limit (a calendar
// year's days map alone exceeds it), so such a response prints whole, like a
// detail object.
func listEnvelopeKey(value map[string]any) (string, bool) {
key := ""
for name, field := range value {
Expand Down
56 changes: 36 additions & 20 deletions internal/cli/generic_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ func renderGenericTable(ctx *RunContext, data any, win listWindow) error {
}

if rows, pg, ok := genericListPage(v, win); ok {
return renderRowTable(ctx, rows, pg)
if err := renderRowTable(ctx, rows, pg); err != nil {
return err
}
if len(pg.Siblings) > 0 {
_, _ = fmt.Fprintf(ctx.Writer, "Also in the response: %s (use --json to see it).\n", strings.Join(pg.Siblings, ", "))
}
return nil
}
if v.Kind() == reflect.Struct {
if err := renderVertical(ctx, v); err != nil {
Expand Down Expand Up @@ -96,7 +102,7 @@ func genericListPage(v reflect.Value, win listWindow) (rows reflect.Value, pg li
if rows, meta, ok = listEnvelope(v); !ok {
return reflect.Value{}, pg, false
}
pg.Total, pg.More = meta.total, meta.more
pg.Total, pg.More, pg.Siblings = meta.total, meta.more, meta.siblings
default:
return reflect.Value{}, pg, false
}
Expand All @@ -106,18 +112,27 @@ func genericListPage(v reflect.Value, win listWindow) (rows reflect.Value, pg li

// listMeta is the pagination metadata a list envelope carries.
type listMeta struct {
total int // negative when the envelope has no Total field
more moreState // from HasNextPage or NextCursor
total int // negative when the envelope has no Total field
more moreState // from HasNextPage, HasMore or NextCursor
siblings []string // wire names of the non-pagination fields beside the rows
}

// listRowFields are the Go names of the row fields — wire items, docs, list, the
// names cligen's listEnvelope matches — that mark a list envelope even when it
// carries more than pagination metadata.
var listRowFields = map[string]bool{"Items": true, "Docs": true, "List": true}

// listEnvelope reports whether struct v is a paginated list envelope: exactly
// one exported field that is a slice of structs (the rows), with any remaining
// fields limited to pagination metadata. It returns the rows value and that
// metadata (Total; HasNextPage or NextCursor as the has-more signal).
// fields limited to pagination metadata — or, when the rows field is Items, Docs
// or List, any other fields too, reported as siblings. It returns the rows value
// and that metadata (Total; HasNextPage, HasMore or NextCursor as the has-more
// signal).
func listEnvelope(v reflect.Value) (rows reflect.Value, meta listMeta, ok bool) {
t := v.Type()
meta.total = -1
found := false
rowsName := ""
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" { // unexported
Expand All @@ -128,18 +143,19 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, meta listMeta, ok bool)
if found {
return reflect.Value{}, meta, false // ambiguous: more than one row slice
}
rows, found = fv, true
rows, found, rowsName = fv, true, f.Name
continue
}
if !isListMetadataField(f, fv) {
return reflect.Value{}, meta, false
meta.siblings = append(meta.siblings, wireName(f))
continue
}
switch f.Name {
case "Total":
if meta.total < 0 {
meta.total = int(fv.Int())
}
case "HasNextPage":
case "HasNextPage", "HasMore":
meta.more = moreNo
if fv.Bool() {
meta.more = moreYes
Expand All @@ -151,20 +167,28 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, meta listMeta, ok bool)
}
}
}
if !found {
if !found || (len(meta.siblings) > 0 && !listRowFields[rowsName]) {
return reflect.Value{}, meta, false
}
return rows, meta, true
}

// wireName is f's JSON name, falling back to the Go field name.
func wireName(f reflect.StructField) string {
if name, _, _ := strings.Cut(f.Tag.Get("json"), ","); name != "" && name != "-" {
return name
}
return f.Name
}

func isListMetadataField(f reflect.StructField, fv reflect.Value) bool {
if f.Anonymous && f.Name == "ListOptions" {
return true
}
switch f.Name {
case "Total":
return fv.CanInt()
case "HasNextPage":
case "HasNextPage", "HasMore":
return fv.Kind() == reflect.Bool
case "SearchAfterCtx", "NextCursor":
return fv.Kind() == reflect.String
Expand Down Expand Up @@ -454,15 +478,7 @@ func isScalarType(t reflect.Type) bool {
// headerFromField derives a column header from a field's json tag (upper-cased),
// falling back to the Go field name.
func headerFromField(f reflect.StructField) string {
if tag := f.Tag.Get("json"); tag != "" {
if c := strings.IndexByte(tag, ','); c >= 0 {
tag = tag[:c]
}
if tag != "" && tag != "-" {
return strings.ToUpper(tag)
}
}
return strings.ToUpper(f.Name)
return strings.ToUpper(wireName(f))
}

// jsonFallback prints indented JSON — the last resort when a response is neither
Expand Down
68 changes: 68 additions & 0 deletions internal/cli/generic_table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,74 @@ func TestRenderGenericTable_Empty(t *testing.T) {
}
}

// An items envelope may carry fields beyond pagination metadata (calendar
// event list's days map): the rows still print as a table and the extra fields
// are named instead of being flattened into the output.
func TestRenderGenericTable_EnvelopeWithSiblings(t *testing.T) {
type resp struct {
Days map[string]heuristicRow `json:"days"`
Items []heuristicRow `json:"items"`
Total int `json:"total"`
}
var buf bytes.Buffer
in := &resp{Days: map[string]heuristicRow{"2026-01-01": {}}, Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1}
if err := renderGenericTable(tableCtx(&buf), in, listWindow{}); err != nil {
t.Fatalf("render: %v", err)
}
got := buf.String()
for _, want := range []string{"NAME", "alpha", "(total 1).", "Also in the response: days (use --json to see it)."} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q\n---\n%s", want, got)
}
}
if strings.Contains(got, "2026-01-01") {
t.Errorf("sibling values must stay out of the table\n%s", got)
}
}

func TestRenderGenericTable_EmptyEnvelopeWithSiblings(t *testing.T) {
type resp struct {
Items []heuristicRow `json:"items"`
ReleaseLimited bool `json:"release_limited"`
}
var buf bytes.Buffer
if err := renderGenericTable(tableCtx(&buf), &resp{}, listWindow{}); err != nil {
t.Fatalf("render: %v", err)
}
want := "No results.\nAlso in the response: release_limited (use --json to see it).\n"
if got := buf.String(); got != want {
t.Errorf("got %q, want %q", got, want)
}
}

// HasMore is pagination metadata (the work item list's has-more flag), not a
// sibling worth naming.
func TestGenericListPage_HasMoreIsMetadata(t *testing.T) {
type resp struct {
HasMore bool `json:"has_more"`
Items []heuristicRow `json:"items"`
}
_, pg, ok := genericListPage(reflect.ValueOf(resp{HasMore: true, Items: []heuristicRow{{Name: "a"}}}), listWindow{})
if !ok {
t.Fatal("expected a list envelope")
}
if pg.More != moreYes || len(pg.Siblings) != 0 {
t.Errorf("More = %v, Siblings = %v; want moreYes and none", pg.More, pg.Siblings)
}
}

// A struct whose only row slice is not items/docs/list and that carries other
// fields is a detail object with a nested list, not a list envelope.
func TestGenericListPage_NestedRowsInDetailObject(t *testing.T) {
type detail struct {
Name string `json:"name"`
Responders []heuristicRow `json:"responders"`
}
if _, _, ok := genericListPage(reflect.ValueOf(detail{Name: "x"}), listWindow{}); ok {
t.Fatal("detail object with a nested row slice must not be treated as a list")
}
}

func TestRenderGenericTable_DetailVertical(t *testing.T) {
var buf bytes.Buffer
row := heuristicRow{Name: "solo", Count: 42}
Expand Down
33 changes: 25 additions & 8 deletions internal/cli/zz_generated_calendars.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading