diff --git a/cmd/restic/cmd_test_pattern_test.go b/cmd/restic/cmd_test_pattern_test.go new file mode 100644 index 000000000..9a9e00cd1 --- /dev/null +++ b/cmd/restic/cmd_test_pattern_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "bytes" + "context" + "io" + "strings" + "sync" + "testing" + + "github.com/restic/restic/internal/global" + "github.com/restic/restic/internal/ui/termstatus" +) + +func newTestTerm() (*termstatus.Terminal, func()) { + ctx, cancel := context.WithCancel(context.Background()) + term := termstatus.New(io.NopCloser(bytes.NewReader(nil)), io.Discard, io.Discard, true) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + term.Run(ctx) + }() + + return term, func() { + cancel() + wg.Wait() + } +} + +func TestTestPatternMatch(t *testing.T) { + for _, test := range []struct { + name string + pattern string + path string + caseInsensitive bool + wantMatch bool + wantErr bool + }{ + { + name: "simple wildcard match", + pattern: "*.go", + path: "/home/user/main.go", + wantMatch: true, + }, + { + name: "simple wildcard no match", + pattern: "*.txt", + path: "/home/user/main.go", + wantMatch: false, + }, + { + name: "exact filename match", + pattern: "main.go", + path: "main.go", + wantMatch: true, + }, + { + name: "case insensitive match", + pattern: "*.GO", + path: "/home/user/main.go", + caseInsensitive: true, + wantMatch: true, + }, + { + name: "case insensitive no match", + pattern: "*.txt", + path: "/home/user/main.go", + caseInsensitive: true, + wantMatch: false, + }, + { + name: "recursive wildcard match", + pattern: "**/.git/**", + path: "/home/user/project/.git/config", + wantMatch: true, + }, + { + name: "recursive wildcard child match", + pattern: "**/.git/**", + path: "/home/user/project/.git", + wantMatch: true, + }, + { + name: "absolute pattern match", + pattern: "/home/user/*.txt", + path: "/home/user/readme.txt", + wantMatch: true, + }, + { + name: "absolute pattern no match different dir", + pattern: "/home/user/*.txt", + path: "/home/other/readme.txt", + wantMatch: false, + }, + { + name: "single character wildcard", + pattern: "file.?o", + path: "/path/file.go", + wantMatch: true, + }, + { + name: "question mark no match", + pattern: "file.?o", + path: "/path/file.py", + wantMatch: false, + }, + { + name: "character class match", + pattern: "file.[gx]o", + path: "/path/file.go", + wantMatch: true, + }, + { + name: "character class no match", + pattern: "file.[ab]o", + path: "/path/file.go", + wantMatch: false, + }, + { + name: "directory wildcard match", + pattern: "/home/*/main.go", + path: "/home/user/main.go", + wantMatch: true, + }, + { + name: "directory wildcard no match subdir", + pattern: "/home/*/main.go", + path: "/home/user/sub/main.go", + wantMatch: false, + }, + { + name: "directory wildcard multi-level", + pattern: "/home/*/*/main.go", + path: "/home/user/sub/main.go", + wantMatch: true, + }, + { + name: "invalid pattern", + pattern: "[", + path: "/home/user/main.go", + wantErr: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + term, cleanup := newTestTerm() + defer cleanup() + + gopts := global.Options{ + Term: term, + } + + opts := TestPatternOptions{ + CaseInsensitive: test.caseInsensitive, + } + + args := []string{test.pattern, test.path} + err := runTestPattern(opts, gopts, args) + + if test.wantErr { + if err == nil { + t.Fatal("expected error but got none") + } + return + } + + if test.wantMatch { + if err != nil { + t.Fatalf("expected match but got error: %v", err) + } + } else { + if err == nil { + t.Fatal("expected no match but got error") + } + if !strings.Contains(err.Error(), "pattern did not match") { + t.Fatalf("expected 'pattern did not match' error, got: %v", err) + } + } + }) + } +} + +func TestTestPatternWrongArgCount(t *testing.T) { + term, cleanup := newTestTerm() + defer cleanup() + + gopts := global.Options{ + Term: term, + } + + opts := TestPatternOptions{} + + err := runTestPattern(opts, gopts, []string{}) + if err == nil || !strings.Contains(err.Error(), "wrong number of arguments") { + t.Fatalf("expected 'wrong number of arguments' error, got: %v", err) + } + + err = runTestPattern(opts, gopts, []string{"*.go"}) + if err == nil || !strings.Contains(err.Error(), "wrong number of arguments") { + t.Fatalf("expected 'wrong number of arguments' error, got: %v", err) + } + + err = runTestPattern(opts, gopts, []string{"*.go", "/path", "extra"}) + if err == nil || !strings.Contains(err.Error(), "wrong number of arguments") { + t.Fatalf("expected 'wrong number of arguments' error, got: %v", err) + } +} diff --git a/cmd/restic/main.go b/cmd/restic/main.go index 619eee642..4f78387d4 100644 --- a/cmd/restic/main.go +++ b/cmd/restic/main.go @@ -101,6 +101,7 @@ The full documentation can be found at https://restic.readthedocs.io/ . newSnapshotsCommand(globalOptions), newStatsCommand(globalOptions), newTagCommand(globalOptions), + newTestPatternCommand(globalOptions), newUnlockCommand(globalOptions), newVersionCommand(globalOptions), ) @@ -118,7 +119,7 @@ The full documentation can be found at https://restic.readthedocs.io/ . // user for authentication). func needsPassword(cmd string) bool { switch cmd { - case "cache", "generate", "help", "options", "self-update", "version", "__complete", "__completeNoDesc": + case "cache", "generate", "help", "options", "self-update", "test-pattern", "version", "__complete", "__completeNoDesc": return false default: return true diff --git a/cmd_test_pattern.go b/cmd_test_pattern.go new file mode 100644 index 000000000..117d70323 --- /dev/null +++ b/cmd_test_pattern.go @@ -0,0 +1,99 @@ +package main + +import ( + "strings" + + "github.com/restic/restic/internal/errors" + "github.com/restic/restic/internal/filter" + "github.com/restic/restic/internal/global" + "github.com/restic/restic/internal/ui" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func newTestPatternCommand(globalOptions *global.Options) *cobra.Command { + var opts TestPatternOptions + + cmd := &cobra.Command{ + Use: "test-pattern [flags] PATTERN PATH", + Short: "Test a pattern against a path", + Long: ` +The "test-pattern" command tests whether a given pattern matches a specific +path. It uses the same pattern matching logic as the "find", "backup", and +"restore" commands, supporting the '**' recursive wildcard in addition to +filepath.Match patterns. + +This is useful for verifying exclude/include patterns before running a backup. + +EXIT STATUS +=========== + +Exit status is 0 if the pattern matched the path. +Exit status is 1 if the pattern did not match. +`, + Example: `restic test-pattern '*.go' /home/user/main.go +restic test-pattern --ignore-case '*.GO' /home/user/main.go +restic test-pattern '**/.git/**' /home/user/project/.git/config +restic test-pattern '/home/user/*.txt' /home/user/readme.txt`, + DisableAutoGenTag: true, + GroupID: cmdGroupDefault, + RunE: func(_ *cobra.Command, args []string) error { + return runTestPattern(opts, *globalOptions, args) + }, + } + + opts.AddFlags(cmd.Flags()) + return cmd +} + +// TestPatternOptions collects all options for the test-pattern command. +type TestPatternOptions struct { + CaseInsensitive bool +} + +func (opts *TestPatternOptions) AddFlags(f *pflag.FlagSet) { + f.BoolVarP(&opts.CaseInsensitive, "ignore-case", "i", false, "ignore case for pattern") +} + +func runTestPattern(opts TestPatternOptions, gopts global.Options, args []string) error { + if len(args) != 2 { + return errors.Fatal("wrong number of arguments, expecting: test-pattern [flags] PATTERN PATH") + } + + pattern := args[0] + path := args[1] + + normalizedPattern := pattern + normalizedPath := path + if opts.CaseInsensitive { + normalizedPattern = strings.ToLower(pattern) + normalizedPath = strings.ToLower(path) + } + + matched, err := filter.Match(normalizedPattern, normalizedPath) + if err != nil { + return errors.Fatalf("error matching pattern: %v", err) + } + + childMayMatch, err := filter.ChildMatch(normalizedPattern, normalizedPath) + if err != nil { + return errors.Fatalf("error testing child match: %v", err) + } + + printer := ui.NewProgressPrinter(gopts.JSON, gopts.Verbosity, gopts.Term) + + if opts.CaseInsensitive { + printer.S("pattern : %s (case-insensitive)\n", pattern) + } else { + printer.S("pattern : %s\n", pattern) + } + printer.S("path : %s\n", path) + printer.S("match : %v\n", matched) + printer.S("child match: %v\n", childMayMatch) + + if !matched { + return errors.Fatal("pattern did not match") + } + + return nil +}