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:
1 parent
d51c040d9f
commit
878701aba7
12 files changed
+408
-21
No files matched your search
@@ -5,6 +5,8 @@
|
|||||||
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
|
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
|
||||||
- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving.
|
- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving.
|
||||||
- The configurator validates SSH options and rules before writing the config.
|
- The configurator validates SSH options and rules before writing the config.
|
||||||
|
- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`.
|
||||||
|
- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches.
|
||||||
|
|
||||||
## v0.0.22
|
## v0.0.22
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ netssh -p 2222 admin@app-server-03 uptime
|
|||||||
- **Shell completion** — install without sudo via `netssh completion install`
|
- **Shell completion** — install without sudo via `netssh completion install`
|
||||||
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
||||||
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
|
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
|
||||||
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
|
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`)
|
||||||
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
|
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
|
||||||
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
||||||
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
|
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
|
||||||
@@ -115,7 +115,7 @@ A menu then gives access to every other setting; open only the sections you want
|
|||||||
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line) |
|
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line) |
|
||||||
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
|
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
|
||||||
| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file |
|
| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file |
|
||||||
| Shortcuts & command alias | domain suffixes, hyphen stripping, `shell.alias` |
|
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
|
||||||
| Completion | `completion.max_results` |
|
| Completion | `completion.max_results` |
|
||||||
|
|
||||||
The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing.
|
The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing.
|
||||||
@@ -366,13 +366,26 @@ shortcuts:
|
|||||||
- .example.com
|
- .example.com
|
||||||
- .example.de
|
- .example.de
|
||||||
strip_hyphens: false # optional
|
strip_hyphens: false # optional
|
||||||
|
rewrites: # optional, applied in order
|
||||||
|
- pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1
|
||||||
|
replace: '$1'
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolution order:
|
Resolution order:
|
||||||
|
|
||||||
1. **Exact match** — the input is looked up as-is in the cache.
|
1. **Exact match** — the input is looked up as-is in the cache.
|
||||||
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
||||||
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
|
3. **Shortcut match** — the input and every cached hostname are shortened the same way and compared: lowercase, strip the domain suffix, apply each rewrite rule in order, then remove hyphens if `strip_hyphens` is set (`fsn1web01` → `fsn1-web01.example.com`, `fsnbx1` → `fsn1-bx1.hoster.de`). This only works when the host is already cached; run `netssh cache refresh` first.
|
||||||
|
|
||||||
|
**Rewrite rules** use Go regular expressions ([syntax](https://pkg.go.dev/regexp/syntax)); `replace` may refer to groups as `$1` or `${1}`. Every match in the name is replaced. Because the typed shortcut is rewritten too, write rules that leave an already shortened name unchanged: `^([a-z]+)[0-9]+-` matches `fsn1-bx1` but not `fsnbx1`. Shortcuts also become shell aliases and are offered by Tab completion when the full name does not match what you typed (`fsnb<Tab>` → `fsnbx1`).
|
||||||
|
|
||||||
|
Check what a hostname is shortened to, and whether the shortcut is unique in the cache:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ netssh alias --test fsn1-bx1.hoster.de
|
||||||
|
fsn1-bx1.hoster.de → fsnbx1
|
||||||
|
matches fsn1-bx1.hoster.de
|
||||||
|
```
|
||||||
|
|
||||||
Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names.
|
Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names.
|
||||||
|
|
||||||
@@ -380,6 +393,7 @@ Domains may be configured with or without a leading dot. If several cached hosts
|
|||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
||||||
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
|
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
|
||||||
|
| `fsnbx1` | `domains: [.hoster.de]`, rewrite `^([a-z]+)[0-9]+-` → `$1` | `fsn1-bx1.hoster.de` (cache only) |
|
||||||
|
|
||||||
### Shell aliases
|
### Shell aliases
|
||||||
|
|
||||||
|
|||||||
+56
-6
@@ -41,7 +41,9 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
|
|||||||
// all instances is offered bare ("web01"), so it completes like with a single
|
// all instances is offered bare ("web01"), so it completes like with a single
|
||||||
// NetBox; connecting to it still checks every instance for ambiguity.
|
// NetBox; connecting to it still checks every instance for ambiguity.
|
||||||
// Names present in several instances stay qualified ("prod/web01").
|
// Names present in several instances stay qualified ("prod/web01").
|
||||||
func hostCompletions(entries []cache.Entry, toComplete string) []string {
|
// Hosts whose full name does not match are offered by their configured
|
||||||
|
// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com).
|
||||||
|
func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string {
|
||||||
prefix := strings.ToLower(toComplete)
|
prefix := strings.ToLower(toComplete)
|
||||||
qualifiedInput := strings.Contains(prefix, "/")
|
qualifiedInput := strings.Contains(prefix, "/")
|
||||||
instances := make(map[string]int)
|
instances := make(map[string]int)
|
||||||
@@ -49,21 +51,69 @@ func hostCompletions(entries []cache.Entry, toComplete string) []string {
|
|||||||
_, name := netbox.SplitTarget(e.Name)
|
_, name := netbox.SplitTarget(e.Name)
|
||||||
instances[strings.ToLower(name)]++
|
instances[strings.ToLower(name)]++
|
||||||
}
|
}
|
||||||
var out []string
|
var unmatched []cache.Entry
|
||||||
|
var names []string
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
source, name := netbox.SplitTarget(e.Name)
|
source, name := netbox.SplitTarget(e.Name)
|
||||||
target := strings.ToLower(e.Name)
|
target := strings.ToLower(e.Name)
|
||||||
switch {
|
switch {
|
||||||
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
|
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
|
||||||
if instances[strings.ToLower(name)] == 1 {
|
if instances[strings.ToLower(name)] == 1 {
|
||||||
out = append(out, name)
|
names = append(names, name)
|
||||||
} else {
|
} else {
|
||||||
out = append(out, e.Name)
|
names = append(names, e.Name)
|
||||||
}
|
}
|
||||||
case strings.HasPrefix(target, prefix):
|
case strings.HasPrefix(target, prefix):
|
||||||
out = append(out, e.Name)
|
names = append(names, e.Name)
|
||||||
|
default:
|
||||||
|
unmatched = append(unmatched, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc.Enabled() {
|
||||||
|
names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcutCompletions offers the shortcut of each unmatched host when it starts
|
||||||
|
// with prefix and selects exactly one host, qualified by instance if needed.
|
||||||
|
func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string {
|
||||||
|
bare := make(map[string]int)
|
||||||
|
qualified := make(map[string]int)
|
||||||
|
short := func(e cache.Entry) (string, string) {
|
||||||
|
source, name := netbox.SplitTarget(e.Name)
|
||||||
|
s := shortcuts.Normalize(name, sc)
|
||||||
|
if source == "" {
|
||||||
|
return s, s
|
||||||
|
}
|
||||||
|
return s, strings.ToLower(source) + "/" + s
|
||||||
|
}
|
||||||
|
for _, e := range all {
|
||||||
|
b, q := short(e)
|
||||||
|
bare[b]++
|
||||||
|
qualified[q]++
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
for _, e := range unmatched {
|
||||||
|
b, q := short(e)
|
||||||
|
candidate := ""
|
||||||
|
switch {
|
||||||
|
case strings.Contains(prefix, "/"):
|
||||||
|
if qualified[q] == 1 && strings.HasPrefix(q, prefix) {
|
||||||
|
candidate = q
|
||||||
|
}
|
||||||
|
case !strings.HasPrefix(b, prefix):
|
||||||
|
case bare[b] == 1:
|
||||||
|
candidate = b
|
||||||
|
case q != b && qualified[q] == 1:
|
||||||
|
candidate = q // like full names: zsh accepts it, bash/fish ignore it
|
||||||
|
}
|
||||||
|
if candidate != "" && !seen[candidate] {
|
||||||
|
seen[candidate] = true
|
||||||
|
out = append(out, candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -228,12 +228,92 @@ func TestHostCompletionsMatchTypedText(t *testing.T) {
|
|||||||
// An instance name typed without the slash completes qualified targets.
|
// An instance name typed without the slash completes qualified targets.
|
||||||
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
|
{"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)
|
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Single-instance caches are unchanged.
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-4
@@ -132,7 +132,7 @@ func runSSHWrapper(args []string) {
|
|||||||
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
||||||
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||||
lookupHost := parsed.Host
|
lookupHost := parsed.Host
|
||||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
shortcutsEnabled := cfg.Shortcuts.Enabled()
|
||||||
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||||
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||||
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
||||||
@@ -339,7 +339,7 @@ func rootCmd() *cobra.Command {
|
|||||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
hosts := hostCompletions(c.All(), toComplete)
|
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
|
||||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||||
hosts = hosts[:limit]
|
hosts = hosts[:limit]
|
||||||
}
|
}
|
||||||
@@ -645,13 +645,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func aliasCmd() *cobra.Command {
|
func aliasCmd() *cobra.Command {
|
||||||
var shell string
|
var shell, test string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "alias",
|
Use: "alias",
|
||||||
Short: "Print shell aliases for all cached hosts",
|
Short: "Print shell aliases for all cached hosts",
|
||||||
Long: `Print shell alias definitions for all cached hosts.
|
Long: `Print shell alias definitions for all cached hosts.
|
||||||
The alias name is the shortened form derived from the configured shortcuts
|
The alias name is the shortened form derived from the configured shortcuts
|
||||||
(domain suffixes stripped, hyphens optionally stripped).
|
(domain suffixes stripped, rewrite rules applied, hyphens optionally stripped).
|
||||||
|
|
||||||
|
Check a rule with: netssh alias --test fsn1-bx1.example.com
|
||||||
|
|
||||||
Source the output in your shell profile:
|
Source the output in your shell profile:
|
||||||
bash/zsh: eval "$(netssh alias)"
|
bash/zsh: eval "$(netssh alias)"
|
||||||
@@ -668,6 +670,10 @@ Or use in a script:
|
|||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if test != "" {
|
||||||
|
printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
shell = filepath.Base(os.Getenv("SHELL"))
|
shell = filepath.Base(os.Getenv("SHELL"))
|
||||||
}
|
}
|
||||||
@@ -694,9 +700,36 @@ Or use in a script:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
||||||
|
cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printShortcutTest helps writing shortcut rules: it shows what a name is
|
||||||
|
// shortened to and which cached hosts share that shortcut.
|
||||||
|
func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) {
|
||||||
|
normalize := normalizeTarget(sc, strings.Contains(name, "/"))
|
||||||
|
short := normalize(name)
|
||||||
|
fmt.Fprintf(w, "%s → %s\n", name, short)
|
||||||
|
var matches []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if normalize(e.Name) == short {
|
||||||
|
matches = append(matches, e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(matches)
|
||||||
|
switch len(matches) {
|
||||||
|
case 0:
|
||||||
|
fmt.Fprintln(w, "no cached host has this shortcut")
|
||||||
|
case 1:
|
||||||
|
fmt.Fprintf(w, "matches %s\n", matches[0])
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
fmt.Fprintf(w, " %s\n", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// shellInitCmd is called at shell startup to update alias files and trigger a
|
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||||
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||||
func shellInitCmd() *cobra.Command {
|
func shellInitCmd() *cobra.Command {
|
||||||
|
|||||||
@@ -57,8 +57,34 @@ type CompletionConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ShortcutsConfig struct {
|
type ShortcutsConfig struct {
|
||||||
Domains []string `mapstructure:"domains"`
|
Domains []string `mapstructure:"domains"`
|
||||||
StripHyphens bool `mapstructure:"strip_hyphens"`
|
StripHyphens bool `mapstructure:"strip_hyphens"`
|
||||||
|
Rewrites []ShortcutRewrite `mapstructure:"rewrites"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortcutRewrite replaces every match of Pattern (Go regexp syntax) with
|
||||||
|
// Replace, which may reference groups as $1. Applied after domain stripping.
|
||||||
|
type ShortcutRewrite struct {
|
||||||
|
Pattern string `mapstructure:"pattern"`
|
||||||
|
Replace string `mapstructure:"replace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether any shortcut normalization is configured.
|
||||||
|
func (s ShortcutsConfig) Enabled() bool {
|
||||||
|
return len(s.Domains) > 0 || s.StripHyphens || len(s.Rewrites) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rejects rewrite rules that cannot be compiled.
|
||||||
|
func (s ShortcutsConfig) Validate() error {
|
||||||
|
for i, rewrite := range s.Rewrites {
|
||||||
|
if rewrite.Pattern == "" {
|
||||||
|
return fmt.Errorf("shortcuts.rewrites[%d]: pattern is required", i)
|
||||||
|
}
|
||||||
|
if _, err := regexp.Compile(rewrite.Pattern); err != nil {
|
||||||
|
return fmt.Errorf("shortcuts.rewrites[%d]: invalid pattern %q: %v", i, rewrite.Pattern, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns the canonical config file path.
|
// Path returns the canonical config file path.
|
||||||
@@ -128,6 +154,9 @@ func Load() (*Config, error) {
|
|||||||
if err := cfg.ValidateSSH(); err != nil {
|
if err := cfg.ValidateSSH(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
for i := range cfg.NetBoxes {
|
for i := range cfg.NetBoxes {
|
||||||
source := &cfg.NetBoxes[i]
|
source := &cfg.NetBoxes[i]
|
||||||
source.Name = strings.ToLower(source.Name)
|
source.Name = strings.ToLower(source.Name)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,3 +178,21 @@ func TestCommandAliasEnvironmentAndValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_ShortcutRewrites(t *testing.T) {
|
||||||
|
dir := writeConfig(t, "shortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n")
|
||||||
|
cfg := loadFromDir(t, dir)
|
||||||
|
want := []ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}
|
||||||
|
if len(cfg.Shortcuts.Rewrites) != 1 || cfg.Shortcuts.Rewrites[0] != want[0] || !cfg.Shortcuts.Enabled() {
|
||||||
|
t.Fatalf("rewrites: %+v", cfg.Shortcuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_InvalidShortcutRewrite(t *testing.T) {
|
||||||
|
for _, rule := range []string{"pattern: '[a-'", "replace: x"} {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", writeConfig(t, "shortcuts:\n rewrites:\n - "+rule+"\n"))
|
||||||
|
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "shortcuts.rewrites[0]") {
|
||||||
|
t.Errorf("%s: got %v", rule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,3 +242,14 @@ func TestAliasesIncludeInstanceName(t *testing.T) {
|
|||||||
t.Fatalf("alias lost source: %+v", entries)
|
t.Fatalf("alias lost source: %+v", entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAliasesUseRewriteRules(t *testing.T) {
|
||||||
|
cfg := config.Config{Shortcuts: config.ShortcutsConfig{
|
||||||
|
Domains: []string{".hoster.de"},
|
||||||
|
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
|
||||||
|
}}
|
||||||
|
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "fsn1-bx1.hoster.de"}}, cfg)
|
||||||
|
if len(entries) != 1 || entries[0].Definition("bash") != "alias fsnbx1='netssh fsn1-bx1.hoster.de'" {
|
||||||
|
t.Fatalf("entries: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,6 +209,7 @@ func editResolverCache(cfg *config.Config) error {
|
|||||||
func editShortcuts(cfg *config.Config) error {
|
func editShortcuts(cfg *config.Config) error {
|
||||||
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||||
stripHyphens := cfg.Shortcuts.StripHyphens
|
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||||
|
rewrites := formatRewrites(cfg.Shortcuts.Rewrites)
|
||||||
commandAlias := cfg.Shell.Alias
|
commandAlias := cfg.Shell.Alias
|
||||||
err := huh.NewForm(huh.NewGroup(
|
err := huh.NewForm(huh.NewGroup(
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
@@ -220,6 +221,17 @@ func editShortcuts(cfg *config.Config) error {
|
|||||||
Title("Strip hyphens").
|
Title("Strip hyphens").
|
||||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||||
Value(&stripHyphens),
|
Value(&stripHyphens),
|
||||||
|
huh.NewText().
|
||||||
|
Title("Rewrite rules").
|
||||||
|
Description("One rule per line: REGEX => REPLACEMENT, applied after the domain suffix is removed.\n"+
|
||||||
|
"Example: ^([a-z]+)[0-9]+- => $1 turns fsn1-bx1.example.com into fsnbx1.\n"+
|
||||||
|
"Check rules with: netssh alias --test HOSTNAME").
|
||||||
|
Lines(3).
|
||||||
|
Value(&rewrites).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
_, err := parseRewrites(s)
|
||||||
|
return err
|
||||||
|
}),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Command alias").
|
Title("Command alias").
|
||||||
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
||||||
@@ -238,6 +250,7 @@ func editShortcuts(cfg *config.Config) error {
|
|||||||
cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s)
|
cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s)
|
||||||
}
|
}
|
||||||
cfg.Shortcuts.StripHyphens = stripHyphens
|
cfg.Shortcuts.StripHyphens = stripHyphens
|
||||||
|
cfg.Shortcuts.Rewrites, _ = parseRewrites(rewrites)
|
||||||
cfg.Shell.Alias = strings.TrimSpace(commandAlias)
|
cfg.Shell.Alias = strings.TrimSpace(commandAlias)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -269,6 +282,30 @@ func editCompletion(cfg *config.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseRewrites reads "REGEX => REPLACEMENT" lines; the replacement may be empty.
|
||||||
|
func parseRewrites(s string) ([]config.ShortcutRewrite, error) {
|
||||||
|
var out []config.ShortcutRewrite
|
||||||
|
for _, line := range parseLines(s) {
|
||||||
|
pattern, replace, ok := strings.Cut(line, "=>")
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%q: expected REGEX => REPLACEMENT", line)
|
||||||
|
}
|
||||||
|
out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)})
|
||||||
|
}
|
||||||
|
if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRewrites(rewrites []config.ShortcutRewrite) string {
|
||||||
|
lines := make([]string, 0, len(rewrites))
|
||||||
|
for _, r := range rewrites {
|
||||||
|
lines = append(lines, r.Pattern+" => "+r.Replace)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// splitList returns the non-empty, trimmed entries of a comma-separated list.
|
// splitList returns the non-empty, trimmed entries of a comma-separated list.
|
||||||
func splitList(s string) []string {
|
func splitList(s string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
@@ -310,6 +347,9 @@ func save(cfg config.Config) error {
|
|||||||
if err := cfg.ValidateSSH(); err != nil {
|
if err := cfg.ValidateSSH(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
path := config.Path()
|
path := config.Path()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
@@ -380,7 +420,7 @@ func save(cfg config.Config) error {
|
|||||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
if cfg.Shortcuts.Enabled() {
|
||||||
b.WriteString("\nshortcuts:\n")
|
b.WriteString("\nshortcuts:\n")
|
||||||
if len(cfg.Shortcuts.Domains) > 0 {
|
if len(cfg.Shortcuts.Domains) > 0 {
|
||||||
b.WriteString(" domains:\n")
|
b.WriteString(" domains:\n")
|
||||||
@@ -391,6 +431,12 @@ func save(cfg config.Config) error {
|
|||||||
if cfg.Shortcuts.StripHyphens {
|
if cfg.Shortcuts.StripHyphens {
|
||||||
b.WriteString(" strip_hyphens: true\n")
|
b.WriteString(" strip_hyphens: true\n")
|
||||||
}
|
}
|
||||||
|
if len(cfg.Shortcuts.Rewrites) > 0 {
|
||||||
|
b.WriteString(" rewrites:\n")
|
||||||
|
for _, r := range cfg.Shortcuts.Rewrites {
|
||||||
|
fmt.Fprintf(&b, " - pattern: %q\n replace: %q\n", r.Pattern, r.Replace)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Shell.Alias != "" {
|
if cfg.Shell.Alias != "" {
|
||||||
|
|||||||
@@ -498,3 +498,47 @@ func TestSaveRejectsInvalidSSHOptions(t *testing.T) {
|
|||||||
t.Fatalf("config file written despite error: %v", err)
|
t.Fatalf("config file written despite error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRewrites(t *testing.T) {
|
||||||
|
text := `^([a-z]+)[0-9]+- => $1
|
||||||
|
|
||||||
|
\.lab$ =>
|
||||||
|
`
|
||||||
|
got, err := parseRewrites(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) {
|
||||||
|
t.Fatalf("roundtrip: %+v", again)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"no arrow", "[a- => x", " => x"} {
|
||||||
|
if _, err := parseRewrites(bad); err == nil {
|
||||||
|
t.Errorf("%q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveShortcutRewritesRoundtrip(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}}
|
||||||
|
cfg := config.Config{
|
||||||
|
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
|
||||||
|
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||||
|
Shortcuts: config.ShortcutsConfig{Rewrites: rewrites},
|
||||||
|
}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) {
|
||||||
|
t.Fatalf("got %+v", loaded.Shortcuts.Rewrites)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
package shortcuts
|
package shortcuts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
|
// compiled caches rewrite patterns: Normalize runs for every cache entry on each lookup.
|
||||||
// The result is lowercased for case-insensitive comparison.
|
var compiled sync.Map // pattern → *regexp.Regexp
|
||||||
|
|
||||||
|
func rewritePattern(pattern string) *regexp.Regexp {
|
||||||
|
if re, ok := compiled.Load(pattern); ok {
|
||||||
|
return re.(*regexp.Regexp)
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil // rejected by config.Load already
|
||||||
|
}
|
||||||
|
compiled.Store(pattern, re)
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize strips configured domain suffixes, applies rewrite rules in order, and
|
||||||
|
// optionally strips hyphens. The result is lowercased for case-insensitive comparison.
|
||||||
func Normalize(name string, cfg config.ShortcutsConfig) string {
|
func Normalize(name string, cfg config.ShortcutsConfig) string {
|
||||||
s := strings.ToLower(name)
|
s := strings.ToLower(name)
|
||||||
for _, domain := range cfg.Domains {
|
for _, domain := range cfg.Domains {
|
||||||
@@ -20,6 +37,11 @@ func Normalize(name string, cfg config.ShortcutsConfig) string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, rewrite := range cfg.Rewrites {
|
||||||
|
if re := rewritePattern(rewrite.Pattern); re != nil {
|
||||||
|
s = re.ReplaceAllString(s, rewrite.Replace)
|
||||||
|
}
|
||||||
|
}
|
||||||
if cfg.StripHyphens {
|
if cfg.StripHyphens {
|
||||||
s = strings.ReplaceAll(s, "-", "")
|
s = strings.ReplaceAll(s, "-", "")
|
||||||
}
|
}
|
||||||
@@ -34,7 +56,7 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AliasName returns a shell-safe alias name for the given host.
|
// AliasName returns a shell-safe alias name for the given host.
|
||||||
// It normalizes the name (strips configured domains and optionally hyphens) then
|
// It normalizes the name (strips configured domains, applies rewrites, optionally strips hyphens) then
|
||||||
// replaces any remaining dots with underscores so the result is a valid identifier.
|
// replaces any remaining dots with underscores so the result is a valid identifier.
|
||||||
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
||||||
s := Normalize(name, cfg)
|
s := Normalize(name, cfg)
|
||||||
|
|||||||
@@ -78,3 +78,40 @@ func TestAliasName_NoConfig(t *testing.T) {
|
|||||||
t.Errorf("AliasName = %q, want %q", got, want)
|
t.Errorf("AliasName = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRewrites(t *testing.T) {
|
||||||
|
cfg := config.ShortcutsConfig{
|
||||||
|
Domains: []string{".hoster.de"},
|
||||||
|
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
|
||||||
|
}
|
||||||
|
for input, want := range map[string]string{
|
||||||
|
"fsn1-bx1.hoster.de": "fsnbx1",
|
||||||
|
"FSN1-BX1.hoster.de": "fsnbx1",
|
||||||
|
"fsn1-bx1": "fsnbx1",
|
||||||
|
"fsnbx1": "fsnbx1", // the shortcut itself is left alone
|
||||||
|
"web01.hoster.de": "web01",
|
||||||
|
} {
|
||||||
|
if got := shortcuts.Normalize(input, cfg); got != want {
|
||||||
|
t.Errorf("Normalize(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := shortcuts.AliasName("fsn1-bx1.hoster.de", cfg); got != "fsnbx1" {
|
||||||
|
t.Errorf("AliasName = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRewritesOrder(t *testing.T) {
|
||||||
|
// Rules run in order after domain stripping, before hyphen stripping.
|
||||||
|
cfg := config.ShortcutsConfig{
|
||||||
|
Domains: []string{".hoster.de"},
|
||||||
|
StripHyphens: true,
|
||||||
|
Rewrites: []config.ShortcutRewrite{
|
||||||
|
{Pattern: `\.hoster\.de$`, Replace: "-never"},
|
||||||
|
{Pattern: `^fsn`, Replace: "f"},
|
||||||
|
{Pattern: `^f1`, Replace: "falkenstein"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := shortcuts.Normalize("fsn1-bx-1.hoster.de", cfg); got != "falkensteinbx1" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user