diff --git a/integration/history.go b/integration/history.go index 9bab033..6f82965 100644 --- a/integration/history.go +++ b/integration/history.go @@ -208,52 +208,33 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error var results []HistResult seenCmds := make(map[string]bool) - addMatches := func(q string, subcmdFilter bool) { + addMatches := func(q string) { qLow := strings.ToLower(q) - queryFirstWord := "" - querySecondWord := "" - if strings.IndexByte(qLow, ' ') != -1 { - if fields := strings.Fields(qLow); len(fields) > 0 { - queryFirstWord = fields[0] - // find first non-flag token after the command as the subcommand - for _, f := range fields[1:] { - if !strings.HasPrefix(f, "-") { - querySecondWord = f - break - } - } - } - } - // extract pure prefix matches based strictly on recency order (historyCache is newest-first) + // extract pure substring matches (all words present) based strictly on recency order (historyCache is newest-first) + // this ensures that long commands with exact substrings are never truncated by the fuzzy searcher's limit strictMatches := 0 + words := strings.Fields(qLow) + if len(words) == 0 { + words = []string{qLow} + } + for _, cmd := range historyCache { if seenCmds[cmd] { continue } - fields := strings.Fields(cmd) - firstWordLow := "" - if len(fields) > 0 { - firstWordLow = strings.ToLower(fields[0]) + + cmdLow := strings.ToLower(cmd) + matchAll := true + for _, w := range words { + if !strings.Contains(cmdLow, w) { + matchAll = false + break + } } - if queryFirstWord != "" { - if firstWordLow != queryFirstWord { - continue - } - if subcmdFilter && querySecondWord != "" { - if len(fields) < 2 { - continue - } - secondWordLow := strings.ToLower(fields[1]) - if !strings.HasPrefix(secondWordLow, querySecondWord) { - continue - } - } - } else { - if !strings.HasPrefix(firstWordLow, qLow) { - continue - } + if !matchAll { + continue } seenCmds[cmd] = true @@ -273,33 +254,11 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error if seenCmds[m.Str] { continue } - - // filter results by command name match - fields := strings.Fields(m.Str) - firstWord := m.Str - if len(fields) > 0 { - firstWord = fields[0] - } - firstWordLow := strings.ToLower(firstWord) - - if queryFirstWord != "" { - if firstWordLow != queryFirstWord { - continue - } - // when query has a non-flag second token, filter by subcommand prefix - if subcmdFilter && querySecondWord != "" { - if len(fields) < 2 { - continue - } - secondWordLow := strings.ToLower(fields[1]) - if !strings.HasPrefix(secondWordLow, querySecondWord) { - continue - } - } - } else { - if !strings.HasPrefix(firstWordLow, qLow) { - continue - } + + // filter out extremely weak fuzzy matches (e.g. random garbage typing that + // loosely matches across a very long command) + if len(q) > 0 && m.Score/len(q) < 150 { + continue } seenCmds[m.Str] = true @@ -311,18 +270,9 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error } } - addMatches(query, true) + addMatches(query) for _, altQ := range alternativeQueries { - addMatches(altQ, true) - } - - // fallback: if subcommand filter produced nothing, retry without it - // so typos like "git chckout" still surface fuzzy matches - if len(results) == 0 { - addMatches(query, false) - for _, altQ := range alternativeQueries { - addMatches(altQ, false) - } + addMatches(altQ) } getTier := func(cmd, q string) int { diff --git a/internal/scoring/frecency.go b/internal/scoring/frecency.go index 0ce184b..e2cf1a7 100644 --- a/internal/scoring/frecency.go +++ b/internal/scoring/frecency.go @@ -335,7 +335,7 @@ func (f *FrecencyStore) QueryLocal(ctx context.Context, cwd, prefix string, limi var rows *sql.Rows var err error if prefix != "" { - rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ? AND cmd LIKE ?`, cwd, prefix+"%") + rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ? AND cmd LIKE ?`, cwd, "%"+prefix+"%") } else { rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ?`, cwd) } @@ -397,7 +397,7 @@ func (f *FrecencyStore) QueryGlobal(ctx context.Context, prefix string, limit in var rows *sql.Rows var err error if prefix != "" { - rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cmd LIKE ?`, prefix+"%") + rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cmd LIKE ?`, "%"+prefix+"%") } else { rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries`) } diff --git a/internal/scoring/frecency_test.go b/internal/scoring/frecency_test.go index cb091e1..5ccccbe 100644 --- a/internal/scoring/frecency_test.go +++ b/internal/scoring/frecency_test.go @@ -145,6 +145,55 @@ func TestFrecencyStore_SQLiteConfigurationAndContext(t *testing.T) { } } +func TestFrecencyStore_SubstringMatch(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "history.db") + store, err := NewFrecencyStore(dbPath) + if err != nil { + t.Fatalf("NewFrecencyStore failed: %v", err) + } + defer store.Close() + + cwd := "/home/user/project" + _ = store.Record(context.Background(), "sudo chown -R www-data:www-data /var/www/html", cwd, 0) + _ = store.Record(context.Background(), "sudo systemctl restart nginx", cwd, 0) + _ = store.Record(context.Background(), "chmod +x script.sh", cwd, 0) + + _ = store.Record(context.Background(), "echo hello world", cwd, 0) + + // Test QueryLocal with substring + entries, err := store.QueryLocal(context.Background(), cwd, "cho", 10) + if err != nil { + t.Fatalf("QueryLocal failed: %v", err) + } + if len(entries) != 2 { // should match 'sudo chown...' and 'echo...' (has 'cho') + t.Fatalf("expected 2 entries for 'cho', got %d", len(entries)) + } + + foundChown := false + for _, e := range entries { + if e.Cmd == "sudo chown -R www-data:www-data /var/www/html" { + foundChown = true + break + } + } + if !foundChown { + t.Errorf("expected 'sudo chown...' to be found with substring 'cho'") + } + + // Test QueryGlobal with substring + entriesGlobal, err := store.QueryGlobal(context.Background(), "chown", 10) + if err != nil { + t.Fatalf("QueryGlobal failed: %v", err) + } + if len(entriesGlobal) != 1 { + t.Fatalf("expected 1 entry for 'chown', got %d", len(entriesGlobal)) + } + if entriesGlobal[0].Cmd != "sudo chown -R www-data:www-data /var/www/html" { + t.Errorf("expected 'sudo chown...' to be found with substring 'chown', got %s", entriesGlobal[0].Cmd) + } +} + func TestFrecencyStore_NilReceiver(t *testing.T) { var nilStore *FrecencyStore if err := nilStore.Record(context.Background(), "cmd", "cwd", 0); err != nil {