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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`
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 <id>`
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 <query>`
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 <id> <status>`
Update the ADR with the given id, setting the status to one of: `proposed`, `accepted`,
`deprecated` or `superseded`.
12 changes: 11 additions & 1 deletion cmd/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query>",
Expand All @@ -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
}
17 changes: 15 additions & 2 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
19 changes: 16 additions & 3 deletions cmd/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@ import (
)

func NewShowCommand(conf *config.Config) *cobra.Command {
var format string

//nolint:exhaustruct
return &cobra.Command{
cmd := &cobra.Command{
Use: "show <id>",
Aliases: []string{"view"},
Short: "Show the ADR with number <id>",
Long: `Show the ADR with the given number.

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 {
Expand All @@ -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
}
44 changes: 27 additions & 17 deletions internal/app/find.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}

Expand Down
77 changes: 77 additions & 0 deletions internal/app/find_format_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 4 additions & 4 deletions internal/app/find_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
}
Expand All @@ -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},
}

Expand Down
30 changes: 30 additions & 0 deletions internal/app/format.go
Original file line number Diff line number Diff line change
@@ -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
}
}
38 changes: 38 additions & 0 deletions internal/app/format_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading