feat(shortcuts): regex rewrite rules for host shortcuts

shortcuts.rewrites applies regex replacements after the domain suffix
is stripped, e.g. ^([a-z]+)[0-9]+- -> $1 turns fsn1-bx1.hoster.de into
fsnbx1. The shortcut works for connecting (cached hosts), shell
aliases, and Tab completion, where it is offered when the full name
does not match the typed text. Rules are editable in netssh configure,
and netssh alias --test HOSTNAME shows a name's shortcut and the cached
hosts it matches.

The shortcut lookup only ran when domains or strip_hyphens were set;
it now also runs for rewrite-only configurations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 22:42:13 +01:00
1 parent d51c040d9f
commit 878701aba7
12 files changed
+408 -21

No files matched your search

+82 -2
View File
@@ -228,12 +228,92 @@ func TestHostCompletionsMatchTypedText(t *testing.T) {
// 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) {
if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !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"}) {
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) {
t.Errorf("single instance: %v", got)
}
}
var fsnRewrite = config.ShortcutsConfig{
Domains: []string{".hoster.de"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}
func TestHostCompletionsOfferShortcuts(t *testing.T) {
entries := []cache.Entry{
{Name: "prod/fsn1-bx1.hoster.de"},
{Name: "prod/fsn1-db1.hoster.de"},
{Name: "stg/fsn1-db1.hoster.de"},
{Name: "prod/web01.hoster.de"},
}
for _, tc := range []struct {
input string
want []string
}{
{"fsnb", []string{"fsnbx1"}},
// Shortcut shared by two instances stays qualified.
{"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}},
{"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}},
{"stg/fsnd", []string{"stg/fsndb1"}},
// A matching full name is not repeated as shortcut.
{"web", []string{"web01.hoster.de"}},
} {
if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
}
}
// Two hosts of the same instance with one shortcut cannot be selected by it.
same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}}
if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 {
t.Errorf("ambiguous shortcut offered: %v", got)
}
}
func TestShortcutTestOutput(t *testing.T) {
var out bytes.Buffer
printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite)
want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n"
if out.String() != want {
t.Fatalf("got:\n%s", out.String())
}
}
// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable,
// so only the cached host can answer.
func TestWrapperConnectsByRewriteShortcut(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n"
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), 3600)
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "10.0.0.9\n") {
t.Fatalf("shortcut not resolved from cache: %s, %v", out, err)
}
}