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 @@ -15,6 +15,9 @@ A simple command-line tool to manage ADRs in markdown format.
Open the ADR with the given id in your `$EDITOR`.
- `adr list`
List all ADRs with their status, date and title.
- `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.
- `adr update <id> <status>`
Update the ADR with the given id, setting the status to one of: `proposed`, `accepted`,
`deprecated` or `superseded`.
35 changes: 35 additions & 0 deletions cmd/find.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package cmd

import (
"log"

"github.com/corani/adr/config"
"github.com/corani/adr/internal/app"
"github.com/spf13/cobra"
)

func NewFindCommand(conf *config.Config) *cobra.Command {
var fullText bool

//nolint:exhaustruct
cmd := &cobra.Command{
Use: "find <query>",
Short: "Find ADRs matching a query",
Long: `Find ADRs whose title matches the query.

Words in the query are matched in order with anything allowed between them,
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 {
log.Printf("couldn't find adrs: %v", err)
}
},
}

cmd.Flags().BoolVarP(&fullText, "text", "t", false, "also search frontmatter fields and body")

return cmd
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func EmbedCommands(conf *config.Config) []*cobra.Command {
NewInitCommand(conf),
NewNewCommand(conf),
NewListCommand(conf),
NewFindCommand(conf),
NewShowCommand(conf),
NewEditCommand(conf),
NewUpdateCommand(conf),
Expand All @@ -28,6 +29,7 @@ func AdrCommands(conf *config.Config) []*cobra.Command {
NewVersionCommand(),
NewNewCommand(conf),
NewListCommand(conf),
NewFindCommand(conf),
NewShowCommand(conf),
NewEditCommand(conf),
NewUpdateCommand(conf),
Expand Down
86 changes: 86 additions & 0 deletions internal/app/find.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package app

import (
"fmt"
"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 {
re := buildQuery(query)

var rows []string

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))
}

return nil
})
if err != nil {
return fmt.Errorf("%w: find: %w", ErrInternal, err)
}

if len(rows) == 0 {
fmt.Println("no results")

return nil
}

slices.Sort(rows)

table := "| # | date | status | title |\n|---|------|--------|-------|\n" + strings.Join(rows, "\n") + "\n"

renderer, err := glamour.NewTermRenderer(
glamour.WithEnvironmentConfig(),
glamour.WithWordWrap(0),
)
if err != nil {
return fmt.Errorf("%w: find: %w", ErrInternal, err)
}

out, err := renderer.Render(table)
if err != nil {
return fmt.Errorf("%w: find: %w", ErrInternal, err)
}

if _, err = lipgloss.Print(out); err != nil {
return fmt.Errorf("%w: find: %w", ErrInternal, err)
}

return nil
}

func buildQuery(query string) *regexp.Regexp {
words := strings.Fields(query)
parts := make([]string, len(words))

for i, w := range words {
parts[i] = regexp.QuoteMeta(w)
}

return regexp.MustCompile("(?i)" + strings.Join(parts, ".*"))
}

func matches(pattern *regexp.Regexp, entry *adr.Adr, fullText bool) bool {
if pattern.MatchString(entry.Title) {
return true
}

if !fullText {
return false
}

return slices.ContainsFunc([]string{
string(entry.Status),
entry.Date,
string(entry.Body),
}, pattern.MatchString)
}
83 changes: 83 additions & 0 deletions internal/app/find_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package app

import (
"testing"

"github.com/corani/adr/internal/adr"
)

func TestBuildQuery(t *testing.T) {
t.Parallel()

const fooBar = "foo bar"

const fooDotBar = "foo.bar"

tests := []struct {
query string
input string
want bool
}{
{"foo", fooBar, true},
{"foo", "bar baz", false},
{fooBar, "foo baz bar", true},
{fooBar, "bar foo", false},
{fooBar, "foobar", true},
{fooDotBar, fooDotBar, true},
{fooDotBar, "fooXbar", false},
}

for _, test := range tests {
t.Run(test.query+"/"+test.input, func(t *testing.T) {
t.Parallel()

re := buildQuery(test.query)

if got := re.MatchString(test.input); got != test.want {
t.Errorf("buildQuery(%q).MatchString(%q) = %v, want %v", test.query, test.input, got, test.want)
}
})
}
}

func TestMatches(t *testing.T) {
t.Parallel()

entry := &adr.Adr{
Filename: "0001-use-postgresql.md",
Type: "",
Number: 1,
Title: "Use PostgreSQL for storage",
Status: adr.StatusAccepted,
Date: "2024-01-15",
Link: 0,
Body: []byte("We chose PostgreSQL because it supports JSONB."),
}

tests := []struct {
name string
query string
fullText bool
want bool
}{
{"title match", "postgres storage", false, true},
{"title case-insensitive", "POSTGRES", false, true},
{"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},
{"date searched with flag", "2024-01", true, true},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

re := buildQuery(test.query)

if got := matches(re, entry, test.fullText); got != test.want {
t.Errorf("matches(%q, fullText=%v) = %v, want %v", test.query, test.fullText, got, test.want)
}
})
}
}
Loading