Merge pull request #135 from glefloch/glob-pathop

Add MatchExtraFilesGlob PathOp
diff --git a/fs/manifest.go b/fs/manifest.go
index 28122f1..e5e693e 100644
--- a/fs/manifest.go
+++ b/fs/manifest.go
@@ -44,7 +44,8 @@
 
 type directory struct {
 	resource
-	items map[string]dirEntry
+	items         map[string]dirEntry
+	filepathGlobs map[string]*filePath
 }
 
 func (f *directory) Type() string {
@@ -96,8 +97,9 @@
 	}
 
 	return &directory{
-		resource: newResourceFromInfo(info),
-		items:    items,
+		resource:      newResourceFromInfo(info),
+		items:         items,
+		filepathGlobs: make(map[string]*filePath),
 	}, nil
 }
 
diff --git a/fs/manifest_test.go b/fs/manifest_test.go
index 9e592f0..31086bb 100644
--- a/fs/manifest_test.go
+++ b/fs/manifest_test.go
@@ -54,6 +54,7 @@
 							content:  readCloser("content k"),
 						},
 					},
+					filepathGlobs: map[string]*filePath{},
 				},
 				"f": &symlink{
 					resource: newResource(defaultSymlinkMode),
@@ -64,6 +65,7 @@
 					content:  readCloser("content x"),
 				},
 			},
+			filepathGlobs: map[string]*filePath{},
 		},
 	}
 	actual := ManifestFromDir(t, srcDir.Path())
diff --git a/fs/path.go b/fs/path.go
index 0f447c3..4bb3877 100644
--- a/fs/path.go
+++ b/fs/path.go
@@ -64,6 +64,13 @@
 	return applyPathOps(exp, ops)
 }
 
+func (p *directoryPath) AddGlobFiles(glob string, ops ...PathOp) error {
+	newFile := &file{resource: newResource(0)}
+	newFilePath := &filePath{file: newFile}
+	p.directory.filepathGlobs[glob] = newFilePath
+	return applyPathOps(newFilePath, ops)
+}
+
 func (p *directoryPath) AddDirectory(path string, ops ...PathOp) error {
 	newDir := newDirectoryWithDefaults()
 	p.directory.items[path] = newDir
@@ -87,8 +94,9 @@
 
 func newDirectoryWithDefaults() *directory {
 	return &directory{
-		resource: newResource(defaultRootDirMode),
-		items:    make(map[string]dirEntry),
+		resource:      newResource(defaultRootDirMode),
+		items:         make(map[string]dirEntry),
+		filepathGlobs: make(map[string]*filePath),
 	}
 }
 
@@ -167,6 +175,17 @@
 	}
 }
 
+// MatchFilesWithGlob is a PathOp that updates a Manifest to match files using
+// glob pattern, and check them using the ops.
+func MatchFilesWithGlob(glob string, ops ...PathOp) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(*directoryPath); ok {
+			m.AddGlobFiles(glob, ops...)
+		}
+		return nil
+	}
+}
+
 // anyFileMode is represented by uint32_max
 const anyFileMode os.FileMode = 4294967295
 
diff --git a/fs/report.go b/fs/report.go
index 851dbd1..adc5a5f 100644
--- a/fs/report.go
+++ b/fs/report.go
@@ -160,11 +160,13 @@
 func eqDirectory(path string, x, y *directory) []failure {
 	p := eqResource(x.resource, y.resource)
 	var f []failure
+	matchedFiles := make(map[string]bool)
 
 	for _, name := range sortedKeys(x.items) {
 		if name == anyFile {
 			continue
 		}
+		matchedFiles[name] = true
 		xEntry := x.items[name]
 		yEntry, ok := y.items[name]
 		if !ok {
@@ -180,19 +182,30 @@
 		f = append(f, eqEntry(filepath.Join(path, name), xEntry, yEntry)...)
 	}
 
-	if _, ok := x.items[anyFile]; !ok {
+	if len(x.filepathGlobs) != 0 {
 		for _, name := range sortedKeys(y.items) {
-			if _, ok := x.items[name]; !ok {
-				yEntry := y.items[name]
-				p = append(p, existenceProblem(name, "unexpected %s", yEntry.Type()))
-			}
+			m := matchGlob(name, y.items[name], x.filepathGlobs)
+			matchedFiles[name] = m.match
+			f = append(f, m.failures...)
 		}
 	}
 
-	if len(p) > 0 {
-		f = append(f, failure{path: path, problems: p})
+	if _, ok := x.items[anyFile]; ok {
+		return maybeAppendFailure(f, path, p)
 	}
-	return f
+	for _, name := range sortedKeys(y.items) {
+		if !matchedFiles[name] {
+			p = append(p, existenceProblem(name, "unexpected %s", y.items[name].Type()))
+		}
+	}
+	return maybeAppendFailure(f, path, p)
+}
+
+func maybeAppendFailure(failures []failure, path string, problems []problem) []failure {
+	if len(problems) > 0 {
+		return append(failures, failure{path: path, problems: problems})
+	}
+	return failures
 }
 
 func sortedKeys(items map[string]dirEntry) []string {
@@ -224,6 +237,30 @@
 	return nil
 }
 
+type globMatch struct {
+	match    bool
+	failures []failure
+}
+
+func matchGlob(name string, yEntry dirEntry, globs map[string]*filePath) globMatch {
+	m := globMatch{}
+
+	for glob, expectedFile := range globs {
+		ok, err := filepath.Match(glob, name)
+		if err != nil {
+			p := errProblem("failed to match glob pattern", err)
+			f := failure{path: name, problems: []problem{p}}
+			m.failures = append(m.failures, f)
+		}
+		if ok {
+			m.match = true
+			m.failures = eqEntry(name, expectedFile.file, yEntry)
+			return m
+		}
+	}
+	return m
+}
+
 func formatFailures(failures []failure) string {
 	sort.Slice(failures, func(i, j int) bool {
 		return failures[i].path < failures[j].path
diff --git a/fs/report_test.go b/fs/report_test.go
index 5c17eca..26cf493 100644
--- a/fs/report_test.go
+++ b/fs/report_test.go
@@ -8,6 +8,7 @@
 
 	"gotest.tools/assert"
 	is "gotest.tools/assert/cmp"
+	"gotest.tools/skip"
 )
 
 func TestEqualMissingRoot(t *testing.T) {
@@ -207,3 +208,67 @@
 		assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
 	})
 }
+
+func TestMatchExtraFilesGlob(t *testing.T) {
+	dir := NewDir(t, t.Name(),
+		WithFile("t.go", "data"),
+		WithFile("a.go", "data"),
+		WithFile("conf.yml", "content", WithMode(0600)))
+	defer dir.Remove()
+
+	t.Run("matching globs", func(t *testing.T) {
+		manifest := Expected(t,
+			MatchFilesWithGlob("*.go", MatchAnyFileMode, MatchAnyFileContent),
+			MatchFilesWithGlob("*.yml", MatchAnyFileMode, MatchAnyFileContent))
+		assert.Assert(t, Equal(dir.Path(), manifest))
+	})
+
+	t.Run("matching globs with wrong mode", func(t *testing.T) {
+		skip.If(t, runtime.GOOS == "windows", "expect mode does not match on windows")
+		manifest := Expected(t,
+			MatchFilesWithGlob("*.go", MatchAnyFileMode, MatchAnyFileContent),
+			MatchFilesWithGlob("*.yml", MatchAnyFileContent, WithMode(0700)))
+
+		result := Equal(dir.Path(), manifest)()
+
+		assert.Assert(t, !result.Success())
+		expected := fmtExpected(`directory %s does not match expected:
+conf.yml
+  mode: expected -rwx------ got -rw-------
+`, dir.Path())
+		assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+	})
+
+	t.Run("matching partial glob", func(t *testing.T) {
+		manifest := Expected(t, MatchFilesWithGlob("*.go", MatchAnyFileMode, MatchAnyFileContent))
+		result := Equal(dir.Path(), manifest)()
+		assert.Assert(t, !result.Success())
+
+		expected := fmtExpected(`directory %s does not match expected:
+/
+  conf.yml: unexpected file
+`, dir.Path())
+		assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+	})
+
+	t.Run("invalid glob", func(t *testing.T) {
+		manifest := Expected(t, MatchFilesWithGlob("[-x]"))
+		result := Equal(dir.Path(), manifest)()
+		assert.Assert(t, !result.Success())
+
+		expected := fmtExpected(`directory %s does not match expected:
+/
+  a.go: unexpected file
+  conf.yml: unexpected file
+  t.go: unexpected file
+a.go
+  failed to match glob pattern: syntax error in pattern
+conf.yml
+  failed to match glob pattern: syntax error in pattern
+t.go
+  failed to match glob pattern: syntax error in pattern
+`, dir.Path())
+		assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+	})
+
+}