From 11fad73ef500bb76e9661c35467f8d4ac95fd974 Mon Sep 17 00:00:00 2001 From: Pavel Sinitsin Date: Fri, 14 Aug 2026 13:11:21 +0300 Subject: [PATCH] fix(cd): harden zoxide directory suggestions --- README.md | 3 + commands/fs/cd.go | 7 +- commands/fs/zoxide.go | 243 +++++++++++++++++++++++---------- commands/fs/zoxide_test.go | 176 ++++++++++++++++++++++++ internal/config/config.go | 12 +- internal/config/config_test.go | 3 + root/config_cmd.go | 4 + root/config_cmd_test.go | 3 + root/init.go | 4 + spec/lookup.go | 28 +++- spec/lookup_test.go | 18 ++- tests/commands/zoxide_test.go | 71 ++++------ 12 files changed, 436 insertions(+), 136 deletions(-) create mode 100644 commands/fs/zoxide_test.go diff --git a/README.md b/README.md index 424e2cb..d27fe75 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,9 @@ navigate-right = "right" # accept ghost text filter-active-branch = true # exclude current branch from suggestions deduplicate-branches = true # merge same-name local/remote branches +[zoxide] +extend-cd = false # include zoxide's frecent directories in cd suggestions + [updater] check-on-startup = true # check for updates on startup channel = "stable" # "stable" or "nightly" diff --git a/commands/fs/cd.go b/commands/fs/cd.go index ca755dd..eb1e155 100644 --- a/commands/fs/cd.go +++ b/commands/fs/cd.go @@ -13,11 +13,8 @@ func init() { Description: "change directory", MaxArgs: 0, Generator: func(tokens []string, prefix string, partial string) []spec.Suggestion { - // The file generator only reads the current directory, so `cd proj` - // comes back empty unless its parent is where you already are -- - // which is not how cd is typically used. zoxide.extend-cd folds in - // the frecency database that `z` already queries, making any - // directory zoxide has seen reachable by name from anywhere. + // Extend cd with zoxide's frecency database while keeping local + // filesystem suggestions first. if config.Get().Zoxide.ExtendCd { return ZoxideGenerator()(tokens, prefix, partial) } diff --git a/commands/fs/zoxide.go b/commands/fs/zoxide.go index 4a29ef5..b07a157 100644 --- a/commands/fs/zoxide.go +++ b/commands/fs/zoxide.go @@ -1,14 +1,10 @@ -// please note that zoxide also shows external suggestions -// at the end of the list on command mode -// they are the old directories that you have visited -// this is a feature, not a bug, and I want to keep it package fs import ( - "bytes" "context" "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -17,6 +13,24 @@ import ( "github.com/versenilvis/iris/spec" ) +const ( + zoxideCacheTTL = 2 * time.Second + zoxideQueryTimeout = 500 * time.Millisecond + zoxideEmptyLimit = 20 + zoxideSearchLimit = 10 +) + +type zoxideQueryFunc func(context.Context) ([]byte, error) + +type zoxideSource struct { + mu sync.Mutex + query zoxideQueryFunc + now func() time.Time + dirs []string + err error + fetched time.Time +} + func init() { spec.Register(&spec.Spec{ Name: "z", @@ -32,94 +46,179 @@ func init() { }) } -// zoxideCacheTTL bounds how stale the directory list may be. Generators run on -// every keystroke and `zoxide query -l` is a subprocess costing on the order of -// ten milliseconds, which is the entire latency budget for a suggestion redraw. -// The list only changes when the user changes directory, so a short window -// costs nothing in practice. -const zoxideCacheTTL = 2 * time.Second - -var zoxideCache struct { - sync.Mutex - dirs []string - err error - fetched time.Time +func newZoxideSource(query zoxideQueryFunc) *zoxideSource { + return &zoxideSource{query: query, now: time.Now} } -// zoxideDirs returns zoxide's known directories, most frecent first. -func zoxideDirs() ([]string, error) { - zoxideCache.Lock() - defer zoxideCache.Unlock() +var defaultZoxideSource = newZoxideSource(func(ctx context.Context) ([]byte, error) { + return exec.CommandContext(ctx, "zoxide", "query", "-l").Output() +}) - if !zoxideCache.fetched.IsZero() && time.Since(zoxideCache.fetched) < zoxideCacheTTL { - return zoxideCache.dirs, zoxideCache.err +// directories caches failures too, preventing a missing zoxide binary from +// spawning a failed process on every keystroke. +func (s *zoxideSource) directories() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := s.now() + if !s.fetched.IsZero() && now.Before(s.fetched.Add(zoxideCacheTTL)) { + return append([]string(nil), s.dirs...), s.err } - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), zoxideQueryTimeout) defer cancel() - out, err := exec.CommandContext(ctx, "zoxide", "query", "-l").Output() - zoxideCache.fetched = time.Now() - zoxideCache.err = err - zoxideCache.dirs = nil + out, err := s.query(ctx) + s.fetched = s.now() + s.err = err + s.dirs = nil if err != nil { return nil, err } - for line := range strings.SplitSeq(string(bytes.TrimSpace(out)), "\n") { - if line = strings.TrimSpace(line); line != "" { - zoxideCache.dirs = append(zoxideCache.dirs, line) + for line := range strings.SplitSeq(string(out), "\n") { + line = strings.TrimSuffix(line, "\r") + if line != "" { + s.dirs = append(s.dirs, line) } } - return zoxideCache.dirs, nil + + return append([]string(nil), s.dirs...), nil } func ZoxideGenerator() spec.GeneratorFunc { + return newZoxideGenerator(defaultZoxideSource.directories) +} + +func newZoxideGenerator(directorySource func() ([]string, error)) spec.GeneratorFunc { return func(tokens []string, prefix string, partial string) []spec.Suggestion { fullQuery := strings.Join(tokens[1:], " ") - localSuggestions := spec.FileGenerator("/")(tokens, prefix, fullQuery) - - var zoxideSuggestions []spec.Suggestion - dirs, err := zoxideDirs() - if err == nil { - home, _ := os.UserHomeDir() - - if fullQuery == "" { - limit := min(len(dirs), 20) - for i := range limit { - path := dirs[i] - display := strings.Replace(path, home, "~", 1) - zoxideSuggestions = append(zoxideSuggestions, spec.Suggestion{ - Cmd: path, - Desc: display, - }) - } - } else if !strings.Contains(fullQuery, "/") { - searcher := fuzzy.NewPlainSearcher(dirs) - matches := searcher.SearchWithScores(fullQuery, &fuzzy.SearchOptions{Limit: 10}) - for _, m := range matches { - path := m.Str - display := strings.Replace(path, home, "~", 1) - zoxideSuggestions = append(zoxideSuggestions, spec.Suggestion{ - Cmd: path, - Desc: display, - }) - } - } + local := spec.FileGenerator("/")(tokens, prefix, fullQuery) + if strings.ContainsAny(fullQuery, `/\`) { + return local } - var finalResults []spec.Suggestion - seen := make(map[string]bool) - - finalResults = append(finalResults, localSuggestions...) - - for _, s := range zoxideSuggestions { - if !seen[s.Cmd] { - finalResults = append(finalResults, s) - seen[s.Cmd] = true - } + dirs, err := directorySource() + if err != nil { + return local } - return finalResults + external := zoxideSuggestions(dirs, fullQuery) + return mergeDirectorySuggestions(local, external, spec.GetCWD()) } } + +func zoxideSuggestions(dirs []string, query string) []spec.Suggestion { + candidates := dirs + limit := zoxideEmptyLimit + if query != "" { + candidates = matchedZoxideDirectories(dirs, query) + limit = zoxideSearchLimit + } + + home, _ := os.UserHomeDir() + results := make([]spec.Suggestion, 0, min(limit, len(candidates))) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + path := filepath.Clean(candidate) + if !filepath.IsAbs(path) { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + continue + } + + results = append(results, spec.Suggestion{ + Cmd: path, + Desc: displayDirectory(path, home), + Priority: 40, + }) + if len(results) == limit { + break + } + } + + return results +} + +func matchedZoxideDirectories(dirs []string, query string) []string { + if len(dirs) == 0 { + return nil + } + + results := make([]string, 0, len(dirs)) + seen := make(map[string]struct{}, len(dirs)) + lowerQuery := strings.ToLower(query) + + for _, dir := range dirs { + if strings.Contains(strings.ToLower(dir), lowerQuery) { + results = append(results, dir) + seen[dir] = struct{}{} + } + } + + matches := fuzzy.NewSearcher(dirs).SearchWithScores(query, &fuzzy.SearchOptions{Limit: len(dirs)}) + for _, match := range matches { + if _, ok := seen[match.Str]; ok { + continue + } + results = append(results, match.Str) + seen[match.Str] = struct{}{} + } + + return results +} + +func mergeDirectorySuggestions(local, external []spec.Suggestion, cwd string) []spec.Suggestion { + results := make([]spec.Suggestion, 0, len(local)+len(external)) + seen := make(map[string]struct{}, len(local)+len(external)) + + add := func(suggestion spec.Suggestion) { + key := directoryKey(suggestion.Cmd, cwd) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + results = append(results, suggestion) + } + for _, suggestion := range local { + add(suggestion) + } + for _, suggestion := range external { + add(suggestion) + } + + return results +} + +func directoryKey(path, cwd string) string { + if path == "" { + return "" + } + if !filepath.IsAbs(path) { + path = filepath.Join(cwd, path) + } + return filepath.Clean(path) +} + +func displayDirectory(path, home string) string { + path = filepath.Clean(path) + if home == "" { + return path + } + + rel, err := filepath.Rel(filepath.Clean(home), path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return path + } + if rel == "." { + return "~" + } + return filepath.Join("~", rel) +} diff --git a/commands/fs/zoxide_test.go b/commands/fs/zoxide_test.go new file mode 100644 index 0000000..ff3fe2a --- /dev/null +++ b/commands/fs/zoxide_test.go @@ -0,0 +1,176 @@ +package fs + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/versenilvis/iris/spec" +) + +func TestZoxideSourceCachesDirectoriesAndErrors(t *testing.T) { + now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) + calls := 0 + source := newZoxideSource(func(context.Context) ([]byte, error) { + calls++ + return []byte("/tmp/Project One \n/tmp/Проект\r\n"), nil + }) + source.now = func() time.Time { return now } + + want := []string{"/tmp/Project One ", "/tmp/Проект"} + for range 2 { + got, err := source.directories() + if err != nil { + t.Fatalf("directories returned an error: %v", err) + } + if !slices.Equal(got, want) { + t.Fatalf("directories = %q, want %q", got, want) + } + } + if calls != 1 { + t.Fatalf("query calls = %d, want 1", calls) + } + + now = now.Add(zoxideCacheTTL) + if _, err := source.directories(); err != nil { + t.Fatalf("directories after cache expiry returned an error: %v", err) + } + if calls != 2 { + t.Fatalf("query calls after cache expiry = %d, want 2", calls) + } + + wantErr := errors.New("zoxide unavailable") + errorCalls := 0 + errorSource := newZoxideSource(func(context.Context) ([]byte, error) { + errorCalls++ + return nil, wantErr + }) + if _, err := errorSource.directories(); !errors.Is(err, wantErr) { + t.Fatalf("directories error = %v, want %v", err, wantErr) + } + if _, err := errorSource.directories(); !errors.Is(err, wantErr) { + t.Fatalf("cached directories error = %v, want %v", err, wantErr) + } + if errorCalls != 1 { + t.Fatalf("failed query calls = %d, want 1", errorCalls) + } +} + +func TestZoxideGeneratorMergesValidDirectories(t *testing.T) { + cwd := t.TempDir() + local := filepath.Join(cwd, "project") + external := filepath.Join(t.TempDir(), "My Проект") + missing := filepath.Join(t.TempDir(), "deleted") + mustMkdirAll(t, local) + mustMkdirAll(t, external) + + previousCWD := spec.GetCWD() + spec.SetCWD(cwd) + t.Cleanup(func() { spec.SetCWD(previousCWD) }) + + gen := newZoxideGenerator(func() ([]string, error) { + return []string{local, external, external, missing}, nil + }) + results := gen([]string{"z", ""}, "z ", "") + + if len(results) != 2 { + t.Fatalf("suggestion count = %d, want 2: %#v", len(results), results) + } + if results[0].Cmd != "project/" { + t.Fatalf("first suggestion = %q, want local project/", results[0].Cmd) + } + if results[1].Cmd != external { + t.Fatalf("second suggestion = %q, want %q", results[1].Cmd, external) + } + if results[1].Priority != 40 { + t.Fatalf("external priority = %d, want 40", results[1].Priority) + } +} + +func TestZoxideGeneratorFiltersAndFallsBack(t *testing.T) { + cwd := t.TempDir() + nested := filepath.Join(cwd, "nested", "child") + mustMkdirAll(t, nested) + + previousCWD := spec.GetCWD() + spec.SetCWD(cwd) + t.Cleanup(func() { spec.SetCWD(previousCWD) }) + + calls := 0 + gen := newZoxideGenerator(func() ([]string, error) { + calls++ + return nil, errors.New("zoxide unavailable") + }) + + results := gen([]string{"z", "nested/ch"}, "z ", "nested/ch") + if calls != 0 { + t.Fatalf("source calls for explicit path = %d, want 0", calls) + } + if len(results) != 1 || results[0].Cmd != "nested/child/" { + t.Fatalf("explicit path suggestions = %#v", results) + } + + results = gen([]string{"z", "n"}, "z ", "n") + if calls != 1 { + t.Fatalf("source calls after name query = %d, want 1", calls) + } + if len(results) != 1 || results[0].Cmd != "nested/" { + t.Fatalf("fallback suggestions = %#v", results) + } + + empty := newZoxideGenerator(func() ([]string, error) { return nil, nil }) + if results := empty([]string{"z", "missing"}, "z ", "missing"); len(results) != 0 { + t.Fatalf("empty database suggestions = %#v, want none", results) + } +} + +func TestZoxideGeneratorFuzzyMatchesUnicodeAndLookupQuotesSpaces(t *testing.T) { + cwd := t.TempDir() + target := filepath.Join(t.TempDir(), "My Awesome $Проект") + mustMkdirAll(t, target) + + previousCWD := spec.GetCWD() + spec.SetCWD(cwd) + t.Cleanup(func() { spec.SetCWD(previousCWD) }) + + const command = "zoxide-test-command" + spec.Register(&spec.Spec{ + Name: command, + MaxArgs: 0, + Generator: newZoxideGenerator(func() ([]string, error) { return []string{target}, nil }), + }) + t.Cleanup(func() { delete(spec.Registry, command) }) + + results := spec.Lookup(command + " My Awe") + want := command + ` '` + target + `'` + if len(results) != 1 || results[0].Cmd != want { + t.Fatalf("lookup suggestions = %#v, want command %q", results, want) + } +} + +func TestDisplayDirectoryOnlyShortensHomePrefix(t *testing.T) { + home := filepath.Join(string(os.PathSeparator), "Users", "example") + inside := filepath.Join(home, "Developer", "Проект") + outside := filepath.Join(string(os.PathSeparator), "tmp", "Users", "example", "data") + + if got := displayDirectory(home, home); got != "~" { + t.Fatalf("displayDirectory(home) = %q, want ~", got) + } + if got := displayDirectory(inside, home); got != filepath.Join("~", "Developer", "Проект") { + t.Fatalf("displayDirectory(inside) = %q", got) + } + if got := displayDirectory(outside, home); got != outside { + t.Fatalf("displayDirectory(outside) = %q, want %q", got, outside) + } +} + +func mustMkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0db1300..6cfae2b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,17 +79,7 @@ type KeybindingsConfig struct { } type ZoxideConfig struct { - // ExtendCd adds zoxide's frecency database to `cd` completions. - // - // Without it `cd` only offers direct children of the current directory, - // because that is all the file generator walks -- so `cd proj` finds - // nothing unless you are already standing in the parent. With it, any - // directory zoxide has seen is reachable by name from anywhere, which is - // what `cd` is usually wanted for. - // - // Off by default: it shells out to zoxide, and the suggestions are absolute - // paths from outside the current directory, which is a real change in what - // `cd` offers. + // ExtendCd adds zoxide's frecency database to cd completions. ExtendCd bool `toml:"extend-cd"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4e9c1f1..a704580 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -30,6 +30,9 @@ func TestDefaultConfigAndState(t *testing.T) { if !cfg.Core.CobraProbeEnabled { t.Errorf("expected cobra probing to be enabled by default") } + if cfg.Zoxide.ExtendCd { + t.Errorf("expected zoxide cd extension to be disabled by default") + } // test manual provider registration cfg.AI.Provider = "custom" diff --git a/root/config_cmd.go b/root/config_cmd.go index 11308b7..e656287 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -97,6 +97,10 @@ filter-active-branch = true # merge remote and local branches with same name deduplicate-branches = true +[zoxide] +# include zoxide directories in cd suggestions +extend-cd = false + [updater] # check for updates on startup check-on-startup = true diff --git a/root/config_cmd_test.go b/root/config_cmd_test.go index 7755ee9..409a5d0 100644 --- a/root/config_cmd_test.go +++ b/root/config_cmd_test.go @@ -37,6 +37,9 @@ func TestConfigCommands(t *testing.T) { if !strings.Contains(string(content), "shell-login = false") { t.Error("expected initialized config to include shell-login = false") } + if !strings.Contains(string(content), "extend-cd = false") { + t.Error("expected initialized config to include extend-cd = false") + } buf := new(bytes.Buffer) ConfigShowCmd.SetOut(buf) diff --git a/root/init.go b/root/init.go index 3916d30..4a344f3 100644 --- a/root/init.go +++ b/root/init.go @@ -276,6 +276,10 @@ filter-active-branch = true # merge remote and local branches with same name deduplicate-branches = true +[zoxide] +# include zoxide directories in cd suggestions +extend-cd = false + [updater] # check for updates on startup check-on-startup = true diff --git a/spec/lookup.go b/spec/lookup.go index ecdf8cc..1ba2d32 100644 --- a/spec/lookup.go +++ b/spec/lookup.go @@ -2,6 +2,7 @@ package spec import ( "maps" + "path/filepath" "slices" "strings" "sync" @@ -274,15 +275,15 @@ func Lookup(input string) []Suggestion { } suggested := g.Cmd - if strings.Contains(suggested, " ") && !strings.HasPrefix(suggested, "\"") { - suggested = "\"" + suggested + "\"" + if filepath.IsAbs(g.Cmd) || strings.ContainsAny(g.Cmd, " \t\r\n") { + suggested = quoteSuggestionArgument(g.Cmd) } - // if the suggestion is a full path that includes - // words already in the command line (multi-word support), we replace - // the entire argument part by using prefix only + // Absolute results replace all typed arguments, avoiding duplication + // for multi-word directory matches. finalCmd := "" - if len(tokens) > depth+1 && strings.HasPrefix(g.Cmd, tokens[depth]) { + replaceArguments := strings.HasPrefix(g.Cmd, tokens[depth]) || filepath.IsAbs(g.Cmd) + if len(tokens) > depth+1 && replaceArguments { finalCmd = prefix + " " + suggested } else if genLinePrefix != "" { finalCmd = genLinePrefix + " " + suggested @@ -382,6 +383,21 @@ func Lookup(input string) []Suggestion { return results } +func quoteSuggestionArgument(argument string) string { + prefix := "" + value := argument + if strings.HasPrefix(value, "~/") { + prefix = "~/" + value = strings.TrimPrefix(value, prefix) + } + if !strings.ContainsAny(value, " \t\r\n'\"\\$`!;&|<>()*?[]{}#^") { + return argument + } + + escaped := strings.ReplaceAll(value, "'", `'\''`) + return prefix + "'" + escaped + "'" +} + func topLevelSuggestions(query string, aliases map[string]string) []Suggestion { scanExternalCommands() results, seen := []Suggestion{}, make(map[string]bool) diff --git a/spec/lookup_test.go b/spec/lookup_test.go index bb487fb..3459e93 100644 --- a/spec/lookup_test.go +++ b/spec/lookup_test.go @@ -173,6 +173,23 @@ func TestLookup_NvimFileGenerator(t *testing.T) { } } +func TestQuoteSuggestionArgument(t *testing.T) { + tests := map[string]string{ + "/tmp/project": "/tmp/project", + "/tmp/Проект": "/tmp/Проект", + "/tmp/My Project": `'/tmp/My Project'`, + "/tmp/$Project`command`": "'/tmp/$Project`command`'", + `/tmp/quote"and'apostrophe`: "'/tmp/quote\"and'\\''apostrophe'", + "~/My Project": `~/'My Project'`, + } + + for input, want := range tests { + if got := quoteSuggestionArgument(input); got != want { + t.Errorf("quoteSuggestionArgument(%q) = %q, want %q", input, got, want) + } + } +} + func TestLookup_OptionAndFilePriority(t *testing.T) { ResetRegistry() Register(&Spec{ @@ -389,4 +406,3 @@ func TestLookup_RealGitProvider(t *testing.T) { t.Errorf("expected 'git recent' in results for real GitProvider, got %v", results) } } - diff --git a/tests/commands/zoxide_test.go b/tests/commands/zoxide_test.go index 6428f5e..7b97b61 100644 --- a/tests/commands/zoxide_test.go +++ b/tests/commands/zoxide_test.go @@ -1,58 +1,47 @@ package tests import ( + "fmt" "os" "path/filepath" - "strings" "testing" "github.com/versenilvis/iris/commands/fs" ) func TestZoxideGenerator(t *testing.T) { - // Setup: Create a mock zoxide binary - tmp := t.TempDir() - mockZoxide := filepath.Join(tmp, "zoxide") - - // Script that prints mock directories - script := "#!/bin/sh\necho \"/home/verse/project1\n/home/verse/docs\n/home/verse/dev/iris\"" - _ = os.WriteFile(mockZoxide, []byte(script), 0755) + dataDir := t.TempDir() + project := filepath.Join(dataDir, "project1") + docs := filepath.Join(dataDir, "docs") + iris := filepath.Join(dataDir, "dev", "iris") + missing := filepath.Join(dataDir, "deleted") + for _, path := range []string{project, docs, iris} { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } - t.Setenv("PATH", tmp+string(os.PathListSeparator)+os.Getenv("PATH")) + binDir := t.TempDir() + mockZoxide := filepath.Join(binDir, "zoxide") + script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' %q %q %q %q\n", project, docs, iris, missing) + if err := os.WriteFile(mockZoxide, []byte(script), 0o755); err != nil { + t.Fatalf("create mock zoxide: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) gen := fs.ZoxideGenerator() - - - t.Run("Query returns correct result when partial is empty", func(t *testing.T) { - results := gen([]string{"z", ""}, "z ", "") - if len(results) == 0 { - t.Errorf("Expected results from zoxide history, got 0") + results := gen([]string{"z", ""}, "z ", "") + if len(results) != 3 { + t.Fatalf("suggestion count = %d, want 3: %#v", len(results), results) + } + for _, result := range results { + if result.Cmd == missing { + t.Fatalf("missing directory was suggested: %#v", result) } - }) + } - - t.Run("Path replaces home dir with ~", func(t *testing.T) { - home, _ := os.UserHomeDir() - results := gen([]string{"z", ""}, "z ", "") - foundHome := false - for _, r := range results { - if strings.HasPrefix(r.Desc, "~") { - foundHome = true - break - } - } - if !foundHome && home != "" { - t.Logf("Warning: Did not find ~ in descriptions, home is %s", home) - } - }) - - - t.Run("Sort by descending score", func(t *testing.T) { - results := gen([]string{"z", "i"}, "z ", "i") - if len(results) >= 1 { - if results[0].Cmd == "" { - t.Errorf("Empty result command") - } - } - }) + results = gen([]string{"z", "iris"}, "z ", "iris") + if len(results) != 1 || results[0].Cmd != iris { + t.Fatalf("fuzzy suggestions = %#v, want %q", results, iris) + } }