diff --git a/cmd/root.go b/cmd/root.go index ac2bc46ddfda078044b04ebdd2dbba2cce07858b..c3007aa13beb1c11c4bdf099f2b50a60ca9e98e0 100644 --- a/cmd/root.go +++ b/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 diff --git a/internal/restic/command_test.go b/internal/restic/command_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7f0acdc4eab6f51b457c211f78301beec95edc8f --- /dev/null +++ b/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) +} diff --git a/internal/restic/warn.go b/internal/restic/warn.go new file mode 100644 index 0000000000000000000000000000000000000000..d5b534d4e16f22c5764dd865423ce3175a84d442 --- /dev/null +++ b/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 +}