Warn on unknown restic flags at runtime

Amolith created

Change summary

cmd/root.go                     |   2 
internal/restic/command_test.go | 188 +++++++++++++++++++++++++++++++++++
internal/restic/warn.go         |  98 ++++++++++++++++++
3 files changed, 288 insertions(+)

Detailed changes

cmd/root.go 🔗

@@ -132,6 +132,8 @@ var rootCmd = &cobra.Command{
 			return err
 		}
 
+		restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
+
 		if config.IsDryRun() {
 			fmt.Print(restic.DryRun(cfg))
 			return nil

internal/restic/command_test.go 🔗

@@ -0,0 +1,188 @@
+package restic
+
+import (
+	"bytes"
+	"fmt"
+	"reflect"
+	"sort"
+	"strings"
+	"testing"
+
+	"git.secluded.site/keld/internal/config"
+)
+
+func TestCommandsSpotChecks(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name       string
+		command    string
+		optionName string
+		wantAlias  string
+		wantRepeat bool
+	}{
+		{
+			name:       "backup dry-run alias",
+			command:    "backup",
+			optionName: "dry-run",
+			wantAlias:  "n",
+			wantRepeat: false,
+		},
+		{
+			name:       "backup exclude repeatable",
+			command:    "backup",
+			optionName: "exclude",
+			wantAlias:  "e",
+			wantRepeat: true,
+		},
+		{
+			name:       "backup tag repeatable",
+			command:    "backup",
+			optionName: "tag",
+			wantAlias:  "",
+			wantRepeat: true,
+		},
+		{
+			name:       "global repo alias",
+			command:    "global",
+			optionName: "repo",
+			wantAlias:  "r",
+			wantRepeat: false,
+		},
+		{
+			name:       "global verbose alias",
+			command:    "global",
+			optionName: "verbose",
+			wantAlias:  "v",
+			wantRepeat: false,
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			cmd, ok := Commands[tt.command]
+			if !ok {
+				t.Fatalf("command %q missing from Commands map", tt.command)
+			}
+
+			opt, ok := findOption(cmd.Options, tt.optionName)
+			if !ok {
+				t.Fatalf("option %q missing from command %q", tt.optionName, tt.command)
+			}
+
+			if opt.Alias != tt.wantAlias {
+				t.Fatalf("alias mismatch for %s/%s: got %q, want %q", tt.command, tt.optionName, opt.Alias, tt.wantAlias)
+			}
+			if opt.Repeatable != tt.wantRepeat {
+				t.Fatalf("repeatable mismatch for %s/%s: got %v, want %v", tt.command, tt.optionName, opt.Repeatable, tt.wantRepeat)
+			}
+		})
+	}
+}
+
+func TestEveryCommandHasHelpOption(t *testing.T) {
+	t.Parallel()
+
+	commandNames := make([]string, 0, len(Commands))
+	for name := range Commands {
+		commandNames = append(commandNames, name)
+	}
+	sort.Strings(commandNames)
+
+	for _, name := range commandNames {
+		name := name
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			cmd := Commands[name]
+			if _, ok := findOption(cmd.Options, "help"); !ok {
+				t.Fatalf("command %q has no help option", name)
+			}
+		})
+	}
+}
+
+func TestWarnUnknownFlags(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name      string
+		command   string
+		flags     []config.Flag
+		wantLines []string
+	}{
+		{
+			name:    "known command and global flags are silent",
+			command: "backup",
+			flags: []config.Flag{
+				{Name: "--repo", Value: "/repo"},
+				{Name: "--tag", Value: "daily"},
+				{Name: "-n"},
+			},
+			wantLines: nil,
+		},
+		{
+			name:    "unknown flags emit warnings once",
+			command: "backup",
+			flags: []config.Flag{
+				{Name: "--repo", Value: "/repo"},
+				{Name: "--unknown", Value: "x"},
+				{Name: "--unknown", Value: "y"},
+				{Name: "-Z"},
+			},
+			wantLines: []string{
+				`warning: unknown restic flag "--unknown" for command "backup"`,
+				`warning: unknown restic flag "-Z" for command "backup"`,
+			},
+		},
+		{
+			name:    "empty command validates against global options",
+			command: "",
+			flags: []config.Flag{
+				{Name: "--repo", Value: "/repo"},
+				{Name: "--exclude", Value: "*.tmp"},
+			},
+			wantLines: []string{
+				`warning: unknown restic flag "--exclude" for command "global"`,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			var stderr bytes.Buffer
+			warnUnknownFlags(&stderr, tt.command, tt.flags)
+
+			gotLines := splitLines(strings.TrimSpace(stderr.String()))
+			if !reflect.DeepEqual(gotLines, tt.wantLines) {
+				t.Fatalf("warnUnknownFlags() output mismatch:\n%s", fmtDiff(gotLines, tt.wantLines))
+			}
+		})
+	}
+}
+
+func findOption(options []Option, name string) (Option, bool) {
+	for _, option := range options {
+		if option.Name == name {
+			return option, true
+		}
+	}
+	return Option{}, false
+}
+
+func splitLines(s string) []string {
+	if s == "" {
+		return nil
+	}
+	return strings.Split(s, "\n")
+}
+
+func fmtDiff(got, want []string) string {
+	return fmt.Sprintf("got:\n%q\nwant:\n%q", got, want)
+}

internal/restic/warn.go 🔗

@@ -0,0 +1,98 @@
+package restic
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"strings"
+
+	"git.secluded.site/keld/internal/config"
+)
+
+// WarnUnknownFlags writes warnings to stderr for flags that don't exist in
+// restic's generated command metadata. Warnings are non-fatal and execution
+// continues.
+func WarnUnknownFlags(command string, flags []config.Flag) {
+	warnUnknownFlags(os.Stderr, command, flags)
+}
+
+func warnUnknownFlags(w io.Writer, command string, flags []config.Flag) {
+	unknown := unknownFlags(command, flags)
+	if len(unknown) == 0 {
+		return
+	}
+
+	commandLabel := command
+	if commandLabel == "" {
+		commandLabel = "global"
+	}
+
+	for _, flag := range unknown {
+		_, _ = fmt.Fprintf(w, "warning: unknown restic flag %q for command %q\n", flag, commandLabel)
+	}
+}
+
+func unknownFlags(command string, flags []config.Flag) []string {
+	knownLong, knownAlias := knownOptionNames(command)
+	seen := make(map[string]struct{})
+	unknown := make([]string, 0)
+
+	for _, flag := range flags {
+		name := strings.TrimSpace(flag.Name)
+		if name == "" {
+			continue
+		}
+
+		normalized := strings.TrimLeft(name, "-")
+		if normalized == "" {
+			continue
+		}
+
+		known := false
+		if len(normalized) == 1 {
+			_, known = knownAlias[normalized]
+			if !known {
+				_, known = knownLong[normalized]
+			}
+		} else {
+			_, known = knownLong[normalized]
+		}
+
+		if known {
+			continue
+		}
+
+		if _, dup := seen[name]; dup {
+			continue
+		}
+		seen[name] = struct{}{}
+		unknown = append(unknown, name)
+	}
+
+	return unknown
+}
+
+func knownOptionNames(command string) (map[string]struct{}, map[string]struct{}) {
+	long := make(map[string]struct{})
+	alias := make(map[string]struct{})
+
+	addOptions := func(opts []Option) {
+		for _, opt := range opts {
+			if opt.Name != "" {
+				long[opt.Name] = struct{}{}
+			}
+			if opt.Alias != "" {
+				alias[opt.Alias] = struct{}{}
+			}
+		}
+	}
+
+	if global, ok := Commands["global"]; ok {
+		addOptions(global.Options)
+	}
+	if commandCfg, ok := Commands[command]; ok {
+		addOptions(commandCfg.Options)
+	}
+
+	return long, alias
+}