diff --git a/cmd/dispatch/info.go b/cmd/dispatch/info.go index 75a3163..13b09bb 100644 --- a/cmd/dispatch/info.go +++ b/cmd/dispatch/info.go @@ -47,6 +47,14 @@ type infoRefs struct { Issues []string `json:"issues"` } +type infoOutputFormat string + +const ( + infoFormatText infoOutputFormat = "text" + infoFormatJSON infoOutputFormat = "json" + infoFormatMarkdown infoOutputFormat = "markdown" +) + // runInfo prints a concise summary of a single session as text, or as JSON // with --json. The --refs flag adds linked reference values. func runInfo(w io.Writer, args []string) error { @@ -54,7 +62,7 @@ func runInfo(w io.Writer, args []string) error { w = io.Discard } - id, asJSON, includeRefs, err := parseInfoArgs(args) + id, format, includeRefs, err := parseInfoArgs(args) if err != nil { return err } @@ -76,29 +84,42 @@ func runInfo(w io.Writer, args []string) error { if includeRefs { addInfoRefs(&info, detail.Refs) } - if asJSON { + switch format { + case infoFormatJSON: return writeInfoJSON(w, info) + case infoFormatMarkdown: + return writeInfoMarkdown(w, info) + default: + return writeInfoText(w, info) } - return writeInfoText(w, info) } // parseInfoArgs extracts the session ID and flags from the info // subcommand arguments. args[0] is expected to be "info". -func parseInfoArgs(args []string) (id string, asJSON, includeRefs bool, err error) { +func parseInfoArgs(args []string) (id string, format infoOutputFormat, includeRefs bool, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "info" token } + format = infoFormatText var positionals []string for _, arg := range rest { switch { case arg == "--json": - asJSON = true + if format == infoFormatMarkdown { + return "", "", false, errors.New("--json and --markdown cannot be combined") + } + format = infoFormatJSON + case arg == "--markdown": + if format == infoFormatJSON { + return "", "", false, errors.New("--json and --markdown cannot be combined") + } + format = infoFormatMarkdown case arg == "--refs": includeRefs = true case strings.HasPrefix(arg, "-"): - return "", false, false, fmt.Errorf("unknown flag: %s", arg) + return "", "", false, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } @@ -106,11 +127,11 @@ func parseInfoArgs(args []string) (id string, asJSON, includeRefs bool, err erro switch len(positionals) { case 0: - return "", false, false, errors.New("info requires a session ID") + return "", "", false, errors.New("info requires a session ID") case 1: - return positionals[0], asJSON, includeRefs, nil + return positionals[0], format, includeRefs, nil default: - return "", false, false, fmt.Errorf("info accepts a single session ID, got %d arguments", len(positionals)) + return "", "", false, fmt.Errorf("info accepts a single session ID, got %d arguments", len(positionals)) } } @@ -223,6 +244,45 @@ func writeInfoText(w io.Writer, info sessionInfo) error { return err } +func writeInfoMarkdown(w io.Writer, info sessionInfo) error { + var b strings.Builder + fmt.Fprintf(&b, "## Session `%s`\n\n", markdownCell(info.ID)) + + writeRow := func(label, value string) { + if value != "" { + fmt.Fprintf(&b, "| %s | %s |\n", label, markdownCell(value)) + } + } + + fmt.Fprintln(&b, "| Field | Value |") + fmt.Fprintln(&b, "|---|---|") + writeRow("Summary", info.Summary) + writeRow("Repository", info.Repository) + writeRow("Branch", info.Branch) + writeRow("Directory", info.Directory) + writeRow("Host", info.HostType) + writeRow("Alias", info.Alias) + if len(info.Tags) > 0 { + writeRow("Tags", strings.Join(info.Tags, ", ")) + } + writeRow("Note", oneLine(info.Note)) + writeRow("Created", info.CreatedAt) + writeRow("Updated", info.UpdatedAt) + writeRow("Last active", info.LastActiveAt) + fmt.Fprintf(&b, "| Turns | %d |\n", info.Turns) + fmt.Fprintf(&b, "| Files | %d |\n", info.Files) + fmt.Fprintf(&b, "| Checkpoints | %d |\n", info.Checkpoints) + writeRow("Refs", formatRefCounts(info)) + if info.Refs != nil { + writeRow("Commits", formatRefList(info.Refs.Commits)) + writeRow("PRs", formatRefList(info.Refs.PRs)) + writeRow("Issues", formatRefList(info.Refs.Issues)) + } + + _, err := io.WriteString(w, b.String()) + return err +} + // formatRefCounts renders the reference breakdown as a compact summary such as // "3 commits, 1 pr, 0 issues", using singular labels when a count is one. func formatRefCounts(info sessionInfo) string { diff --git a/cmd/dispatch/info_test.go b/cmd/dispatch/info_test.go index 71dc5cd..004d011 100644 --- a/cmd/dispatch/info_test.go +++ b/cmd/dispatch/info_test.go @@ -57,23 +57,25 @@ func TestParseInfoArgs(t *testing.T) { name string args []string wantID string - wantJSON bool + wantFmt infoOutputFormat wantRefs bool wantErr bool }{ - {name: "id only", args: []string{"info", "abc"}, wantID: "abc"}, - {name: "id with json", args: []string{"info", "abc", "--json"}, wantID: "abc", wantJSON: true}, - {name: "json before id", args: []string{"info", "--json", "abc"}, wantID: "abc", wantJSON: true}, - {name: "id with refs", args: []string{"info", "abc", "--refs"}, wantID: "abc", wantRefs: true}, - {name: "json with refs", args: []string{"info", "--json", "--refs", "abc"}, wantID: "abc", wantJSON: true, wantRefs: true}, + {name: "id only", args: []string{"info", "abc"}, wantID: "abc", wantFmt: infoFormatText}, + {name: "id with json", args: []string{"info", "abc", "--json"}, wantID: "abc", wantFmt: infoFormatJSON}, + {name: "json before id", args: []string{"info", "--json", "abc"}, wantID: "abc", wantFmt: infoFormatJSON}, + {name: "id with markdown", args: []string{"info", "abc", "--markdown"}, wantID: "abc", wantFmt: infoFormatMarkdown}, + {name: "id with refs", args: []string{"info", "abc", "--refs"}, wantID: "abc", wantFmt: infoFormatText, wantRefs: true}, + {name: "json with refs", args: []string{"info", "--json", "--refs", "abc"}, wantID: "abc", wantFmt: infoFormatJSON, wantRefs: true}, {name: "missing id", args: []string{"info"}, wantErr: true}, {name: "two ids", args: []string{"info", "a", "b"}, wantErr: true}, {name: "unknown flag", args: []string{"info", "abc", "--nope"}, wantErr: true}, + {name: "json markdown conflict", args: []string{"info", "abc", "--json", "--markdown"}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - id, asJSON, includeRefs, err := parseInfoArgs(tt.args) + id, format, includeRefs, err := parseInfoArgs(tt.args) if tt.wantErr { if err == nil { t.Fatal("expected an error") @@ -86,8 +88,8 @@ func TestParseInfoArgs(t *testing.T) { if id != tt.wantID { t.Errorf("id = %q, want %q", id, tt.wantID) } - if asJSON != tt.wantJSON { - t.Errorf("asJSON = %v, want %v", asJSON, tt.wantJSON) + if format != tt.wantFmt { + t.Errorf("format = %q, want %q", format, tt.wantFmt) } if includeRefs != tt.wantRefs { t.Errorf("includeRefs = %v, want %v", includeRefs, tt.wantRefs) @@ -270,6 +272,29 @@ func TestRunInfo_JSONWithRefs(t *testing.T) { } } +func TestRunInfo_MarkdownWithRefs(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return infoSampleDetail(), nil + }) + + var buf bytes.Buffer + if err := runInfo(&buf, []string{"info", "ses-info-1", "--markdown", "--refs"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + for _, want := range []string{ + "## Session `ses-info-1`", + "| Summary | Fix the widget |", + "| Repository | jongio/dispatch |", + "| Turns | 5 |", + "| PRs | 42, 43 |", + } { + if !strings.Contains(out, want) { + t.Errorf("markdown output missing %q, got:\n%s", want, out) + } + } +} + func TestRunInfo_NotFound(t *testing.T) { withInfoDetail(t, func(string) (*data.SessionDetail, error) { return nil, nil // loader returns (nil, nil) when the ID is unknown