diff --git a/README.md b/README.md index 4700a4e..3b21913 100644 --- a/README.md +++ b/README.md @@ -348,9 +348,10 @@ Compare two sessions side by side with `dispatch compare`: ```sh dispatch compare 0a1b2c3d 9f8e7d6c dispatch compare 0a1b2c3d 9f8e7d6c --json +dispatch compare 0a1b2c3d 9f8e7d6c --markdown ``` -The output shows metadata differences (summary, branch, turn count), files that appear in only one session, ref differences, and checkpoint title lists. Use `--json` for machine-readable output. Session IDs accept the same prefix shorthand as `open` and `export`. +The output shows metadata differences (summary, branch, turn count), files that appear in only one session, ref differences, and checkpoint title lists. Use `--json` for machine-readable output or `--markdown` to paste the comparison into an issue or PR. Session IDs accept the same prefix shorthand as `open` and `export`. ### Info diff --git a/cmd/dispatch/compare.go b/cmd/dispatch/compare.go index 085ca07..abb0193 100644 --- a/cmd/dispatch/compare.go +++ b/cmd/dispatch/compare.go @@ -51,14 +51,24 @@ type metadataDiff struct { Right string `json:"right"` } -// runCompare prints a comparison of two sessions as text, or as JSON with -// --json. args is the full argument slice with args[0] == "compare". +// compareOutputFormat selects how runCompare renders the comparison. +type compareOutputFormat string + +const ( + compareFormatText compareOutputFormat = "text" + compareFormatJSON compareOutputFormat = "json" + compareFormatMarkdown compareOutputFormat = "markdown" +) + +// runCompare prints a comparison of two sessions as text, as JSON with --json, +// or as Markdown with --markdown. args is the full argument slice with +// args[0] == "compare". func runCompare(w io.Writer, args []string) error { if w == nil { w = io.Discard } - leftID, rightID, asJSON, err := parseCompareArgs(args) + leftID, rightID, format, err := parseCompareArgs(args) if err != nil { return err } @@ -80,41 +90,59 @@ func runCompare(w io.Writer, args []string) error { } cmp := buildComparison(leftDetail, rightDetail) - if asJSON { + switch format { + case compareFormatJSON: return writeCompareJSON(w, cmp) + case compareFormatMarkdown: + return writeCompareMarkdown(w, cmp) + default: + return writeCompareText(w, cmp) } - return writeCompareText(w, cmp) } -// parseCompareArgs extracts the two session IDs and the --json flag from the -// compare subcommand arguments. args[0] is expected to be "compare". -func parseCompareArgs(args []string) (leftID, rightID string, asJSON bool, err error) { +// parseCompareArgs extracts the two session IDs and the output format from the +// compare subcommand arguments. args[0] is expected to be "compare". --json and +// --markdown select a format and cannot be combined. +func parseCompareArgs(args []string) (leftID, rightID string, format compareOutputFormat, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "compare" token } + jsonOut := false + markdownOut := false var positionals []string for _, arg := range rest { switch { case arg == "--json": - asJSON = true + jsonOut = true + case arg == "--markdown": + markdownOut = true case strings.HasPrefix(arg, "-"): - return "", "", false, fmt.Errorf("unknown flag: %s", arg) + return "", "", "", fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } } + if jsonOut && markdownOut { + return "", "", "", errors.New("--json and --markdown cannot be combined") + } + format = compareFormatText + switch { + case jsonOut: + format = compareFormatJSON + case markdownOut: + format = compareFormatMarkdown + } + switch len(positionals) { - case 0: - return "", "", false, errors.New("compare requires two session IDs") - case 1: - return "", "", false, errors.New("compare requires two session IDs") + case 0, 1: + return "", "", "", errors.New("compare requires two session IDs") case 2: - return positionals[0], positionals[1], asJSON, nil + return positionals[0], positionals[1], format, nil default: - return "", "", false, fmt.Errorf("compare accepts exactly two session IDs, got %d", len(positionals)) + return "", "", "", fmt.Errorf("compare accepts exactly two session IDs, got %d", len(positionals)) } } @@ -280,3 +308,49 @@ func writeCompareList(b *strings.Builder, label string, items []string) { } } } + +// writeCompareMarkdown renders the comparison as Markdown so it can be pasted +// into issues, PRs, or reports. +func writeCompareMarkdown(w io.Writer, cmp sessionComparison) error { + var b strings.Builder + + b.WriteString("# Session comparison\n\n") + b.WriteString("| Side | Session |\n") + b.WriteString("|---|---|\n") + fmt.Fprintf(&b, "| Left | %s |\n", markdownCell(cmp.Left.ID)) + fmt.Fprintf(&b, "| Right | %s |\n", markdownCell(cmp.Right.ID)) + + b.WriteString("\n## Metadata\n\n") + if len(cmp.MetadataDiffs) == 0 { + b.WriteString("Metadata is identical.\n") + } else { + b.WriteString("| Field | Left | Right |\n") + b.WriteString("|---|---|---|\n") + for _, d := range cmp.MetadataDiffs { + fmt.Fprintf(&b, "| %s | %s | %s |\n", markdownCell(d.Field), markdownCell(d.Left), markdownCell(d.Right)) + } + } + + writeCompareMarkdownList(&b, "Checkpoint titles (left)", cmp.Left.CheckpointTitles) + writeCompareMarkdownList(&b, "Checkpoint titles (right)", cmp.Right.CheckpointTitles) + writeCompareMarkdownList(&b, "Files only in left", cmp.FilesOnlyLeft) + writeCompareMarkdownList(&b, "Files only in right", cmp.FilesOnlyRight) + writeCompareMarkdownList(&b, "Refs only in left", cmp.RefsOnlyLeft) + writeCompareMarkdownList(&b, "Refs only in right", cmp.RefsOnlyRight) + + _, err := io.WriteString(w, b.String()) + return err +} + +// writeCompareMarkdownList appends a Markdown section with a bulleted list, or +// an italic "(none)" when the slice is empty. +func writeCompareMarkdownList(b *strings.Builder, heading string, items []string) { + fmt.Fprintf(b, "\n## %s\n\n", heading) + if len(items) == 0 { + b.WriteString("_(none)_\n") + return + } + for _, item := range items { + fmt.Fprintf(b, "- %s\n", markdownCell(item)) + } +} diff --git a/cmd/dispatch/compare_test.go b/cmd/dispatch/compare_test.go index 7b6cc05..f3efc43 100644 --- a/cmd/dispatch/compare_test.go +++ b/cmd/dispatch/compare_test.go @@ -105,13 +105,16 @@ func TestParseCompareArgs(t *testing.T) { args []string wantLeft string wantRight string - wantJSON bool + wantFmt compareOutputFormat wantErr bool }{ - {name: "two ids", args: []string{"compare", "a", "b"}, wantLeft: "a", wantRight: "b"}, - {name: "two ids with json", args: []string{"compare", "a", "b", "--json"}, wantLeft: "a", wantRight: "b", wantJSON: true}, - {name: "json between ids", args: []string{"compare", "a", "--json", "b"}, wantLeft: "a", wantRight: "b", wantJSON: true}, - {name: "json before ids", args: []string{"compare", "--json", "a", "b"}, wantLeft: "a", wantRight: "b", wantJSON: true}, + {name: "two ids", args: []string{"compare", "a", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatText}, + {name: "two ids with json", args: []string{"compare", "a", "b", "--json"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatJSON}, + {name: "json between ids", args: []string{"compare", "a", "--json", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatJSON}, + {name: "json before ids", args: []string{"compare", "--json", "a", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatJSON}, + {name: "two ids with markdown", args: []string{"compare", "a", "b", "--markdown"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatMarkdown}, + {name: "markdown before ids", args: []string{"compare", "--markdown", "a", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatMarkdown}, + {name: "json and markdown conflict", args: []string{"compare", "a", "b", "--json", "--markdown"}, wantErr: true}, {name: "no args", args: []string{"compare"}, wantErr: true}, {name: "one arg", args: []string{"compare", "a"}, wantErr: true}, {name: "three args", args: []string{"compare", "a", "b", "c"}, wantErr: true}, @@ -120,7 +123,7 @@ func TestParseCompareArgs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - leftID, rightID, asJSON, err := parseCompareArgs(tt.args) + leftID, rightID, format, err := parseCompareArgs(tt.args) if tt.wantErr { if err == nil { t.Fatal("expected an error") @@ -136,8 +139,8 @@ func TestParseCompareArgs(t *testing.T) { if rightID != tt.wantRight { t.Errorf("rightID = %q, want %q", rightID, tt.wantRight) } - 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) } }) } @@ -228,6 +231,63 @@ func TestRunCompare_JSON(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Markdown output +// --------------------------------------------------------------------------- + +func TestRunCompare_Markdown(t *testing.T) { + withCompareDetail(t, compareLoader()) + + var buf bytes.Buffer + if err := runCompare(&buf, []string{"compare", "ses-left", "ses-right", "--markdown"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "# Session comparison", + "| Side | Session |", + "ses-left", + "ses-right", + "## Metadata", + "| Field | Left | Right |", + "| summary |", + "Add auth", + "Fix login", + "## Files only in left", + "- src/auth.go", + "## Files only in right", + "- src/login.go", + "## Refs only in left", + "- commit:abc123", + "## Checkpoint titles (left)", + "- Setup auth", + } { + if !strings.Contains(out, want) { + t.Errorf("markdown output missing %q, got:\n%s", want, out) + } + } +} + +func TestRunCompare_MarkdownIdentical(t *testing.T) { + withCompareDetail(t, func(string) (*data.SessionDetail, error) { + return compareSampleLeft(), nil + }) + + var buf bytes.Buffer + if err := runCompare(&buf, []string{"compare", "ses-left", "ses-left", "--markdown"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + if !strings.Contains(out, "Metadata is identical.") { + t.Errorf("expected identical metadata note, got:\n%s", out) + } + if !strings.Contains(out, "_(none)_") { + t.Errorf("expected empty-list marker for identical sessions, got:\n%s", out) + } +} + // --------------------------------------------------------------------------- // Missing session IDs // --------------------------------------------------------------------------- diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 72865d6..7910286 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -157,7 +157,7 @@ Commands: Print a concise session summary path Print a session's working directory; IDs may be aliases or prefixes - compare [--json] + compare [--json|--markdown] Compare two sessions side by side tag [flags] Add, remove, set, or list tags on a session prune [--apply] [--json] diff --git a/cmd/dispatch/man.go b/cmd/dispatch/man.go index 608698c..59f1ca2 100644 --- a/cmd/dispatch/man.go +++ b/cmd/dispatch/man.go @@ -115,7 +115,7 @@ var manCommands = []manEntry{ {"export [flags]", "Export a session as Markdown, JSON, HTML, or plain text."}, {"info [--json] [--refs]", "Print a concise session summary."}, {"path ", "Print a session's working directory. IDs may be aliases or unique prefixes."}, - {"compare [--json]", "Compare two sessions side by side."}, + {"compare [--json|--markdown]", "Compare two sessions side by side."}, {"tag [flags]", "Add, remove, set, or list tags on a session."}, {"prune [--apply] [--json]", "Report (or remove) config entries for missing sessions."}, {"watch [--once] [flags]", "Monitor session attention state."},