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
Amolith created
cmd/root.go | 2
internal/restic/command_test.go | 188 +++++++++++++++++++++++++++++++++++
internal/restic/warn.go | 98 ++++++++++++++++++
3 files changed, 288 insertions(+)
@@ -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
@@ -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)
+}
@@ -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
+}