diff --git a/README.md b/README.md index ac6d42e..580c956 100644 --- a/README.md +++ b/README.md @@ -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 ` + 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 ` 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 new file mode 100644 index 0000000..a01aaf1 --- /dev/null +++ b/cmd/find.go @@ -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 ", + 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 +} diff --git a/cmd/root.go b/cmd/root.go index 8dc6f8e..2e53de4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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), @@ -28,6 +29,7 @@ func AdrCommands(conf *config.Config) []*cobra.Command { NewVersionCommand(), NewNewCommand(conf), NewListCommand(conf), + NewFindCommand(conf), NewShowCommand(conf), NewEditCommand(conf), NewUpdateCommand(conf), diff --git a/internal/app/find.go b/internal/app/find.go new file mode 100644 index 0000000..5666b34 --- /dev/null +++ b/internal/app/find.go @@ -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) +} diff --git a/internal/app/find_test.go b/internal/app/find_test.go new file mode 100644 index 0000000..5c7d408 --- /dev/null +++ b/internal/app/find_test.go @@ -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) + } + }) + } +}