diff --git a/.gitignore b/.gitignore index f40fea8d..83f638d1 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ claws.log # Node.js node_modules/ +.claude diff --git a/custom/ecs/tasks/render.go b/custom/ecs/tasks/render.go index 1c7fa2b1..1bc11a4a 100644 --- a/custom/ecs/tasks/render.go +++ b/custom/ecs/tasks/render.go @@ -318,8 +318,7 @@ func (r *TaskRenderer) Navigations(resource dao.Resource) []render.Navigation { } // If task is part of a service, add service navigation - if group := task.Group(); strings.HasPrefix(group, "service:") { - serviceName := strings.TrimPrefix(group, "service:") + if serviceName, ok := strings.CutPrefix(task.Group(), "service:"); ok { navs = append(navs, render.Navigation{ Key: "s", Label: "Service", diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 50c18bf7..289fe3ad 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "slices" + "strings" "sync" "github.com/clawscli/claws/internal/dao" @@ -53,6 +54,12 @@ type Registry struct { aliases map[string]string // alias -> service name or service/resource displayNames map[string]string // service -> display name for UI categories []ServiceCategory // ordered list of service categories + + // Cached computed values (aliases are immutable after init, safe to cache) + aliasListOnce sync.Once // guards aliasListCache initialization + aliasListCache []string // cached result of GetAliases() + serviceAliasesOnce sync.Once // guards serviceAliasesCache initialization + serviceAliasesCache map[string][]string // cached result of GetAliasesForService() by service } // New creates a new Registry @@ -281,20 +288,43 @@ func (r *Registry) ResolveAlias(input string) (string, string, bool) { return input, "", false } -// GetAliasesForService returns all aliases for a given service +// GetAliasesForService returns all aliases for a given service. func (r *Registry) GetAliasesForService(service string) []string { - r.mu.RLock() - defer r.mu.RUnlock() + r.serviceAliasesOnce.Do(func() { + r.mu.RLock() + defer r.mu.RUnlock() + + r.serviceAliasesCache = make(map[string][]string) + for alias, target := range r.aliases { + svc := target + if idx := strings.Index(target, "/"); idx != -1 { + svc = target[:idx] + } + r.serviceAliasesCache[svc] = append(r.serviceAliasesCache[svc], alias) + } + for svc := range r.serviceAliasesCache { + slices.Sort(r.serviceAliasesCache[svc]) + } + }) + return slices.Clone(r.serviceAliasesCache[service]) +} + +// GetAliases returns all aliases (excluding self-referential ones like "sfn" -> "sfn"). +func (r *Registry) GetAliases() []string { + r.aliasListOnce.Do(func() { + r.mu.RLock() + defer r.mu.RUnlock() - var aliases []string - for alias, target := range r.aliases { - // Check if target matches service (handle "service" or "service/resource") - if target == service || (len(target) > len(service) && target[:len(service)] == service && target[len(service)] == '/') { - aliases = append(aliases, alias) + var aliases []string + for alias, target := range r.aliases { + if alias != target { + aliases = append(aliases, alias) + } } - } - slices.Sort(aliases) - return aliases + slices.Sort(aliases) + r.aliasListCache = aliases + }) + return slices.Clone(r.aliasListCache) } // RegisterCustom registers a custom (hand-written) implementation diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index c5c135da..a28aca31 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -2,6 +2,7 @@ package registry import ( "context" + "sync" "testing" "github.com/clawscli/claws/internal/dao" @@ -259,6 +260,74 @@ func TestRegistry_GetAliasesForService_WithResourceAlias(t *testing.T) { } } +func TestRegistry_GetAliases(t *testing.T) { + reg := New() + aliases := reg.GetAliases() + + if len(aliases) == 0 { + t.Fatal("GetAliases() should return aliases") + } + + aliasMap := make(map[string]bool) + for _, a := range aliases { + aliasMap[a] = true + } + + for _, expected := range []string{"cfn", "cf", "sg", "cost-explorer"} { + if !aliasMap[expected] { + t.Errorf("GetAliases() should include %q", expected) + } + } +} + +func TestRegistry_GetAliases_ExcludesSelfReferential(t *testing.T) { + reg := New() + aliases := reg.GetAliases() + + for _, alias := range aliases { + resolved, _, found := reg.ResolveAlias(alias) + if found && alias == resolved { + t.Errorf("GetAliases() should exclude self-referential alias %q", alias) + } + } +} + +func TestRegistry_GetAliases_ConcurrentAccess(t *testing.T) { + reg := New() + var wg sync.WaitGroup + const goroutines = 100 + + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + aliases := reg.GetAliases() + if len(aliases) == 0 { + t.Error("GetAliases() should return aliases") + } + }() + } + wg.Wait() +} + +func TestRegistry_GetAliasesForService_ConcurrentAccess(t *testing.T) { + reg := New() + var wg sync.WaitGroup + const goroutines = 100 + + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + aliases := reg.GetAliasesForService("cloudformation") + if len(aliases) != 2 { + t.Errorf("GetAliasesForService() returned %d aliases, want 2", len(aliases)) + } + }() + } + wg.Wait() +} + func TestRegistry_GetDAO_NotRegistered(t *testing.T) { reg := New() diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 06db304f..1c29a76b 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -3,6 +3,7 @@ package view import ( "context" "fmt" + "slices" "strings" "charm.land/bubbles/v2/textinput" @@ -228,63 +229,58 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { return nil, &NavigateMsg{View: browser, ClearStack: true} } - // Handle sort command: :sort, :sort , :sort desc - if input == "sort" || strings.HasPrefix(input, "sort ") { - return c.parseSortCommand(input), nil + // Handle sort command: :sort (clear) or :sort (sort by column) + if input == "sort" { + return func() tea.Msg { + return SortMsg{Column: "", Ascending: true} + }, nil + } + if suffix, ok := strings.CutPrefix(input, "sort "); ok { + return c.parseSortArgs(suffix), nil } - if input == "login" || strings.HasPrefix(input, "login ") { - profileName := "claws-login" - if strings.HasPrefix(input, "login ") { - if name := strings.TrimSpace(strings.TrimPrefix(input, "login ")); name != "" { - if !config.IsValidProfileName(name) { - return func() tea.Msg { - return ErrorMsg{Err: fmt.Errorf("invalid profile name: %s", name)} - }, nil - } - profileName = name - } + // Handle login command: :login (default) or :login + if input == "login" { + return c.executeLogin("claws-login"), nil + } + if suffix, ok := strings.CutPrefix(input, "login "); ok { + profileName := strings.TrimSpace(suffix) + if profileName == "" { + return c.executeLogin("claws-login"), nil } - exec := &action.SimpleExec{ - Command: fmt.Sprintf("aws login --remote --profile %s", profileName), - ActionName: action.ActionNameLogin, - SkipAWSEnv: true, + if !config.IsValidProfileName(profileName) { + return func() tea.Msg { + return ErrorMsg{Err: fmt.Errorf("invalid profile name: %q", profileName)} + }, nil } - return tea.Exec(exec, func(err error) tea.Msg { - if err != nil { - return ErrorMsg{Err: err} - } - sel := config.NamedProfile(profileName) - config.Global().SetSelections([]config.ProfileSelection{sel}) - return navmsg.ProfilesChangedMsg{Selections: []config.ProfileSelection{sel}} - }), nil + return c.executeLogin(profileName), nil } - // Handle tag command: :tag - filter current view by tag - if input == "tag" || strings.HasPrefix(input, "tag ") { - tagFilter := "" - if strings.HasPrefix(input, "tag ") { - tagFilter = strings.TrimPrefix(input, "tag ") - } + // Handle tag command: :tag (clear) or :tag (filter by tag) + if input == "tag" { + return func() tea.Msg { + return TagFilterMsg{Filter: ""} + }, nil + } + if tagFilter, ok := strings.CutPrefix(input, "tag "); ok { return func() tea.Msg { return TagFilterMsg{Filter: tagFilter} }, nil } - // Handle tags command: :tags, :tags - cross-service tag search via Tagging API - if input == "tags" || strings.HasPrefix(input, "tags ") { - tagFilter := "" - if strings.HasPrefix(input, "tags ") { - tagFilter = strings.TrimPrefix(input, "tags ") - } + // Handle tags command: :tags (all) or :tags (cross-service tag search) + if input == "tags" { + browser := NewTagSearchView(c.ctx, c.registry, "") + return nil, &NavigateMsg{View: browser} + } + if tagFilter, ok := strings.CutPrefix(input, "tags "); ok { browser := NewTagSearchView(c.ctx, c.registry, tagFilter) return nil, &NavigateMsg{View: browser} } // Handle diff command: :diff or :diff - if strings.HasPrefix(input, "diff ") { - args := strings.TrimSpace(strings.TrimPrefix(input, "diff ")) - parts := strings.Fields(args) + if suffix, ok := strings.CutPrefix(input, "diff "); ok { + parts := strings.Fields(suffix) if len(parts) == 1 { // :diff - compare current row with named resource return func() tea.Msg { @@ -356,55 +352,56 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { return nil, nil } -// parseSortCommand parses the sort command and returns a SortMsg command -// Syntax: :sort, :sort , :sort desc -func (c *CommandInput) parseSortCommand(input string) tea.Cmd { - // :sort - clear sorting - if input == "sort" { - return func() tea.Msg { - return SortMsg{Column: "", Ascending: true} - } - } - - // Parse arguments - args := strings.TrimPrefix(input, "sort ") +func (c *CommandInput) parseSortArgs(args string) tea.Cmd { ascending := true column := args - // Check for "desc" prefix - if strings.HasPrefix(args, "desc ") { + if col, ok := strings.CutPrefix(args, "desc "); ok { ascending = false - column = strings.TrimPrefix(args, "desc ") - } else if strings.HasPrefix(args, "asc ") { - ascending = true - column = strings.TrimPrefix(args, "asc ") + column = col + } else if col, ok := strings.CutPrefix(args, "asc "); ok { + column = col } - column = strings.TrimSpace(column) - return func() tea.Msg { - return SortMsg{Column: column, Ascending: ascending} + return SortMsg{Column: strings.TrimSpace(column), Ascending: ascending} } } +func (c *CommandInput) executeLogin(profileName string) tea.Cmd { + exec := &action.SimpleExec{ + Command: fmt.Sprintf("aws login --remote --profile %s", profileName), + ActionName: action.ActionNameLogin, + SkipAWSEnv: true, + } + return tea.Exec(exec, func(err error) tea.Msg { + if err != nil { + return ErrorMsg{Err: err} + } + sel := config.NamedProfile(profileName) + config.Global().SetSelections([]config.ProfileSelection{sel}) + return navmsg.ProfilesChangedMsg{Selections: []config.ProfileSelection{sel}} + }) +} + // GetSuggestions returns command suggestions based on current input func (c *CommandInput) GetSuggestions() []string { input := c.textInput.Value() var suggestions []string // Handle :tag command completion - if strings.HasPrefix(input, "tag ") { - return c.getTagSuggestions("tag ", strings.TrimPrefix(input, "tag ")) + if suffix, ok := strings.CutPrefix(input, "tag "); ok { + return c.getTagSuggestions("tag ", suffix) } // Handle :tags command completion (same as :tag) - if strings.HasPrefix(input, "tags ") { - return c.getTagSuggestions("tags ", strings.TrimPrefix(input, "tags ")) + if suffix, ok := strings.CutPrefix(input, "tags "); ok { + return c.getTagSuggestions("tags ", suffix) } // Handle :diff command completion - if strings.HasPrefix(input, "diff ") { - return c.getDiffSuggestions(strings.TrimPrefix(input, "diff ")) + if suffix, ok := strings.CutPrefix(input, "diff "); ok { + return c.getDiffSuggestions(suffix) } if strings.Contains(input, "/") { @@ -462,40 +459,51 @@ func (c *CommandInput) GetSuggestions() []string { suggestions = append(suggestions, svc) } } + + for _, alias := range c.registry.GetAliases() { + if strings.HasPrefix(alias, input) { + suggestions = append(suggestions, alias) + } + } + + slices.Sort(suggestions) } return suggestions } -// getDiffSuggestions returns resource name suggestions for diff command -// Supports: :diff and :diff func (c *CommandInput) getDiffSuggestions(args string) []string { if c.diffProvider == nil { return nil } - var suggestions []string names := c.diffProvider.GetResourceNames() - - // Check if we're completing the second name (has space after first name) parts := strings.SplitN(args, " ", 2) + if len(parts) == 2 { - // Completing second name: "diff name1 " firstName := parts[0] secondPrefix := strings.ToLower(parts[1]) + + var filtered []string for _, name := range names { - if name != firstName && (secondPrefix == "" || strings.Contains(strings.ToLower(name), secondPrefix)) { - suggestions = append(suggestions, "diff "+firstName+" "+name) + if name != firstName { + filtered = append(filtered, name) } } - } else { - // Completing first name: "diff " - prefix := strings.ToLower(args) - for _, name := range names { - if prefix == "" || strings.Contains(strings.ToLower(name), prefix) { - suggestions = append(suggestions, "diff "+name) - } + + matched := matchNamesWithFallback(filtered, secondPrefix) + var suggestions []string + for _, name := range matched { + suggestions = append(suggestions, "diff "+firstName+" "+name) } + return suggestions + } + + prefix := strings.ToLower(args) + matched := matchNamesWithFallback(names, prefix) + var suggestions []string + for _, name := range matched { + suggestions = append(suggestions, "diff "+name) } return suggestions } diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index 197f952b..a5643b17 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -94,6 +94,46 @@ func TestCommandInput_GetSuggestions(t *testing.T) { } } +func TestCommandInput_GetSuggestions_Aliases(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + reg.RegisterCustom("costexplorer", "costs", registry.Entry{}) + reg.RegisterCustom("cloudformation", "stacks", registry.Entry{}) + + ci := NewCommandInput(ctx, reg) + ci.Activate() + + tests := []struct { + input string + expected []string + }{ + {"cost", []string{"costexplorer", "cost-explorer"}}, + {"cf", []string{"cf", "cfn"}}, + {"cfn", []string{"cfn"}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + ci.textInput.SetValue(tt.input) + suggestions := ci.GetSuggestions() + + for _, exp := range tt.expected { + found := false + for _, s := range suggestions { + if s == exp { + found = true + break + } + } + if !found { + t.Errorf("Expected %q in suggestions for %q, got %v", exp, tt.input, suggestions) + } + } + }) + } +} + func TestCommandInput_SetWidth(t *testing.T) { ctx := context.Background() reg := registry.New() @@ -221,22 +261,46 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { want: nil, }, { - name: "empty args returns all", + name: "empty args returns all sorted", provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, args: "", - want: []string{"diff web-server", "diff db-server", "diff cache"}, + want: []string{"diff cache", "diff db-server", "diff web-server"}, + }, + { + name: "prefix match", + provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + args: "web", + want: []string{"diff web-server"}, + }, + { + name: "prefix match multiple sorted", + provider: &mockDiffProvider{names: []string{"web-server", "web-api", "db-server"}}, + args: "web", + want: []string{"diff web-api", "diff web-server"}, }, { - name: "first name prefix filter", + name: "fuzzy fallback when no prefix sorted", provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, args: "server", - want: []string{"diff web-server", "diff db-server"}, + want: []string{"diff db-server", "diff web-server"}, }, { - name: "case insensitive match", + name: "fuzzy match pattern", + provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + args: "wsr", + want: []string{"diff web-server"}, + }, + { + name: "case insensitive prefix", + provider: &mockDiffProvider{names: []string{"Web-Server", "DB-Server", "Cache"}}, + args: "WEB", + want: []string{"diff Web-Server"}, + }, + { + name: "case insensitive fuzzy sorted", provider: &mockDiffProvider{names: []string{"Web-Server", "DB-Server", "Cache"}}, args: "SERVER", - want: []string{"diff Web-Server", "diff DB-Server"}, + want: []string{"diff DB-Server", "diff Web-Server"}, }, { name: "no match returns empty", @@ -245,10 +309,10 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { want: nil, }, { - name: "second name completion excludes first", + name: "second name completion excludes first sorted", provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, args: "web-server ", - want: []string{"diff web-server db-server", "diff web-server cache"}, + want: []string{"diff web-server cache", "diff web-server db-server"}, }, { name: "second name with prefix", @@ -256,6 +320,12 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { args: "web-server db", want: []string{"diff web-server db-server"}, }, + { + name: "second name fuzzy fallback", + provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + args: "web-server sr", + want: []string{"diff web-server db-server"}, + }, { name: "second name no match", provider: &mockDiffProvider{names: []string{"web-server", "db-server"}}, @@ -278,7 +348,7 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { name: "single resource for second - no suggestions", provider: &mockDiffProvider{names: []string{"only-one"}}, args: "only-one ", - want: nil, // can't diff with self + want: nil, }, } diff --git a/internal/view/match.go b/internal/view/match.go new file mode 100644 index 00000000..b1769fc7 --- /dev/null +++ b/internal/view/match.go @@ -0,0 +1,51 @@ +package view + +import ( + "slices" + "strings" +) + +// fuzzyMatch checks if pattern characters appear in order in str (case insensitive) +func fuzzyMatch(str, pattern string) bool { + str = strings.ToLower(str) + pattern = strings.ToLower(pattern) + pi := 0 + for i := 0; i < len(str) && pi < len(pattern); i++ { + if str[i] == pattern[pi] { + pi++ + } + } + return pi == len(pattern) +} + +// matchNamesWithFallback returns names matching the pattern. +// It first tries prefix matching, then falls back to fuzzy matching if no prefix matches. +func matchNamesWithFallback(names []string, pattern string) []string { + if pattern == "" { + result := slices.Clone(names) + slices.Sort(result) + return result + } + + pattern = strings.ToLower(pattern) + + var prefixMatches []string + for _, name := range names { + if strings.HasPrefix(strings.ToLower(name), pattern) { + prefixMatches = append(prefixMatches, name) + } + } + if len(prefixMatches) > 0 { + slices.Sort(prefixMatches) + return prefixMatches + } + + var fuzzyMatches []string + for _, name := range names { + if fuzzyMatch(name, pattern) { + fuzzyMatches = append(fuzzyMatches, name) + } + } + slices.Sort(fuzzyMatches) + return fuzzyMatches +} diff --git a/internal/view/match_test.go b/internal/view/match_test.go new file mode 100644 index 00000000..abab4beb --- /dev/null +++ b/internal/view/match_test.go @@ -0,0 +1,113 @@ +package view + +import ( + "slices" + "testing" +) + +func TestFuzzyMatch(t *testing.T) { + tests := []struct { + str string + pattern string + want bool + }{ + {"AgentCoreStackdev", "agecrstdev", true}, + {"AgentCoreStackdev", "agent", true}, + {"AgentCoreStackdev", "acd", true}, + {"AgentCoreStackdev", "xyz", false}, + {"AgentCoreStackdev", "deva", false}, + {"i-1234567890abcdef0", "i1234", true}, + {"i-1234567890abcdef0", "abcdef", true}, + {"production", "prod", true}, + {"production", "pdn", true}, + {"", "a", false}, + {"abc", "", true}, + // uppercase pattern - case insensitive + {"production", "PROD", true}, + {"AgentCoreStackdev", "ACD", true}, + {"web-server", "WEB", true}, + } + + for _, tt := range tests { + t.Run(tt.str+"_"+tt.pattern, func(t *testing.T) { + got := fuzzyMatch(tt.str, tt.pattern) + if got != tt.want { + t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.str, tt.pattern, got, tt.want) + } + }) + } +} + +func TestMatchNamesWithFallback(t *testing.T) { + tests := []struct { + name string + names []string + pattern string + want []string + }{ + { + name: "empty pattern returns all sorted", + names: []string{"web-server", "db-server", "cache"}, + pattern: "", + want: []string{"cache", "db-server", "web-server"}, + }, + { + name: "prefix match single", + names: []string{"web-server", "db-server", "cache"}, + pattern: "web", + want: []string{"web-server"}, + }, + { + name: "prefix match multiple", + names: []string{"web-server", "web-api", "db-server"}, + pattern: "web", + want: []string{"web-api", "web-server"}, + }, + { + name: "fuzzy fallback when no prefix", + names: []string{"web-server", "db-server", "cache"}, + pattern: "server", + want: []string{"db-server", "web-server"}, + }, + { + name: "fuzzy match pattern", + names: []string{"web-server", "db-server", "cache"}, + pattern: "wsr", + want: []string{"web-server"}, + }, + { + name: "case insensitive prefix lowercase pattern", + names: []string{"Web-Server", "DB-Server", "Cache"}, + pattern: "web", + want: []string{"Web-Server"}, + }, + { + name: "case insensitive prefix uppercase pattern", + names: []string{"web-server", "web-api", "db-server"}, + pattern: "WEB", + want: []string{"web-api", "web-server"}, + }, + { + name: "no match returns empty", + names: []string{"web-server", "db-server"}, + pattern: "xyz", + want: nil, + }, + { + name: "empty names", + names: []string{}, + pattern: "web", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchNamesWithFallback(tt.names, tt.pattern) + if !slices.Equal(got, tt.want) { + t.Errorf("matchNamesWithFallback(%v, %q) = %v, want %v", + tt.names, tt.pattern, got, tt.want) + } + }) + } +} diff --git a/internal/view/resource_browser_filter.go b/internal/view/resource_browser_filter.go index 4c143e40..23ad866d 100644 --- a/internal/view/resource_browser_filter.go +++ b/internal/view/resource_browser_filter.go @@ -193,15 +193,3 @@ func getFieldValue(data any, fieldName string) string { return fmt.Sprintf("%v", field.Interface()) } } - -// fuzzyMatch checks if pattern characters appear in order in str (case insensitive) -func fuzzyMatch(str, pattern string) bool { - str = strings.ToLower(str) - pi := 0 - for i := 0; i < len(str) && pi < len(pattern); i++ { - if str[i] == pattern[pi] { - pi++ - } - } - return pi == len(pattern) -} diff --git a/internal/view/resource_browser_sort.go b/internal/view/resource_browser_sort.go index ce02cb15..242aa524 100644 --- a/internal/view/resource_browser_sort.go +++ b/internal/view/resource_browser_sort.go @@ -90,8 +90,8 @@ func parseNumeric(s string) (float64, error) { } for suffix, mult := range suffixes { - if strings.HasSuffix(s, suffix) { - s = strings.TrimSuffix(s, suffix) + if before, ok := strings.CutSuffix(s, suffix); ok { + s = before multiplier = mult break } diff --git a/internal/view/service_browser_test.go b/internal/view/service_browser_test.go index 35b06d10..15d989fe 100644 --- a/internal/view/service_browser_test.go +++ b/internal/view/service_browser_test.go @@ -140,35 +140,6 @@ func TestServiceBrowserCategoryNavigation(t *testing.T) { } } -func TestFuzzyMatch(t *testing.T) { - tests := []struct { - str string - pattern string - want bool - }{ - {"AgentCoreStackdev", "agecrstdev", true}, - {"AgentCoreStackdev", "agent", true}, - {"AgentCoreStackdev", "acd", true}, - {"AgentCoreStackdev", "xyz", false}, - {"AgentCoreStackdev", "deva", false}, // order matters - {"i-1234567890abcdef0", "i1234", true}, - {"i-1234567890abcdef0", "abcdef", true}, - {"production", "prod", true}, - {"production", "pdn", true}, - {"", "a", false}, - {"abc", "", true}, // empty pattern matches everything - } - - for _, tt := range tests { - t.Run(tt.str+"_"+tt.pattern, func(t *testing.T) { - got := fuzzyMatch(tt.str, tt.pattern) - if got != tt.want { - t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.str, tt.pattern, got, tt.want) - } - }) - } -} - func TestServiceBrowserMouseHover(t *testing.T) { ctx := context.Background() reg := registry.New()