From 263da8eac544a1a0214f4d09a726e127a92aff86 Mon Sep 17 00:00:00 2001 From: Daniel Bos Date: Tue, 4 Aug 2026 12:55:53 +0800 Subject: [PATCH] feat: add '--format' flag with JSON output --- README.md | 3 ++ cmd/find.go | 12 ++++- cmd/list.go | 17 ++++++- cmd/show.go | 19 ++++++-- internal/app/find.go | 44 ++++++++++------- internal/app/find_format_test.go | 77 +++++++++++++++++++++++++++++ internal/app/find_test.go | 8 +-- internal/app/format.go | 30 ++++++++++++ internal/app/format_test.go | 38 +++++++++++++++ internal/app/list.go | 45 ++++++++++------- internal/app/list_test.go | 67 +++++++++++++++++++++++++ internal/app/show.go | 55 ++++++++++++++++++++- internal/app/show_test.go | 84 ++++++++++++++++++++++++++++++++ internal/app/table.go | 47 ++++++++++++++++++ internal/app/testconst_test.go | 8 +++ 15 files changed, 508 insertions(+), 46 deletions(-) create mode 100644 internal/app/find_format_test.go create mode 100644 internal/app/format.go create mode 100644 internal/app/format_test.go create mode 100644 internal/app/list_test.go create mode 100644 internal/app/show_test.go create mode 100644 internal/app/table.go create mode 100644 internal/app/testconst_test.go diff --git a/README.md b/README.md index 580c956..36c02bb 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,16 @@ A simple command-line tool to manage ADRs in markdown format. Create a new ADR with the given title and open it in your `$EDITOR`. - `adr show ` Show the ADR with the given id. + Use `--format` / `-f` to control output: `md` (default, rendered markdown), `raw` (unrendered markdown), `json` (structured JSON with frontmatter fields and body as separate keys). - `adr edit ` Open the ADR with the given id in your `$EDITOR`. - `adr list` List all ADRs with their status, date and title. + Use `--format` / `-f` to control output: `md` (default, rendered markdown), `raw` (unrendered markdown), `json` (structured JSON). - `adr find ` Find ADRs whose title matches the query. Words are matched in order, case-insensitively, with anything allowed between them. Use `--text` / `-t` to also search frontmatter fields and the body. + Use `--format` / `-f` to control output: `md` (default), `raw`, `json`. - `adr update ` Update the ADR with the given id, setting the status to one of: `proposed`, `accepted`, `deprecated` or `superseded`. diff --git a/cmd/find.go b/cmd/find.go index a01aaf1..825c559 100644 --- a/cmd/find.go +++ b/cmd/find.go @@ -11,6 +11,8 @@ import ( func NewFindCommand(conf *config.Config) *cobra.Command { var fullText bool + var format string + //nolint:exhaustruct cmd := &cobra.Command{ Use: "find ", @@ -23,13 +25,21 @@ so "my search term" matches any title containing "my", then "search", then "term Use --text to also search frontmatter fields and the body.`, Args: cobra.ExactArgs(1), Run: func(_ *cobra.Command, args []string) { - if err := app.Find(conf, args[0], fullText); err != nil { + outputFormat, err := app.ParseFormat(format) + if err != nil { + log.Printf("invalid format %q: %v", format, err) + + return + } + + if err := app.Find(conf, args[0], fullText, outputFormat); err != nil { log.Printf("couldn't find adrs: %v", err) } }, } cmd.Flags().BoolVarP(&fullText, "text", "t", false, "also search frontmatter fields and body") + cmd.Flags().StringVarP(&format, "format", "f", string(app.FormatMd), "output format: md, raw, json") return cmd } diff --git a/cmd/list.go b/cmd/list.go index 534d3ca..d65c44e 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -9,14 +9,27 @@ import ( ) func NewListCommand(conf *config.Config) *cobra.Command { + var format string + //nolint:exhaustruct - return &cobra.Command{ + cmd := &cobra.Command{ Use: "list", Short: "List all ADRs with their id, date and status", Run: func(_ *cobra.Command, _ []string) { - if err := app.List(conf); err != nil { + outputFormat, err := app.ParseFormat(format) + if err != nil { + log.Printf("invalid format %q: %v", format, err) + + return + } + + if err := app.List(conf, outputFormat); err != nil { log.Printf("couldn't list adrs: %v", err) } }, } + + cmd.Flags().StringVarP(&format, "format", "f", string(app.FormatMd), "output format: md, raw, json") + + return cmd } diff --git a/cmd/show.go b/cmd/show.go index 78f8d4f..fb18800 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -10,8 +10,10 @@ import ( ) func NewShowCommand(conf *config.Config) *cobra.Command { + var format string + //nolint:exhaustruct - return &cobra.Command{ + cmd := &cobra.Command{ Use: "show ", Aliases: []string{"view"}, Short: "Show the ADR with number ", @@ -19,7 +21,7 @@ func NewShowCommand(conf *config.Config) *cobra.Command { Prints a summary table (number, date, status, filename) followed by the rendered markdown body of the ADR.`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), Run: func(_ *cobra.Command, args []string) { number, err := strconv.Atoi(args[0]) if err != nil { @@ -28,9 +30,20 @@ rendered markdown body of the ADR.`, return } - if err := app.Show(conf, number); err != nil { + outputFormat, err := app.ParseFormat(format) + if err != nil { + log.Printf("invalid format %q: %v", format, err) + + return + } + + if err := app.Show(conf, number, outputFormat); err != nil { log.Printf("couldn't show adr %d: %v", number, err) } }, } + + cmd.Flags().StringVarP(&format, "format", "f", string(app.FormatMd), "output format: md, raw, json") + + return cmd } diff --git a/internal/app/find.go b/internal/app/find.go index 5666b34..3d796d2 100644 --- a/internal/app/find.go +++ b/internal/app/find.go @@ -1,25 +1,32 @@ package app import ( + "encoding/json" "fmt" + "io" + "os" "regexp" "slices" "strings" - "charm.land/glamour/v2" - "charm.land/lipgloss/v2" "github.com/corani/adr/config" "github.com/corani/adr/internal/adr" ) -func Find(conf *config.Config, query string, fullText bool) error { +func Find(conf *config.Config, query string, fullText bool, format Format) error { re := buildQuery(query) - var rows []string + var entries []adrListEntry err := adr.ForEach(conf, func(entry *adr.Adr) error { if matches(re, entry, fullText) { - rows = append(rows, fmt.Sprintf("| %04d | %s | %s | %s |", entry.Number, entry.Date, entry.Status, entry.Title)) + entries = append(entries, adrListEntry{ + Number: int(entry.Number), + Title: entry.Title, + Status: string(entry.Status), + Date: entry.Date, + Filepath: entry.Filename, + }) } return nil @@ -28,30 +35,33 @@ func Find(conf *config.Config, query string, fullText bool) error { return fmt.Errorf("%w: find: %w", ErrInternal, err) } - if len(rows) == 0 { + if len(entries) == 0 { fmt.Println("no results") return nil } - slices.Sort(rows) - - table := "| # | date | status | title |\n|---|------|--------|-------|\n" + strings.Join(rows, "\n") + "\n" + slices.SortFunc(entries, func(a, b adrListEntry) int { + return a.Number - b.Number + }) - renderer, err := glamour.NewTermRenderer( - glamour.WithEnvironmentConfig(), - glamour.WithWordWrap(0), - ) - if err != nil { - return fmt.Errorf("%w: find: %w", ErrInternal, err) + switch format { + case FormatJSON: + return findJSON(os.Stdout, entries) + case FormatRaw, FormatMd: + return renderMarkdownTable(os.Stdout, entries, format, "find") + default: + return fmt.Errorf("%w: find: unknown format %q", ErrInternal, format) } +} - out, err := renderer.Render(table) +func findJSON(writer io.Writer, entries []adrListEntry) error { + out, err := json.MarshalIndent(entries, "", " ") if err != nil { return fmt.Errorf("%w: find: %w", ErrInternal, err) } - if _, err = lipgloss.Print(out); err != nil { + if _, err = fmt.Fprintln(writer, string(out)); err != nil { return fmt.Errorf("%w: find: %w", ErrInternal, err) } diff --git a/internal/app/find_format_test.go b/internal/app/find_format_test.go new file mode 100644 index 0000000..55b9f90 --- /dev/null +++ b/internal/app/find_format_test.go @@ -0,0 +1,77 @@ +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/corani/adr/internal/adr" +) + +func TestFindJSON(t *testing.T) { + t.Parallel() + + entries := []adrListEntry{ + {Number: 3, Title: "Use Kafka", Status: "deprecated", Date: "2024-03-01", Filepath: "0003-use-kafka.md"}, + } + + var buf bytes.Buffer + + if err := findJSON(&buf, entries); err != nil { + t.Fatalf("findJSON: %v", err) + } + + var got []adrListEntry + + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\noutput: %s", err, buf.String()) + } + + if len(got) != 1 || got[0] != entries[0] { + t.Errorf("got %+v, want %+v", got, entries) + } +} + +func TestFindMarkdownRaw(t *testing.T) { + t.Parallel() + + entries := []adrListEntry{ + {Number: 3, Title: "Use Kafka", Status: "deprecated", Date: "2024-03-01", Filepath: "0003-use-kafka.md"}, + } + + var buf bytes.Buffer + + if err := renderMarkdownTable(&buf, entries, FormatRaw, "find"); err != nil { + t.Fatalf("renderMarkdownTable: %v", err) + } + + out := buf.String() + + const wantRow = "| 0003 | 2024-03-01 | deprecated | Use Kafka |" + + if !strings.Contains(out, wantRow) { + t.Errorf("output missing %q\ngot: %s", wantRow, out) + } +} + +func TestMatchesFormat(t *testing.T) { + t.Parallel() + + entry := &adr.Adr{ + Filename: testFilename, + Type: "", + Number: 1, + Title: testTitle, + Status: adr.StatusAccepted, + Date: testDate, + Link: 0, + Body: []byte("We chose PostgreSQL because it supports JSONB."), + } + + re := buildQuery("postgres") + + if !matches(re, entry, false) { + t.Error("expected title match") + } +} diff --git a/internal/app/find_test.go b/internal/app/find_test.go index 5c7d408..fdd2b89 100644 --- a/internal/app/find_test.go +++ b/internal/app/find_test.go @@ -44,12 +44,12 @@ func TestMatches(t *testing.T) { t.Parallel() entry := &adr.Adr{ - Filename: "0001-use-postgresql.md", + Filename: testFilename, Type: "", Number: 1, - Title: "Use PostgreSQL for storage", + Title: testTitle, Status: adr.StatusAccepted, - Date: "2024-01-15", + Date: testDate, Link: 0, Body: []byte("We chose PostgreSQL because it supports JSONB."), } @@ -65,7 +65,7 @@ func TestMatches(t *testing.T) { {"title no match", "mysql", false, false}, {"body not searched without flag", "jsonb", false, false}, {"body searched with flag", "jsonb", true, true}, - {"status searched with flag", "accepted", true, true}, + {"status searched with flag", testStatus, true, true}, {"date searched with flag", "2024-01", true, true}, } diff --git a/internal/app/format.go b/internal/app/format.go new file mode 100644 index 0000000..470e782 --- /dev/null +++ b/internal/app/format.go @@ -0,0 +1,30 @@ +package app + +import "errors" + +type Format string + +type adrListEntry struct { + Number int `json:"number"` + Title string `json:"title"` + Status string `json:"status"` + Date string `json:"date"` + Filepath string `json:"filepath"` +} + +const ( + FormatMd Format = "md" + FormatRaw Format = "raw" + FormatJSON Format = "json" +) + +var ErrInvalidFormat = errors.New("invalid format") + +func ParseFormat(s string) (Format, error) { + switch Format(s) { + case FormatMd, FormatRaw, FormatJSON: + return Format(s), nil + default: + return FormatMd, ErrInvalidFormat + } +} diff --git a/internal/app/format_test.go b/internal/app/format_test.go new file mode 100644 index 0000000..7bc5e15 --- /dev/null +++ b/internal/app/format_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "testing" +) + +func TestParseFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want Format + wantErr bool + }{ + {"md", FormatMd, false}, + {"raw", FormatRaw, false}, + {"json", FormatJSON, false}, + {"", FormatMd, true}, + {"html", FormatMd, true}, + {"MD", FormatMd, true}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + t.Parallel() + + got, err := ParseFormat(test.input) + + if (err != nil) != test.wantErr { + t.Errorf("ParseFormat(%q) error = %v, wantErr %v", test.input, err, test.wantErr) + } + + if !test.wantErr && got != test.want { + t.Errorf("ParseFormat(%q) = %v, want %v", test.input, got, test.want) + } + }) + } +} diff --git a/internal/app/list.go b/internal/app/list.go index 762177f..6c6dfc8 100644 --- a/internal/app/list.go +++ b/internal/app/list.go @@ -1,21 +1,27 @@ package app import ( + "encoding/json" "fmt" + "io" + "os" "slices" - "strings" - "charm.land/glamour/v2" - "charm.land/lipgloss/v2" "github.com/corani/adr/config" "github.com/corani/adr/internal/adr" ) -func List(conf *config.Config) error { - var rows []string +func List(conf *config.Config, format Format) error { + var entries []adrListEntry - err := adr.ForEach(conf, func(v *adr.Adr) error { - rows = append(rows, fmt.Sprintf("| %04d | %s | %s | %s |", v.Number, v.Date, v.Status, v.Title)) + err := adr.ForEach(conf, func(entry *adr.Adr) error { + entries = append(entries, adrListEntry{ + Number: int(entry.Number), + Title: entry.Title, + Status: string(entry.Status), + Date: entry.Date, + Filepath: entry.Filename, + }) return nil }) @@ -23,24 +29,27 @@ func List(conf *config.Config) error { return fmt.Errorf("%w: list: %w", ErrInternal, err) } - slices.Sort(rows) - - table := "| # | date | status | title |\n|---|------|--------|-------|\n" + strings.Join(rows, "\n") + "\n" + slices.SortFunc(entries, func(a, b adrListEntry) int { + return a.Number - b.Number + }) - renderer, err := glamour.NewTermRenderer( - glamour.WithEnvironmentConfig(), - glamour.WithWordWrap(0), - ) - if err != nil { - return fmt.Errorf("%w: list: %w", ErrInternal, err) + switch format { + case FormatJSON: + return listJSON(os.Stdout, entries) + case FormatRaw, FormatMd: + return renderMarkdownTable(os.Stdout, entries, format, "list") + default: + return fmt.Errorf("%w: list: unknown format %q", ErrInternal, format) } +} - out, err := renderer.Render(table) +func listJSON(writer io.Writer, entries []adrListEntry) error { + out, err := json.MarshalIndent(entries, "", " ") if err != nil { return fmt.Errorf("%w: list: %w", ErrInternal, err) } - if _, err = lipgloss.Print(out); err != nil { + if _, err = fmt.Fprintln(writer, string(out)); err != nil { return fmt.Errorf("%w: list: %w", ErrInternal, err) } diff --git a/internal/app/list_test.go b/internal/app/list_test.go new file mode 100644 index 0000000..de5c5c2 --- /dev/null +++ b/internal/app/list_test.go @@ -0,0 +1,67 @@ +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestListJSON(t *testing.T) { + t.Parallel() + + entries := []adrListEntry{ + {Number: 1, Title: "Use PostgreSQL", Status: testStatus, Date: testDate, Filepath: testFilename}, + {Number: 2, Title: "Use Redis", Status: "proposed", Date: "2024-02-01", Filepath: "0002-use-redis.md"}, + } + + var buf bytes.Buffer + + if err := listJSON(&buf, entries); err != nil { + t.Fatalf("listJSON: %v", err) + } + + var got []adrListEntry + + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\noutput: %s", err, buf.String()) + } + + if len(got) != len(entries) { + t.Fatalf("len = %d, want %d", len(got), len(entries)) + } + + for i, want := range entries { + if got[i] != want { + t.Errorf("[%d] = %+v, want %+v", i, got[i], want) + } + } +} + +func TestListMarkdownRaw(t *testing.T) { + t.Parallel() + + entries := []adrListEntry{ + {Number: 1, Title: "Use PostgreSQL", Status: testStatus, Date: testDate, Filepath: testFilename}, + } + + var buf bytes.Buffer + + if err := renderMarkdownTable(&buf, entries, FormatRaw, "list"); err != nil { + t.Fatalf("renderMarkdownTable: %v", err) + } + + out := buf.String() + + const wantHeader = "| # | date | status | title |" + + if !strings.Contains(out, wantHeader) { + t.Errorf("output missing header %q\ngot: %s", wantHeader, out) + } + + const wantRow = "| 0001 | 2024-01-15 | accepted | Use PostgreSQL |" + + if !strings.Contains(out, wantRow) { + t.Errorf("output missing row %q\ngot: %s", wantRow, out) + } +} diff --git a/internal/app/show.go b/internal/app/show.go index f15cfd3..fb95471 100644 --- a/internal/app/show.go +++ b/internal/app/show.go @@ -1,7 +1,10 @@ package app import ( + "encoding/json" "fmt" + "io" + "os" "strings" "charm.land/glamour/v2" @@ -10,12 +13,54 @@ import ( "github.com/corani/adr/internal/adr" ) -func Show(conf *config.Config, number int) error { +type adrShowEntry struct { + Number int `json:"number"` + Title string `json:"title"` + Status string `json:"status"` + Date string `json:"date"` + Filepath string `json:"filepath"` + Body string `json:"body"` +} + +func Show(conf *config.Config, number int, format Format) error { found, err := adr.ByID(conf, adr.Number(number)) if err != nil { return fmt.Errorf("%w: show: %w", ErrInternal, err) } + switch format { + case FormatJSON: + return showJSON(os.Stdout, found) + case FormatRaw, FormatMd: + return showMarkdown(os.Stdout, found, format) + default: + return fmt.Errorf("%w: show: unknown format %q", ErrInternal, format) + } +} + +func showJSON(writer io.Writer, found *adr.Adr) error { + entry := adrShowEntry{ + Number: int(found.Number), + Title: found.Title, + Status: string(found.Status), + Date: found.Date, + Filepath: found.Filename, + Body: strings.TrimSpace(string(found.Body)), + } + + out, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("%w: show: %w", ErrInternal, err) + } + + if _, err = fmt.Fprintln(writer, string(out)); err != nil { + return fmt.Errorf("%w: show: %w", ErrInternal, err) + } + + return nil +} + +func showMarkdown(writer io.Writer, found *adr.Adr, format Format) error { meta := strings.Join([]string{ "| field | value |", "|-------|-------|", @@ -25,6 +70,14 @@ func Show(conf *config.Config, number int) error { fmt.Sprintf("| Status | %s |", found.Status), }, "\n") + "\n\n" + reflowBody(string(found.Body)) + if format == FormatRaw { + if _, err := fmt.Fprint(writer, meta); err != nil { + return fmt.Errorf("%w: show: %w", ErrInternal, err) + } + + return nil + } + renderer, err := glamour.NewTermRenderer( glamour.WithEnvironmentConfig(), glamour.WithWordWrap(0), diff --git a/internal/app/show_test.go b/internal/app/show_test.go new file mode 100644 index 0000000..2cc5a8e --- /dev/null +++ b/internal/app/show_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/corani/adr/internal/adr" +) + +func TestShowJSON(t *testing.T) { + t.Parallel() + + entry := &adr.Adr{ + Filename: testFilename, + Type: "", + Number: 1, + Title: testTitle, + Status: adr.StatusAccepted, + Date: testDate, + Link: 0, + Body: []byte(" We chose PostgreSQL.\n"), + } + + var buf bytes.Buffer + + if err := showJSON(&buf, entry); err != nil { + t.Fatalf("showJSON: %v", err) + } + + var got adrShowEntry + + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\noutput: %s", err, buf.String()) + } + + if got.Number != 1 { + t.Errorf("Number = %d, want 1", got.Number) + } + + if got.Title != testTitle { + t.Errorf("Title = %q", got.Title) + } + + if got.Status != testStatus { + t.Errorf("Status = %q", got.Status) + } + + if got.Body != "We chose PostgreSQL." { + t.Errorf("Body = %q", got.Body) + } +} + +func TestShowMarkdownRaw(t *testing.T) { + t.Parallel() + + entry := &adr.Adr{ + Filename: testFilename, + Type: "", + Number: 1, + Title: testTitle, + Status: adr.StatusAccepted, + Date: testDate, + Link: 0, + Body: []byte("We chose PostgreSQL.\n"), + } + + var buf bytes.Buffer + + if err := showMarkdown(&buf, entry, FormatRaw); err != nil { + t.Fatalf("showMarkdown: %v", err) + } + + out := buf.String() + + if !strings.Contains(out, "| Status | "+testStatus+" |") { + t.Errorf("output missing status row\ngot: %s", out) + } + + if !strings.Contains(out, "We chose PostgreSQL.") { + t.Errorf("output missing body\ngot: %s", out) + } +} diff --git a/internal/app/table.go b/internal/app/table.go new file mode 100644 index 0000000..6c066ef --- /dev/null +++ b/internal/app/table.go @@ -0,0 +1,47 @@ +package app + +import ( + "fmt" + "io" + "strings" + + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" +) + +func renderMarkdownTable(writer io.Writer, entries []adrListEntry, format Format, label string) error { + rows := make([]string, len(entries)) + + for i, e := range entries { + rows[i] = fmt.Sprintf("| %04d | %s | %s | %s |", e.Number, e.Date, e.Status, e.Title) + } + + table := "| # | date | status | title |\n|---|------|--------|-------|\n" + strings.Join(rows, "\n") + "\n" + + if format == FormatRaw { + if _, err := fmt.Fprint(writer, table); err != nil { + return fmt.Errorf("%w: %s: %w", ErrInternal, label, err) + } + + return nil + } + + renderer, err := glamour.NewTermRenderer( + glamour.WithEnvironmentConfig(), + glamour.WithWordWrap(0), + ) + if err != nil { + return fmt.Errorf("%w: %s: %w", ErrInternal, label, err) + } + + rendered, err := renderer.Render(table) + if err != nil { + return fmt.Errorf("%w: %s: %w", ErrInternal, label, err) + } + + if _, err = lipgloss.Print(rendered); err != nil { + return fmt.Errorf("%w: %s: %w", ErrInternal, label, err) + } + + return nil +} diff --git a/internal/app/testconst_test.go b/internal/app/testconst_test.go new file mode 100644 index 0000000..49fec05 --- /dev/null +++ b/internal/app/testconst_test.go @@ -0,0 +1,8 @@ +package app + +const ( + testFilename = "0001-use-postgresql.md" + testTitle = "Use PostgreSQL for storage" + testDate = "2024-01-15" + testStatus = "accepted" +)