diff --git a/internal/cli/display_columns.go b/internal/cli/display_columns.go new file mode 100644 index 0000000..6602926 --- /dev/null +++ b/internal/cli/display_columns.go @@ -0,0 +1,109 @@ +package cli + +// colSpec is a display-only column for the generic table renderer: which row +// field to show, its header, and an optional width cap. It NEVER affects flags or +// json/toon output — a wrong entry degrades a single table column at worst, it +// can't cause a functional error. Field is the Go struct field name on the row +// type; timestamp fields are detected and formatted automatically. +type colSpec struct { + Header string + Field string + MaxWidth int +} + +// displayColumns maps a go-flashduty response row type (by Go type name) to its +// human table columns, seeded from the hand-written column sets the curated +// commands used before the CLI converged on generated commands. Row types with +// no entry fall back to the reflective heuristic in generic_table.go. +// +// Names are intentionally not resolved here (e.g. ChannelItem shows TEAM_ID / +// CREATOR_ID, not team/creator names): id→name enrichment belongs in the API +// response, not the client. Until the API carries those names, the table shows +// the ids; json/toon is unaffected either way. +var displayColumns = map[string][]colSpec{ + "IncidentInfo": { + {Header: "ID", Field: "IncidentID"}, + {Header: "TITLE", Field: "Title", MaxWidth: 50}, + {Header: "SEVERITY", Field: "IncidentSeverity"}, + {Header: "PROGRESS", Field: "Progress"}, + {Header: "CHANNEL", Field: "ChannelName"}, + {Header: "CREATED", Field: "StartTime"}, + }, + "PastIncidentItem": { + {Header: "ID", Field: "IncidentID"}, + {Header: "TITLE", Field: "Title", MaxWidth: 50}, + {Header: "SEVERITY", Field: "IncidentSeverity"}, + {Header: "PROGRESS", Field: "Progress"}, + {Header: "CHANNEL", Field: "ChannelName"}, + {Header: "CREATED", Field: "StartTime"}, + }, + "AlertInfo": { + {Header: "ALERT_ID", Field: "AlertID"}, + {Header: "TITLE", Field: "Title", MaxWidth: 50}, + {Header: "SEVERITY", Field: "AlertSeverity"}, + {Header: "STATUS", Field: "AlertStatus"}, + {Header: "STARTED", Field: "StartTime"}, + }, + "AlertItem": { + {Header: "ID", Field: "AlertID"}, + {Header: "TITLE", Field: "Title", MaxWidth: 50}, + {Header: "SEVERITY", Field: "AlertSeverity"}, + {Header: "STATUS", Field: "AlertStatus"}, + {Header: "EVENTS", Field: "EventCnt"}, + {Header: "CHANNEL", Field: "ChannelName"}, + {Header: "STARTED", Field: "StartTime"}, + }, + "AlertEventItem": { + {Header: "EVENT_ID", Field: "EventID"}, + {Header: "ALERT_ID", Field: "AlertID"}, + {Header: "SEVERITY", Field: "EventSeverity"}, + {Header: "STATUS", Field: "EventStatus"}, + {Header: "TIME", Field: "EventTime"}, + {Header: "TITLE", Field: "Title", MaxWidth: 50}, + }, + "ChangeItem": { + {Header: "ID", Field: "ChangeID"}, + {Header: "TITLE", Field: "Title", MaxWidth: 50}, + {Header: "STATUS", Field: "ChangeStatus"}, + {Header: "CHANNEL", Field: "ChannelName"}, + {Header: "TIME", Field: "StartTime"}, + }, + "ChannelItem": { + {Header: "ID", Field: "ChannelID"}, + {Header: "NAME", Field: "ChannelName", MaxWidth: 40}, + {Header: "TEAM_ID", Field: "TeamID"}, + {Header: "CREATOR_ID", Field: "CreatorID"}, + {Header: "STATUS", Field: "Status"}, + }, + "TeamItem": { + {Header: "ID", Field: "TeamID"}, + {Header: "NAME", Field: "TeamName", MaxWidth: 40}, + }, + "MemberItem": { + {Header: "ID", Field: "MemberID"}, + {Header: "NAME", Field: "MemberName", MaxWidth: 30}, + {Header: "EMAIL", Field: "Email"}, + {Header: "STATUS", Field: "Status"}, + {Header: "TIMEZONE", Field: "TimeZone"}, + }, + "FieldItem": { + {Header: "ID", Field: "FieldID"}, + {Header: "NAME", Field: "FieldName"}, + {Header: "DISPLAY_NAME", Field: "DisplayName"}, + {Header: "TYPE", Field: "FieldType"}, + }, + "WarRoomItem": { + {Header: "INTEGRATION", Field: "IntegrationID"}, + {Header: "CHAT_ID", Field: "ChatID"}, + {Header: "INCIDENT_ID", Field: "IncidentID"}, + {Header: "STATUS", Field: "Status"}, + {Header: "PLUGIN", Field: "PluginType"}, + {Header: "CREATED", Field: "CreatedAt"}, + }, + "WarRoomPersonItem": { + {Header: "PERSON_ID", Field: "PersonID"}, + {Header: "NAME", Field: "PersonName"}, + {Header: "EMAIL", Field: "Email"}, + {Header: "STATUS", Field: "Status"}, + }, +} diff --git a/internal/cli/gen_support.go b/internal/cli/gen_support.go index 4f4d0e0..bf6e8c5 100644 --- a/internal/cli/gen_support.go +++ b/internal/cli/gen_support.go @@ -162,20 +162,16 @@ func bindURLTagged(body map[string]any, rv reflect.Value) { } } -// printGenericResult renders a generated command's typed response. Generated -// commands have no curated column set, so in machine-readable mode (TOON/JSON) -// it marshals the whole value — which is what the agent reads — and in human -// table mode it falls back to pretty JSON rather than a blank table. +// printGenericResult renders a generated command's typed response. In +// machine-readable mode (TOON/JSON) it marshals the whole value — which is what +// the agent reads. In human (table) mode it derives an aligned table by +// reflection (renderGenericTable), since generated commands carry no hand-written +// column set; anything that isn't a list or object falls back to indented JSON. func printGenericResult(ctx *RunContext, data any) error { if ctx.Structured() { return ctx.Printer.Print(data, nil) } - out, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal output: %w", err) - } - _, err = fmt.Fprintln(ctx.Writer, string(out)) - return err + return renderGenericTable(ctx, data) } // genGroup finds an existing subcommand named `name` under parent, or creates a diff --git a/internal/cli/generic_table.go b/internal/cli/generic_table.go new file mode 100644 index 0000000..1a2fea8 --- /dev/null +++ b/internal/cli/generic_table.go @@ -0,0 +1,301 @@ +package cli + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) + +// maxHeuristicColumns bounds the auto-derived column count for response row +// types that have no displayColumns entry, so a wide struct (incident rows carry +// ~50 fields) doesn't print an unreadably wide table. +const maxHeuristicColumns = 8 + +// genericStringMaxWidth caps free-text columns (titles, names) so one long value +// can't blow out the table width. +const genericStringMaxWidth = 40 + +// instantLike mirrors go-flashduty's Timestamp/TimestampMilli (and the output +// package's unexported instant) so the renderer can recognise timestamp fields +// by reflection. +type instantLike interface { + Time() time.Time + IsZero() bool +} + +var instantLikeType = reflect.TypeOf((*instantLike)(nil)).Elem() + +// genKV is one row of the vertical key/value table used for a single (non-list) +// object response. +type genKV struct { + Field string + Value string +} + +// renderGenericTable renders a generated command's typed response for human +// (table) output. Generated commands carry no hand-written column set, so we +// derive one by reflection: +// - a paginated list envelope ({Items:[...], Total, ...}) or a top-level row +// array prints as an aligned table (columns from displayColumns, else a +// reflective heuristic); +// - a single object prints as a vertical key/value table; +// - anything we can't model falls back to indented JSON, so output is never +// empty. +func renderGenericTable(ctx *RunContext, data any) error { + v := reflect.ValueOf(data) + for v.Kind() == reflect.Pointer { + if v.IsNil() { + return jsonFallback(ctx, data) + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.Slice: + return renderRowTable(ctx, v, v.Len()) + case reflect.Struct: + if rows, total, ok := listEnvelope(v); ok { + return renderRowTable(ctx, rows, total) + } + return renderVertical(ctx, v) + default: + return jsonFallback(ctx, data) + } +} + +// listEnvelope reports whether struct v is a paginated list envelope: exactly +// one exported field that is a slice of structs (the rows), with the remaining +// fields being pagination metadata. It returns the rows value and the total +// (the int field named "Total" when present, else the row count). +func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) { + t := v.Type() + total = -1 + found := false + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.PkgPath != "" { // unexported + continue + } + fv := v.Field(i) + if isRowSlice(fv.Type()) { + if found { + return reflect.Value{}, 0, false // ambiguous: more than one row slice + } + rows, found = fv, true + continue + } + if total < 0 && f.Name == "Total" && fv.CanInt() { + total = int(fv.Int()) + } + } + if !found { + return reflect.Value{}, 0, false + } + if total < 0 { + total = rows.Len() + } + return rows, total, true +} + +// isRowSlice reports whether t is a slice whose element (after pointer deref) is +// a struct — i.e. a table-able row collection. +func isRowSlice(t reflect.Type) bool { + if t.Kind() != reflect.Slice { + return false + } + elem := t.Elem() + for elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + return elem.Kind() == reflect.Struct +} + +func renderRowTable(ctx *RunContext, rows reflect.Value, total int) error { + if rows.Len() == 0 { + _, _ = fmt.Fprintln(ctx.Writer, "No results.") + return nil + } + rowType := rows.Type().Elem() + for rowType.Kind() == reflect.Pointer { + rowType = rowType.Elem() + } + cols := columnsForType(rowType) + if len(cols) == 0 { + return jsonFallback(ctx, rows.Interface()) + } + if err := ctx.Printer.Print(rows.Interface(), cols); err != nil { + return err + } + // Reached only from the table (human) path — printGenericResult handles + // structured output before calling the renderer — so the footer always prints. + _, _ = fmt.Fprintf(ctx.Writer, "Total: %d\n", total) + return nil +} + +// columnsForType returns the display columns for a row type: the curated set +// from displayColumns when present, else a reflective heuristic. +func columnsForType(rowType reflect.Type) []output.Column { + if specs, ok := displayColumns[rowType.Name()]; ok { + cols := make([]output.Column, 0, len(specs)) + for _, s := range specs { + cols = append(cols, output.Column{ + Header: s.Header, + MaxWidth: s.MaxWidth, + Field: func(item any) string { return fieldString(item, s.Field) }, + }) + } + return cols + } + return heuristicColumns(rowType) +} + +// heuristicColumns derives columns for a row type with no displayColumns entry: +// the first maxHeuristicColumns scalar (or timestamp) exported fields, in +// declaration order. Nested objects and arrays are skipped — they belong in +// json/toon output, not a human table. +func heuristicColumns(rowType reflect.Type) []output.Column { + cols := make([]output.Column, 0, maxHeuristicColumns) + for i := 0; i < rowType.NumField() && len(cols) < maxHeuristicColumns; i++ { + f := rowType.Field(i) + if f.PkgPath != "" || !isScalarType(f.Type) { + continue + } + maxW := 0 + if f.Type.Kind() == reflect.String { + maxW = genericStringMaxWidth + } + cols = append(cols, output.Column{ + Header: headerFromField(f), + MaxWidth: maxW, + Field: func(item any) string { return fieldString(item, f.Name) }, + }) + } + return cols +} + +// renderVertical prints a single object as a two-column FIELD/VALUE table, +// showing scalar fields with a non-empty value. Nested objects/arrays are +// omitted (json/toon carries the full shape for machines). +func renderVertical(ctx *RunContext, v reflect.Value) error { + t := v.Type() + rows := make([]genKV, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.PkgPath != "" || !isScalarType(f.Type) { + continue + } + s := scalarString(v.Field(i)) + if s == "" || s == "-" { + continue + } + rows = append(rows, genKV{Field: headerFromField(f), Value: s}) + } + if len(rows) == 0 { + return jsonFallback(ctx, v.Interface()) + } + cols := []output.Column{ + {Header: "FIELD", Field: func(item any) string { return item.(genKV).Field }}, + {Header: "VALUE", MaxWidth: 80, Field: func(item any) string { return item.(genKV).Value }}, + } + return ctx.Printer.Print(rows, cols) +} + +// fieldString reads the named Go field from a row item (deref'ing a pointer row) +// and formats it. An absent field yields "" rather than panicking, so a stale +// displayColumns entry degrades a column instead of crashing the command. +func fieldString(item any, goField string) string { + rv := reflect.ValueOf(item) + for rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return "" + } + rv = rv.Elem() + } + fv := rv.FieldByName(goField) + if !fv.IsValid() { + return "" + } + return scalarString(fv) +} + +// scalarString formats a scalar (or timestamp) reflect value. Non-scalars yield +// "" — the generic table never renders nested objects/arrays. +func scalarString(fv reflect.Value) string { + for fv.Kind() == reflect.Pointer { + if fv.IsNil() { + return "" + } + fv = fv.Elem() + } + if fv.CanInterface() { + if s, ok := output.FormatTimeValue(fv.Interface()); ok { + return s + } + } + switch fv.Kind() { + case reflect.String: + return fv.String() + case reflect.Bool: + return strconv.FormatBool(fv.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(fv.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(fv.Uint(), 10) + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(fv.Float(), 'f', -1, 64) + default: + return "" + } +} + +// isScalarType reports whether t is renderable as a single table cell: a string, +// number, bool, or a timestamp (instant) type. +func isScalarType(t reflect.Type) bool { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + // Timestamp/TimestampMilli satisfy instantLike with value receivers, so the + // deref'd (non-pointer) type implements it directly. + if t.Implements(instantLikeType) { + return true + } + switch t.Kind() { + case reflect.String, reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + default: + return false + } +} + +// 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) +} + +// jsonFallback prints indented JSON — the last resort when a response is neither +// a list nor a renderable object. Preserves the pre-renderer behavior. +func jsonFallback(ctx *RunContext, data any) error { + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + _, err = fmt.Fprintln(ctx.Writer, string(out)) + return err +} diff --git a/internal/cli/generic_table_test.go b/internal/cli/generic_table_test.go new file mode 100644 index 0000000..8379543 --- /dev/null +++ b/internal/cli/generic_table_test.go @@ -0,0 +1,189 @@ +package cli + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/flashcatcloud/go-flashduty" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) + +// tableCtx builds a RunContext that renders a human table into buf. +func tableCtx(buf *bytes.Buffer) *RunContext { + return &RunContext{ + Writer: buf, + Printer: output.NewPrinter(output.FormatTable, false, buf), + Format: output.FormatTable, + } +} + +func structuredCtx(buf *bytes.Buffer, f output.Format) *RunContext { + return &RunContext{ + Writer: buf, + Printer: output.NewPrinter(f, false, buf), + Format: f, + } +} + +// heuristicRow has no displayColumns entry, so it exercises the reflective +// heuristic. The nested field must never become a column. +type heuristicRow struct { + Name string `json:"name"` + Count int `json:"count"` + Nested struct { + X int `json:"x"` + } `json:"nested"` +} + +type fakeListResp struct { + Items []heuristicRow `json:"items"` + Total int `json:"total"` +} + +func TestRenderGenericTable_DisplayColumns(t *testing.T) { + var buf bytes.Buffer + resp := &flashduty.IncidentListResponse{ + Items: []flashduty.IncidentInfo{ + {IncidentID: "abc123", Title: "db down", IncidentSeverity: "Critical", Progress: "Triage", ChannelName: "prod-db"}, + }, + Total: 1, + } + if err := renderGenericTable(tableCtx(&buf), resp); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + for _, want := range []string{"ID", "TITLE", "SEVERITY", "PROGRESS", "CHANNEL", "CREATED", "abc123", "db down", "Critical", "Triage", "prod-db", "Total: 1"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q\n---\n%s", want, got) + } + } + // A zero timestamp renders as "-", proving the instant path is reached. + if !strings.Contains(got, "-") { + t.Errorf("expected zero StartTime to render as \"-\"\n%s", got) + } +} + +func TestRenderGenericTable_Heuristic(t *testing.T) { + var buf bytes.Buffer + resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1} + if err := renderGenericTable(tableCtx(&buf), resp); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + for _, want := range []string{"NAME", "COUNT", "alpha", "7", "Total: 1"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q\n---\n%s", want, got) + } + } + if strings.Contains(got, "NESTED") { + t.Errorf("nested struct field must not become a column\n%s", got) + } +} + +func TestRenderGenericTable_TopLevelArray(t *testing.T) { + var buf bytes.Buffer + rows := []heuristicRow{{Name: "x", Count: 1}, {Name: "y", Count: 2}} + if err := renderGenericTable(tableCtx(&buf), rows); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + for _, want := range []string{"NAME", "COUNT", "x", "y", "Total: 2"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q\n---\n%s", want, got) + } + } +} + +func TestRenderGenericTable_Empty(t *testing.T) { + var buf bytes.Buffer + if err := renderGenericTable(tableCtx(&buf), &fakeListResp{}); err != nil { + t.Fatalf("render: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "No results." { + t.Errorf("empty list: got %q, want %q", got, "No results.") + } +} + +func TestRenderGenericTable_DetailVertical(t *testing.T) { + var buf bytes.Buffer + row := heuristicRow{Name: "solo", Count: 42} + if err := renderGenericTable(tableCtx(&buf), &row); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + for _, want := range []string{"FIELD", "VALUE", "NAME", "solo", "COUNT", "42"} { + if !strings.Contains(got, want) { + t.Errorf("vertical output missing %q\n---\n%s", want, got) + } + } +} + +func TestPrintGenericResult_StructuredUnchanged(t *testing.T) { + resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1} + + // In any structured mode, printGenericResult must produce byte-identical + // output to the direct printer (the agent path is untouched by the renderer). + for _, f := range []output.Format{output.FormatJSON, output.FormatTOON} { + var got, want bytes.Buffer + if err := printGenericResult(structuredCtx(&got, f), resp); err != nil { + t.Fatalf("%v render: %v", f, err) + } + if err := output.NewPrinter(f, false, &want).Print(resp, nil); err != nil { + t.Fatalf("%v reference: %v", f, err) + } + if got.String() != want.String() { + t.Errorf("structured output changed for %v\n got:\n%s\nwant:\n%s", f, got.String(), want.String()) + } + } +} + +// displayColumnSamples is one zero value per row type that displayColumns keys. +// The validation test cross-checks this list against displayColumns so a new +// entry without a sample (or vice versa) fails loudly. +var displayColumnSamples = []any{ + flashduty.IncidentInfo{}, + flashduty.PastIncidentItem{}, + flashduty.AlertInfo{}, + flashduty.AlertItem{}, + flashduty.AlertEventItem{}, + flashduty.ChangeItem{}, + flashduty.ChannelItem{}, + flashduty.TeamItem{}, + flashduty.MemberItem{}, + flashduty.FieldItem{}, + flashduty.WarRoomItem{}, + flashduty.WarRoomPersonItem{}, +} + +// TestDisplayColumns_FieldsResolve guards against typos: every displayColumns +// field name must resolve on its row type (the names are reflection strings, so +// the compiler can't catch a typo), and every key must have a sample type. +func TestDisplayColumns_FieldsResolve(t *testing.T) { + typeByName := make(map[string]reflect.Type, len(displayColumnSamples)) + for _, s := range displayColumnSamples { + ty := reflect.TypeOf(s) + typeByName[ty.Name()] = ty + } + + for name, specs := range displayColumns { + ty, ok := typeByName[name] + if !ok { + t.Errorf("displayColumns[%q] has no sample in displayColumnSamples", name) + continue + } + for _, s := range specs { + if _, ok := ty.FieldByName(s.Field); !ok { + t.Errorf("%s: field %q (header %q) does not exist", name, s.Field, s.Header) + } + } + } + + for name := range typeByName { + if _, ok := displayColumns[name]; !ok { + t.Errorf("sample type %q is not used by displayColumns (remove it or add columns)", name) + } + } +} diff --git a/internal/output/table.go b/internal/output/table.go index 077366c..267369f 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -104,6 +104,18 @@ func FormatTime(ts instant) string { return ts.Time().Local().Format("2006-01-02 15:04") } +// FormatTimeValue formats v with FormatTime when v is an instant (go-flashduty +// Timestamp/TimestampMilli), returning ok=false for any other value. It lets the +// generic table renderer format timestamp fields it reaches by reflection +// without importing the unexported instant type or duplicating the layout. +func FormatTimeValue(v any) (string, bool) { + ts, ok := v.(instant) + if !ok { + return "", false + } + return FormatTime(ts), true +} + // FormatDuration formats seconds into human-readable duration (e.g., "2m 30s", "1h 15m"). func FormatDuration(seconds int) string { if seconds <= 0 {