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
@@ -209,6 +209,7 @@ func editResolverCache(cfg *config.Config) error {
|
||||
func editShortcuts(cfg *config.Config) error {
|
||||
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||
rewrites := formatRewrites(cfg.Shortcuts.Rewrites)
|
||||
commandAlias := cfg.Shell.Alias
|
||||
err := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().
|
||||
@@ -220,6 +221,17 @@ func editShortcuts(cfg *config.Config) error {
|
||||
Title("Strip hyphens").
|
||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||
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().
|
||||
Title("Command alias").
|
||||
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.StripHyphens = stripHyphens
|
||||
cfg.Shortcuts.Rewrites, _ = parseRewrites(rewrites)
|
||||
cfg.Shell.Alias = strings.TrimSpace(commandAlias)
|
||||
return nil
|
||||
}
|
||||
@@ -269,6 +282,30 @@ func editCompletion(cfg *config.Config) error {
|
||||
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.
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
@@ -310,6 +347,9 @@ func save(cfg config.Config) error {
|
||||
if err := cfg.ValidateSSH(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := config.Path()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
||||
if cfg.Shortcuts.Enabled() {
|
||||
b.WriteString("\nshortcuts:\n")
|
||||
if len(cfg.Shortcuts.Domains) > 0 {
|
||||
b.WriteString(" domains:\n")
|
||||
@@ -391,6 +431,12 @@ func save(cfg config.Config) error {
|
||||
if cfg.Shortcuts.StripHyphens {
|
||||
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 != "" {
|
||||
|
||||
@@ -498,3 +498,47 @@ func TestSaveRejectsInvalidSSHOptions(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user