From 607200c1d007b8dec420554a433e52559d3747dc Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sun, 31 May 2026 15:47:14 -0600 Subject: [PATCH 1/2] fix(search): duplicate search logic in ui and headless --- internal/headless/headless.go | 63 +-------------------------- internal/resolver/search.go | 74 ++++++++++++++++++++++++++++++++ internal/resolver/search_test.go | 72 +++++++++++++++++++++++++++++++ internal/ui/cheat_select.go | 34 ++------------- internal/ui/helpers.go | 10 ----- internal/ui/history_view.go | 2 +- internal/ui/substitute_search.go | 4 +- internal/ui/var_resolve.go | 2 +- internal/ui/var_resolve_test.go | 3 +- 9 files changed, 157 insertions(+), 107 deletions(-) create mode 100644 internal/resolver/search.go create mode 100644 internal/resolver/search_test.go diff --git a/internal/headless/headless.go b/internal/headless/headless.go index 03ec64f..9e2d422 100644 --- a/internal/headless/headless.go +++ b/internal/headless/headless.go @@ -7,7 +7,6 @@ import ( "io" "os" "os/exec" - "path/filepath" "strings" "time" @@ -118,8 +117,8 @@ func (s *RunnerSession) tryMatchByQuery(cheats []*parser.Cheat, query string) er return fmt.Errorf("headless runner requires a precise query or match command to isolate a single cheat block") } words := strings.Fields(strings.ToLower(query)) - matchedCheats := s.filterCheatsByWords(cheats, words) - s.Cheat = s.findExactHeaderMatch(matchedCheats, query) + matchedCheats := resolver.SearchCheats(cheats, words) + s.Cheat = resolver.FindExactHeaderMatch(matchedCheats, query) if s.Cheat != nil { return nil } @@ -133,24 +132,6 @@ func (s *RunnerSession) tryMatchByQuery(cheats []*parser.Cheat, query string) er return fmt.Errorf("headless runner requires a precise query or match command to isolate a single cheat block") } -func (s *RunnerSession) filterCheatsByWords(cheats []*parser.Cheat, words []string) []*parser.Cheat { - var matched []*parser.Cheat - for _, c := range cheats { - if cheatMatchesQuery(c, words) { - matched = append(matched, c) - } - } - return matched -} - -func (s *RunnerSession) findExactHeaderMatch(cheats []*parser.Cheat, query string) *parser.Cheat { - for _, mc := range cheats { - if strings.EqualFold(mc.Header, query) { - return mc - } - } - return nil -} // runCommand constructs the final command string, attaches any configured hooks, // executes the command on the target shell, and reports the output via JSON-RPC. @@ -270,43 +251,3 @@ func getExitCode(err error) int { } return -1 } - -// cheatMatchesQuery performs a heuristic search across the cheat's metadata for targeting. -func cheatMatchesQuery(cheat *parser.Cheat, words []string) bool { - for _, word := range words { - if !wordMatchesCheat(cheat, word) { - return false - } - } - return true -} - -func wordMatchesCheat(cheat *parser.Cheat, word string) bool { - if matchesBasicMetadata(cheat, word) { - return true - } - return matchesAnyTag(cheat.Tags, word) -} - -func matchesBasicMetadata(cheat *parser.Cheat, word string) bool { - folder := strings.ToLower(filepath.Base(cheat.File)) - file := strings.ToLower(strings.TrimSuffix(filepath.Base(cheat.File), ".md")) - header := strings.ToLower(cheat.Header) - desc := strings.ToLower(cheat.Description) - command := strings.ToLower(cheat.Command) - - return strings.Contains(folder, word) || - strings.Contains(file, word) || - strings.Contains(header, word) || - strings.Contains(desc, word) || - strings.Contains(command, word) -} - -func matchesAnyTag(tags []string, word string) bool { - for _, tag := range tags { - if strings.Contains(strings.ToLower(tag), word) { - return true - } - } - return false -} diff --git a/internal/resolver/search.go b/internal/resolver/search.go new file mode 100644 index 0000000..0f885cf --- /dev/null +++ b/internal/resolver/search.go @@ -0,0 +1,74 @@ +package resolver + +import ( + "path/filepath" + "strings" + + "github.com/cheatmd-dev/cheatmd/pkg/parser" +) + +// SearchCheats filters a slice of cheats returning only those that match all query words. +// Query words must be pre-lowercased. +func SearchCheats(cheats []*parser.Cheat, words []string) []*parser.Cheat { + var matched []*parser.Cheat + for _, c := range cheats { + if MatchesQuery(c, words) { + matched = append(matched, c) + } + } + return matched +} + +// MatchesQuery returns true if the cheat's metadata contains all query words. +// Query words must be pre-lowercased. +func MatchesQuery(cheat *parser.Cheat, words []string) bool { + for _, word := range words { + if !ContainsWord(cheat, word) { + return false + } + } + return true +} + +// ContainsWord returns true if the cheat's metadata (folder, file, header, description, command, tags) +// contains the given lowercase word. +func ContainsWord(cheat *parser.Cheat, word string) bool { + folder := strings.ToLower(filepath.Base(filepath.Dir(cheat.File))) + file := strings.ToLower(strings.TrimSuffix(filepath.Base(cheat.File), filepath.Ext(cheat.File))) + + if strings.Contains(folder, word) || + strings.Contains(file, word) || + strings.Contains(strings.ToLower(cheat.Header), word) || + strings.Contains(strings.ToLower(cheat.Description), word) || + strings.Contains(strings.ToLower(cheat.Command), word) { + return true + } + + for _, tag := range cheat.Tags { + if strings.Contains(strings.ToLower(tag), word) { + return true + } + } + return false +} + +// FindExactHeaderMatch returns the first cheat with an exact case-insensitive header match. +func FindExactHeaderMatch(cheats []*parser.Cheat, query string) *parser.Cheat { + for _, mc := range cheats { + if strings.EqualFold(mc.Header, query) { + return mc + } + } + return nil +} + +// MatchesAllWords returns true if the given text contains all the given words. +// The text and words must be pre-lowercased. +func MatchesAllWords(text string, words []string) bool { + for _, w := range words { + if !strings.Contains(text, w) { + return false + } + } + return true +} diff --git a/internal/resolver/search_test.go b/internal/resolver/search_test.go new file mode 100644 index 0000000..845b042 --- /dev/null +++ b/internal/resolver/search_test.go @@ -0,0 +1,72 @@ +package resolver + +import ( + "testing" + + "github.com/cheatmd-dev/cheatmd/pkg/parser" +) + +func TestSearchCheats(t *testing.T) { + c1 := &parser.Cheat{ + File: "/path/to/project/docker.md", + Header: "Build image", + Description: "Builds a docker image", + Command: "docker build -t app .", + Tags: []string{"container"}, + } + c2 := &parser.Cheat{ + File: "/path/to/project/k8s.md", + Header: "Deploy to k8s", + Description: "Deploys to kubernetes", + Command: "kubectl apply -f .", + Tags: []string{"container", "deploy"}, + } + + cheats := []*parser.Cheat{c1, c2} + + // Test folder match + if len(SearchCheats(cheats, []string{"project"})) != 2 { + t.Error("Expected both cheats to match folder 'project'") + } + + // Test file match + if len(SearchCheats(cheats, []string{"docker"})) != 1 { + t.Error("Expected only c1 to match 'docker'") + } + + // Test header match + if len(SearchCheats(cheats, []string{"deploy", "k8s"})) != 1 { + t.Error("Expected only c2 to match 'deploy k8s'") + } + + // Test tag match + if len(SearchCheats(cheats, []string{"container"})) != 2 { + t.Error("Expected both cheats to match tag 'container'") + } +} + +func TestFindExactHeaderMatch(t *testing.T) { + c1 := &parser.Cheat{Header: "Build Image"} + c2 := &parser.Cheat{Header: "Deploy App"} + cheats := []*parser.Cheat{c1, c2} + + if match := FindExactHeaderMatch(cheats, "build image"); match != c1 { + t.Error("Expected case-insensitive exact match for 'build image'") + } + if match := FindExactHeaderMatch(cheats, "deploy app"); match != c2 { + t.Error("Expected case-insensitive exact match for 'deploy app'") + } + if match := FindExactHeaderMatch(cheats, "build"); match != nil { + t.Error("Expected nil for partial match") + } +} + +func TestMatchesAllWords(t *testing.T) { + text := "the quick brown fox" + if !MatchesAllWords(text, []string{"quick", "fox"}) { + t.Error("Expected text to match all words") + } + if MatchesAllWords(text, []string{"quick", "lazy"}) { + t.Error("Expected text to not match missing words") + } +} diff --git a/internal/ui/cheat_select.go b/internal/ui/cheat_select.go index 7c5a72b..c852f9f 100644 --- a/internal/ui/cheat_select.go +++ b/internal/ui/cheat_select.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/cheatmd-dev/cheatmd/internal/resolver" "github.com/cheatmd-dev/cheatmd/pkg/chainstate" "github.com/cheatmd-dev/cheatmd/pkg/config" "github.com/cheatmd-dev/cheatmd/pkg/parser" @@ -80,36 +81,7 @@ func newChainItem(chain chainGroup, cheat *parser.Cheat) cheatItem { // matchesQuery reports whether the cheat item matches all search words. // Words must be pre-lowercased. func (item *cheatItem) matchesQuery(words []string) bool { - for _, word := range words { - if !item.containsWord(word) { - return false - } - } - return true -} - -func (item *cheatItem) containsWord(word string) bool { - if strings.Contains(strings.ToLower(item.folder), word) { - return true - } - if strings.Contains(strings.ToLower(item.file), word) { - return true - } - if strings.Contains(strings.ToLower(item.cheat.Header), word) { - return true - } - if strings.Contains(strings.ToLower(item.cheat.Description), word) { - return true - } - if strings.Contains(strings.ToLower(item.cheat.Command), word) { - return true - } - for _, tag := range item.cheat.Tags { - if strings.Contains(strings.ToLower(tag), word) { - return true - } - } - return false + return resolver.MatchesQuery(item.cheat, words) } // buildPathDisplay builds the path display string based on config options. @@ -280,7 +252,7 @@ func chainMatchesQuery(chain chainGroup, words []string) bool { hay += " " + strings.ToLower(step.Header) hay += " " + strings.ToLower(step.Description) } - return matchesAllWords(hay, words) + return resolver.MatchesAllWords(hay, words) } func (m *mainModel) nextChainStep(chain chainGroup) *parser.Cheat { diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index ac7ed76..4d09653 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -70,16 +70,6 @@ func truncateLines(text string, maxLines int, maxLen int) string { return text } -// matchesAllWords returns true if text contains all words. -func matchesAllWords(text string, words []string) bool { - for _, word := range words { - if !strings.Contains(text, word) { - return false - } - } - return true -} - // formatKeyDisplay turns "ctrl+x" into "Ctrl+X" for display purposes. func formatKeyDisplay(key string) string { if strings.HasPrefix(key, "ctrl+") { diff --git a/internal/ui/history_view.go b/internal/ui/history_view.go index ccc2d54..4f1aaa9 100644 --- a/internal/ui/history_view.go +++ b/internal/ui/history_view.go @@ -37,7 +37,7 @@ func (m *mainModel) enterHistory() bool { m.histState = &historyState{ picker: NewPicker(entries, func(e history.Entry, words []string) bool { hay := strings.ToLower(e.Command + " " + e.Header + " " + e.File) - return matchesAllWords(hay, words) + return resolver.MatchesAllWords(hay, words) }), prevInput: m.textInput.Value(), prevCursor: m.picker.Cursor, diff --git a/internal/ui/substitute_search.go b/internal/ui/substitute_search.go index f463356..6ee85e0 100644 --- a/internal/ui/substitute_search.go +++ b/internal/ui/substitute_search.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" + "github.com/cheatmd-dev/cheatmd/internal/resolver" "github.com/cheatmd-dev/cheatmd/pkg/config" ) @@ -57,8 +58,7 @@ func (m *mainModel) enterSubstituteSearch() bool { } m.subState = &substituteSearchState{ picker: NewPicker(opts, func(opt substituteOption, words []string) bool { - hay := strings.ToLower(opt.Display) - return matchesAllWords(hay, words) + return resolver.MatchesAllWords(strings.ToLower(opt.Display), words) }), prevInput: m.textInput.Value(), prevCursor: m.picker.Cursor, diff --git a/internal/ui/var_resolve.go b/internal/ui/var_resolve.go index 6a0ad6e..19a970d 100644 --- a/internal/ui/var_resolve.go +++ b/internal/ui/var_resolve.go @@ -282,7 +282,7 @@ func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { if m.varState.picker == nil { m.varState.picker = NewPicker(items, func(opt FilteredOption, words []string) bool { - return matchesAllWords(opt.SearchText, words) + return resolver.MatchesAllWords(opt.SearchText, words) }) } else { m.varState.picker.SetItems(items) diff --git a/internal/ui/var_resolve_test.go b/internal/ui/var_resolve_test.go index 5f765fb..1836f08 100644 --- a/internal/ui/var_resolve_test.go +++ b/internal/ui/var_resolve_test.go @@ -4,6 +4,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/cheatmd-dev/cheatmd/internal/resolver" "github.com/cheatmd-dev/cheatmd/pkg/parser" ) @@ -18,7 +19,7 @@ func TestVarResolveArrowKeysMoveSelection(t *testing.T) { m.varState = &varResolveState{ isPromptOnly: false, picker: NewPicker(items, func(opt FilteredOption, words []string) bool { - return matchesAllWords(opt.SearchText, words) + return resolver.MatchesAllWords(opt.SearchText, words) }), } From 06cf78eb6733d1d6e5e619b40899d35a6279b6d5 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sun, 31 May 2026 15:48:58 -0600 Subject: [PATCH 2/2] fix(search): duplicate search logic in ui and headless --- internal/headless/headless.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/headless/headless.go b/internal/headless/headless.go index 9e2d422..948fc44 100644 --- a/internal/headless/headless.go +++ b/internal/headless/headless.go @@ -132,7 +132,6 @@ func (s *RunnerSession) tryMatchByQuery(cheats []*parser.Cheat, query string) er return fmt.Errorf("headless runner requires a precise query or match command to isolate a single cheat block") } - // runCommand constructs the final command string, attaches any configured hooks, // executes the command on the target shell, and reports the output via JSON-RPC. func (s *RunnerSession) runCommand() error {