Skip to content

Commit e3c9814

Browse files
authored
Merge pull request #24 from cybrota/feat/add-fuzzy-search
feat: add fuzzing mode with configuration
2 parents e7cf360 + 521b43b commit e3c9814

8 files changed

Lines changed: 254 additions & 20 deletions

File tree

README.md

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ Recaller searches your shell history locally with smart ranking, instant help lo
1212

1313
## ✨ Features
1414

15-
- **Smart Search**: Commands ranked by frequency and recency with fuzzy matching
15+
- **Smart Search**: Commands ranked by frequency and recency with configurable search modes
16+
- **Fuzzy Search**: Substring matching anywhere in commands (default)
17+
- **Prefix Search**: Fast matching of command beginnings (configurable)
1618
- **Instant Help**: View man pages and command documentation without leaving the interface
1719
- **Terminal Integration**: Copy to clipboard or execute in new terminal tabs
1820
- **Privacy First**: All processing happens locally - your history stays on your machine
@@ -44,13 +46,37 @@ cd recaller && go build -o recaller . && sudo mv recaller /usr/local/bin/
4446
- **Bash**: Follow [setup guide](docs/setup-bash.md) to enable timestamped history
4547
- **Zsh**: Works out of the box, see [setup guide](docs/setup-zsh.md) for optimization
4648

49+
**Search Configuration** (Optional)
50+
Create `~/.recaller.yaml` to customize search behavior:
51+
```yaml
52+
history:
53+
# Default: true (fuzzy search - matches substring anywhere)
54+
enable_fuzzing: true
55+
56+
# Set to false for prefix-based search only
57+
# enable_fuzzing: false
58+
```
59+
4760
**Usage**
4861
```bash
49-
recaller # Launch interactive search
50-
recaller history # View history with filtering
51-
recaller version # Check version
62+
recaller # Launch interactive search
63+
recaller history # View history with filtering
64+
recaller settings list # View current configuration settings
65+
recaller version # Check version
5266
```
5367

68+
## 🔍 Search Modes
69+
70+
**Fuzzy Search** (Default)
71+
- Matches commands containing your search query **anywhere**
72+
- More intuitive and finds commands with keywords in any position
73+
- Example: `commit` matches `git commit -m "fix"`, `pre-commit run`, etc.
74+
75+
**Prefix Search** (Configurable)
76+
- Matches commands that **start with** your search query
77+
- Fast and efficient for finding commands by their beginning
78+
- Example: `git` matches `git status`, `git commit`, etc.
79+
5480
## ⌨️ Keyboard Shortcuts
5581

5682
| Key | Action | Key | Action |
@@ -109,12 +135,6 @@ limitations under the License.
109135

110136
Copyright © 2025 [Naren Yellavula](https://github.com/narenaryan)
111137

112-
## 👨‍💻 Author
113-
114-
**Naren Yellavula**
115-
- GitHub: [@narenaryan](https://github.com/narenaryan)
116-
- Website: [https://github.com/narenaryan](https://github.com/narenaryan)
117-
118138
## 🙏 Acknowledgments
119139

120140
- Built with [termui](https://github.com/gizak/termui) for the beautiful terminal interface

app.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,13 @@ func run(tree *AVLTree, hc *cache.Cache) {
214214
// co_key := os.Getenv("COHERE_API_KEY")
215215
// client := cohereclient.NewClient(cohereclient.WithToken(co_key))
216216

217+
// Load configuration
218+
config, err := LoadConfig()
219+
if err != nil {
220+
log.Printf("Failed to load configuration: %v. Using default settings.", err)
221+
config = &Config{History: HistoryConfig{EnableFuzzing: true}}
222+
}
223+
217224
// Done channel for ticker
218225
done := make(chan bool)
219226

@@ -297,7 +304,7 @@ func run(tree *AVLTree, hc *cache.Cache) {
297304
return // Skip if query hasn't changed
298305
}
299306
lastSearchQuery = query
300-
matches := SearchWithRanking(tree, query)
307+
matches := SearchWithRanking(tree, query, config.History.EnableFuzzing)
301308
suggestionList.Rows = suggestionList.Rows[:0] // Reuse slice to reduce allocations
302309
for _, node := range matches {
303310
suggestionList.Rows = append(suggestionList.Rows, node.Command)
@@ -558,10 +565,10 @@ func run(tree *AVLTree, hc *cache.Cache) {
558565
}
559566
}
560567

561-
// getSuggestions searches through file tree and returns list of macthes
568+
// getSuggestions searches through file tree and returns list of matches
562569
// of commandRecommendLimit length
563-
func getSuggestions(searchStr string, tree *AVLTree) []string {
564-
matches := SearchWithRanking(tree, searchStr)
570+
func getSuggestions(searchStr string, tree *AVLTree, enableFuzzing bool) []string {
571+
matches := SearchWithRanking(tree, searchStr, enableFuzzing)
565572
results := []string{}
566573

567574
count := 0

avl_tree.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ type AVLTreeIFace interface {
4848
Delete(key string)
4949
Search(key string) (interface{}, bool)
5050
SearchPrefix(prefix string) []*AVLNode
51+
SearchFuzzy(query string) []*AVLNode
5152
SearchPrefixMostRecent(prefix string) []*AVLNode
52-
SearchWithRanking(tree *AVLTree, query string) []RankedCommand
5353
}
5454

5555
type AVLTree struct {
@@ -334,9 +334,40 @@ func calculateScore(metadata CommandMetadata) (float64, error) {
334334
return score, nil
335335
}
336336

337-
func SearchWithRanking(tree *AVLTree, query string) []RankedCommand {
337+
// fuzzySearch performs in-order traversal and finds commands containing the query as substring
338+
func fuzzySearch(node *AVLNode, query string, results *[]*AVLNode) {
339+
if node == nil {
340+
return
341+
}
342+
343+
// Traverse left subtree
344+
fuzzySearch(node.Left, query, results)
345+
346+
// Check if current node contains the query as substring (case-insensitive)
347+
if strings.Contains(strings.ToLower(node.Key), strings.ToLower(query)) {
348+
*results = append(*results, node)
349+
}
350+
351+
// Traverse right subtree
352+
fuzzySearch(node.Right, query, results)
353+
}
354+
355+
func (tree *AVLTree) SearchFuzzy(query string) []*AVLNode {
356+
var results []*AVLNode
357+
fuzzySearch(tree.Root, query, &results)
358+
return results
359+
}
360+
361+
func SearchWithRanking(tree *AVLTree, query string, enableFuzzing bool) []RankedCommand {
362+
var nodes []*AVLNode
363+
364+
if enableFuzzing {
365+
nodes = tree.SearchFuzzy(query)
366+
} else {
367+
nodes = tree.SearchPrefix(query)
368+
}
369+
338370
// Pre-allocate slice with estimated capacity to reduce allocations
339-
nodes := tree.SearchPrefix(query)
340371
rankedCommands := make([]RankedCommand, 0, len(nodes))
341372

342373
// Traverse the tree to find matching commands

config.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright 2025 Naren Yellavula
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
22+
"gopkg.in/yaml.v3"
23+
)
24+
25+
type HistoryConfig struct {
26+
EnableFuzzing bool `yaml:"enable_fuzzing"`
27+
}
28+
29+
type Config struct {
30+
History HistoryConfig `yaml:"history"`
31+
}
32+
33+
var defaultConfig = Config{
34+
History: HistoryConfig{
35+
EnableFuzzing: true,
36+
},
37+
}
38+
39+
func LoadConfig() (*Config, error) {
40+
homeDir, err := os.UserHomeDir()
41+
if err != nil {
42+
return &defaultConfig, nil
43+
}
44+
45+
configPath := filepath.Join(homeDir, ".recaller.yaml")
46+
47+
if _, err := os.Stat(configPath); os.IsNotExist(err) {
48+
return &defaultConfig, nil
49+
}
50+
51+
data, err := os.ReadFile(configPath)
52+
if err != nil {
53+
return &defaultConfig, nil
54+
}
55+
56+
var config Config
57+
err = yaml.Unmarshal(data, &config)
58+
if err != nil {
59+
return &defaultConfig, nil
60+
}
61+
62+
return &config, nil
63+
}
64+
65+
func getConfigPath() (string, error) {
66+
homeDir, err := os.UserHomeDir()
67+
if err != nil {
68+
return "", err
69+
}
70+
return filepath.Join(homeDir, ".recaller.yaml"), nil
71+
}
72+
73+
func createDefaultConfigFile() error {
74+
configPath, err := getConfigPath()
75+
if err != nil {
76+
return fmt.Errorf("failed to get config path: %v", err)
77+
}
78+
79+
data, err := yaml.Marshal(&defaultConfig)
80+
if err != nil {
81+
return fmt.Errorf("failed to marshal default config: %v", err)
82+
}
83+
84+
err = os.WriteFile(configPath, data, 0644)
85+
if err != nil {
86+
return fmt.Errorf("failed to write config file: %v", err)
87+
}
88+
89+
return nil
90+
}
91+
92+
func displaySettings() {
93+
configPath, err := getConfigPath()
94+
if err != nil {
95+
fmt.Printf("❌ Failed to get config path: %v\n", err)
96+
return
97+
}
98+
99+
config, err := LoadConfig()
100+
if err != nil {
101+
fmt.Printf("❌ Failed to load configuration: %v\n", err)
102+
return
103+
}
104+
105+
configExists := true
106+
if _, err := os.Stat(configPath); os.IsNotExist(err) {
107+
configExists = false
108+
fmt.Printf("📝 Configuration file not found. Creating default configuration...\n\n")
109+
110+
if err := createDefaultConfigFile(); err != nil {
111+
fmt.Printf("❌ Failed to create default config file: %v\n", err)
112+
return
113+
}
114+
fmt.Printf("✅ Created default configuration at: %s\n\n", configPath)
115+
}
116+
117+
fmt.Printf("🔧 Recaller Configuration Settings\n")
118+
fmt.Printf("═══════════════════════════════════\n\n")
119+
120+
if configExists {
121+
fmt.Printf("📍 Config file: %s\n", configPath)
122+
} else {
123+
fmt.Printf("📍 Config file: %s (newly created)\n", configPath)
124+
}
125+
126+
fmt.Printf("📊 Current settings:\n\n")
127+
128+
fmt.Printf("🔍 %sHistory Search:%s\n", Green, Reset)
129+
130+
fuzzyValue := "true"
131+
fuzzyDesc := "Fuzzy search (substring matching anywhere)"
132+
if !config.History.EnableFuzzing {
133+
fuzzyValue = "false"
134+
fuzzyDesc = "Prefix-based search (commands starting with query)"
135+
}
136+
137+
fmt.Printf(" • %senable_fuzzing%s: %s\n", Green, Reset, fuzzyValue)
138+
fmt.Printf(" %s\n\n", fuzzyDesc)
139+
140+
if !config.History.EnableFuzzing {
141+
fmt.Printf("💡 Fuzzy search is disabled. To enable it, edit %s:\n", configPath)
142+
fmt.Printf(" history:\n enable_fuzzing: true\n\n")
143+
} else {
144+
fmt.Printf("💡 To use prefix-only search, edit %s:\n", configPath)
145+
fmt.Printf(" history:\n enable_fuzzing: false\n\n")
146+
}
147+
148+
fmt.Printf("📚 For more information, see: https://github.com/cybrota/recaller#search-modes\n")
149+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
github.com/nsf/termbox-go v1.1.1
1414
github.com/patrickmn/go-cache v2.1.0+incompatible
1515
github.com/spf13/cobra v1.9.1
16+
gopkg.in/yaml.v3 v3.0.1
1617
)
1718

1819
require (

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100100
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
101101
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
102102
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
103+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
103104
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
104105
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
105106
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

main.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,36 @@ Copyright @ Naren Yellavula (Please give us a star ⭐ here: https://github.com/
7676
if err := readHistoryAndPopulateTree(tree); err != nil {
7777
log.Fatalf("Error reading history: %v", err)
7878
}
79-
res := getSuggestions(cmd.Flag("match").Value.String(), tree)
79+
80+
// Load configuration for fuzzy search
81+
config, err := LoadConfig()
82+
if err != nil {
83+
log.Printf("Failed to load configuration: %v. Using default settings.", err)
84+
config = &Config{History: HistoryConfig{EnableFuzzing: true}}
85+
}
86+
87+
res := getSuggestions(cmd.Flag("match").Value.String(), tree, config.History.EnableFuzzing)
8088
fmt.Println(strings.Join(res, "\n"))
8189
},
8290
}
8391

8492
cmdHistory.Flags().String("match", "", "match string prefix to look in history")
8593

94+
var cmdSettingsList = &cobra.Command{
95+
Use: "list",
96+
Short: "List current configuration settings",
97+
Long: "Display all current configuration settings with their values",
98+
Run: func(cmd *cobra.Command, args []string) {
99+
displaySettings()
100+
},
101+
}
102+
103+
var cmdSettings = &cobra.Command{
104+
Use: "settings",
105+
Short: "Manage Recaller configuration settings",
106+
Long: "Commands for viewing and managing Recaller configuration",
107+
}
108+
86109
var cmdVersion = &cobra.Command{
87110
Use: "version",
88111
Short: "Print Recaller version",
@@ -107,6 +130,8 @@ Copyright @ Naren Yellavula (Please give us a star ⭐ here: https://github.com/
107130
run(tree, helpCache)
108131
},
109132
}
110-
rootCmd.AddCommand(cmdRun, cmdUsage, cmdVersion, cmdHistory)
133+
134+
cmdSettings.AddCommand(cmdSettingsList)
135+
rootCmd.AddCommand(cmdRun, cmdUsage, cmdVersion, cmdHistory, cmdSettings)
111136
rootCmd.Execute()
112137
}

version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414

1515
package main
1616

17-
const version = "v0.1.1"
17+
const version = "v0.2.0"

0 commit comments

Comments
 (0)