diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e95b06..ffee5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.0.22 + +- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names. +- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`. + ## v0.0.21 - Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script. diff --git a/README.md b/README.md index 4466e07..1b157ea 100644 --- a/README.md +++ b/README.md @@ -552,6 +552,10 @@ rm -f ~/.zcompdump* && exec zsh ``` Completions are served from the local cache — no network request on every ``. +With several NetBox instances, a hostname that exists in only one instance is +completed without the instance prefix (`web01`); names that exist in several +instances are offered as `prod/web01`, `staging/web01`. In bash, type the +instance prefix for those; zsh and fish offer them for the hostname alone. The zsh script that `netssh completion zsh` prints is built for large caches: 5000 hosts complete in well under a second. To limit the number of hostnames offered, set `completion.max_results`. diff --git a/cmd/netssh/instances.go b/cmd/netssh/instances.go index fb61f8a..2d23f70 100644 --- a/cmd/netssh/instances.go +++ b/cmd/netssh/instances.go @@ -1,6 +1,10 @@ package main import ( + "sort" + "strings" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts" @@ -30,3 +34,36 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st return name } } + +// hostCompletions returns the cached hosts matching toComplete in a form that +// starts with the typed text, because bash and fish only accept such +// candidates. Without an instance prefix, a hostname that is unique across +// all instances is offered bare ("web01"), so it completes like with a single +// NetBox; connecting to it still checks every instance for ambiguity. +// Names present in several instances stay qualified ("prod/web01"). +func hostCompletions(entries []cache.Entry, toComplete string) []string { + prefix := strings.ToLower(toComplete) + qualifiedInput := strings.Contains(prefix, "/") + instances := make(map[string]int) + for _, e := range entries { + _, name := netbox.SplitTarget(e.Name) + instances[strings.ToLower(name)]++ + } + var out []string + for _, e := range entries { + source, name := netbox.SplitTarget(e.Name) + target := strings.ToLower(e.Name) + switch { + case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix): + if instances[strings.ToLower(name)] == 1 { + out = append(out, name) + } else { + out = append(out, e.Name) + } + case strings.HasPrefix(target, prefix): + out = append(out, e.Name) + } + } + sort.Strings(out) + return out +} diff --git a/cmd/netssh/instances_test.go b/cmd/netssh/instances_test.go index a626c69..02955c6 100644 --- a/cmd/netssh/instances_test.go +++ b/cmd/netssh/instances_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" "time" @@ -206,3 +207,33 @@ func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) { } } } + +func TestHostCompletionsMatchTypedText(t *testing.T) { + entries := []cache.Entry{ + {Name: "prod/fsn1-bx1.example.com"}, + {Name: "prod/fsn1-bx2.example.com"}, + {Name: "stg/fsn1-bx2.example.com"}, + {Name: "stg/hel1-tb1.example.com"}, + {Name: "prod/HEL1-TB2"}, + } + for _, tc := range []struct { + input string + want []string + }{ + // Unique names are bare; names in both instances stay qualified. + {"fsn1", []string{"fsn1-bx1.example.com", "prod/fsn1-bx2.example.com", "stg/fsn1-bx2.example.com"}}, + {"hel1", []string{"HEL1-TB2", "hel1-tb1.example.com"}}, + {"prod/", []string{"prod/HEL1-TB2", "prod/fsn1-bx1.example.com", "prod/fsn1-bx2.example.com"}}, + {"stg/fsn", []string{"stg/fsn1-bx2.example.com"}}, + // An instance name typed without the slash completes qualified targets. + {"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}}, + } { + if got := hostCompletions(entries, tc.input); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%q: got %v, want %v", tc.input, got, tc.want) + } + } + // Single-instance caches are unchanged. + if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !reflect.DeepEqual(got, []string{"web01"}) { + t.Errorf("single instance: %v", got) + } +} diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index fbd7f7d..b8af0aa 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -339,14 +339,13 @@ func rootCmd() *cobra.Command { c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() - hosts := c.Search(toComplete) - sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name }) + hosts := hostCompletions(c.All(), toComplete) if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { hosts = hosts[:limit] } completions := make([]cobra.Completion, 0, len(hosts)) - for _, e := range hosts { - completions = append(completions, cobra.Completion(e.Name)) + for _, host := range hosts { + completions = append(completions, cobra.Completion(host)) } // Subcommands at the end, after all hostnames. for _, sub := range cmd.Commands() {