Merge pull request #19 from dnephin/add-fs-assert

fs: Add fs.Equal() and fs.Manifest
diff --git a/README.md b/README.md
index e277e32..cc47d3e 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
 * [env](http://godoc.org/github.com/gotestyourself/gotestyourself/env) -
   test code that uses environment variables
 * [fs](http://godoc.org/github.com/gotestyourself/gotestyourself/fs) -
-  create test files and directories
+  create test files and compare directories structures
 * [golden](http://godoc.org/github.com/gotestyourself/gotestyourself/golden) -
   compare large multi-line strings
 * [icmd](http://godoc.org/github.com/gotestyourself/gotestyourself/icmd) -
diff --git a/fs/example_test.go b/fs/example_test.go
index 908ede4..42c6627 100644
--- a/fs/example_test.go
+++ b/fs/example_test.go
@@ -8,6 +8,7 @@
 	"github.com/gotestyourself/gotestyourself/assert"
 	"github.com/gotestyourself/gotestyourself/assert/cmp"
 	"github.com/gotestyourself/gotestyourself/fs"
+	"github.com/gotestyourself/gotestyourself/golden"
 )
 
 var t = &testing.T{}
@@ -41,3 +42,21 @@
 	)
 	defer dir.Remove()
 }
+
+// Test that a directory contains the expected files, and all the files have the
+// expected properties.
+func ExampleEqual() {
+	path := operationWhichCreatesFiles()
+	expected := fs.Expected(t,
+		fs.WithFile("one", "",
+			fs.WithBytes(golden.Get(t, "one.golden")),
+			fs.WithMode(0600)),
+		fs.WithDir("data",
+			fs.WithFile("config", "", fs.MatchAnyFileContent)))
+
+	assert.Assert(t, fs.Equal(path, expected))
+}
+
+func operationWhichCreatesFiles() string {
+	return "example-path"
+}
diff --git a/fs/file.go b/fs/file.go
index 6d384bc..cf71fa2 100644
--- a/fs/file.go
+++ b/fs/file.go
@@ -1,5 +1,5 @@
-/*Package fs provides tools for creating and working with temporary files and
-directories.
+/*Package fs provides tools for creating temporary files, and testing the
+contents and structure of a directory.
 */
 package fs
 
@@ -12,7 +12,9 @@
 	"github.com/gotestyourself/gotestyourself/x/subtest"
 )
 
-// Path objects return their filesystem path. Both File and Dir implement Path.
+// Path objects return their filesystem path. Path may be implemented by a
+// real filesystem object (such as File and Dir) or by a type which updates
+// entries in a Manifest.
 type Path interface {
 	Path() string
 	Remove()
diff --git a/fs/file_test.go b/fs/file_test.go
new file mode 100644
index 0000000..8e87ade
--- /dev/null
+++ b/fs/file_test.go
@@ -0,0 +1,40 @@
+package fs_test
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gotestyourself/gotestyourself/assert"
+	"github.com/gotestyourself/gotestyourself/fs"
+)
+
+func TestNewDirWithOpsAndManifestEqual(t *testing.T) {
+	var userOps []fs.PathOp
+	if os.Geteuid() == 0 {
+		userOps = append(userOps, fs.AsUser(1001, 1002))
+	}
+
+	ops := []fs.PathOp{
+		fs.WithFile("file1", "contenta", fs.WithMode(0400)),
+		fs.WithFile("file2", "", fs.WithBytes([]byte{0, 1, 2})),
+		fs.WithFile("file5", "", userOps...),
+		fs.WithSymlink("link1", "file1"),
+		fs.WithDir("sub",
+			fs.WithFiles(map[string]string{
+				"file3": "contentb",
+				"file4": "contentc",
+			}),
+			fs.WithMode(0705),
+		),
+	}
+
+	dir := fs.NewDir(t, "test-all", ops...)
+	defer dir.Remove()
+
+	manifestOps := append(
+		ops[:3],
+		fs.WithSymlink("link1", dir.Join("file1")),
+		ops[4],
+	)
+	assert.Assert(t, fs.Equal(dir.Path(), fs.Expected(t, manifestOps...)))
+}
diff --git a/fs/manifest.go b/fs/manifest.go
new file mode 100644
index 0000000..15cf2ce
--- /dev/null
+++ b/fs/manifest.go
@@ -0,0 +1,129 @@
+package fs
+
+import (
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"github.com/gotestyourself/gotestyourself/assert"
+	"github.com/pkg/errors"
+)
+
+// Manifest stores the expected structure and properties of files and directories
+// in a filesystem.
+type Manifest struct {
+	root *directory
+}
+
+type resource struct {
+	mode os.FileMode
+	uid  uint32
+	gid  uint32
+}
+
+type file struct {
+	resource
+	content io.ReadCloser
+}
+
+func (f *file) Type() string {
+	return "file"
+}
+
+type symlink struct {
+	resource
+	target string
+}
+
+func (f *symlink) Type() string {
+	return "symlink"
+}
+
+type directory struct {
+	resource
+	items map[string]dirEntry
+}
+
+func (f *directory) Type() string {
+	return "directory"
+}
+
+type dirEntry interface {
+	Type() string
+}
+
+// ManifestFromDir creates a Manifest by reading the directory at path. The
+// manifest stores the structure and properties of files in the directory.
+// ManifestFromDir can be used with Equal to compare two directories.
+func ManifestFromDir(t assert.TestingT, path string) Manifest {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+
+	manifest, err := manifestFromDir(path)
+	assert.NilError(t, err)
+	return manifest
+}
+
+func manifestFromDir(path string) (Manifest, error) {
+	info, err := os.Stat(path)
+	switch {
+	case err != nil:
+		return Manifest{}, err
+	case !info.IsDir():
+		return Manifest{}, errors.Errorf("path %s must be a directory", path)
+	}
+
+	directory, err := newDirectory(path, info)
+	return Manifest{root: directory}, err
+}
+
+func newDirectory(path string, info os.FileInfo) (*directory, error) {
+	items := make(map[string]dirEntry)
+	children, err := ioutil.ReadDir(path)
+	if err != nil {
+		return nil, err
+	}
+	for _, child := range children {
+		fullPath := filepath.Join(path, child.Name())
+		items[child.Name()], err = getTypedResource(fullPath, child)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return &directory{
+		resource: newResourceFromInfo(info),
+		items:    items,
+	}, nil
+}
+
+func getTypedResource(path string, info os.FileInfo) (dirEntry, error) {
+	switch {
+	case info.IsDir():
+		return newDirectory(path, info)
+	case info.Mode()&os.ModeSymlink != 0:
+		return newSymlink(path, info)
+	// TODO: devices, pipes?
+	default:
+		return newFile(path, info)
+	}
+}
+
+func newSymlink(path string, info os.FileInfo) (*symlink, error) {
+	target, err := os.Readlink(path)
+	return &symlink{
+		resource: newResourceFromInfo(info),
+		target:   target,
+	}, err
+}
+
+func newFile(path string, info os.FileInfo) (*file, error) {
+	// TODO: defer file opening to reduce number of open FDs?
+	readCloser, err := os.Open(path)
+	return &file{
+		resource: newResourceFromInfo(info),
+		content:  readCloser,
+	}, err
+}
diff --git a/fs/manifest_test.go b/fs/manifest_test.go
new file mode 100644
index 0000000..0064735
--- /dev/null
+++ b/fs/manifest_test.go
@@ -0,0 +1,97 @@
+package fs
+
+import (
+	"bytes"
+	"io"
+	"io/ioutil"
+	"os"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/gotestyourself/gotestyourself/assert"
+)
+
+func TestManifestFromDir(t *testing.T) {
+	var defaultFileMode os.FileMode = 0644
+	var subDirMode = 0755 | os.ModeDir
+	var jFileMode os.FileMode = 0600
+	if runtime.GOOS == "windows" {
+		defaultFileMode = 0666
+		subDirMode = 0777 | os.ModeDir
+		jFileMode = 0666
+	}
+
+	var userOps []PathOp
+	var expectedUserResource = newResource(defaultFileMode)
+	if os.Geteuid() == 0 {
+		userOps = append(userOps, AsUser(1001, 1002))
+		expectedUserResource = resource{mode: defaultFileMode, uid: 1001, gid: 1002}
+	}
+
+	srcDir := NewDir(t, t.Name(),
+		WithFile("j", "content j", WithMode(0600)),
+		WithDir("s",
+			WithFile("k", "content k")),
+		WithSymlink("f", "j"),
+		WithFile("x", "content x", userOps...))
+	defer srcDir.Remove()
+
+	expected := Manifest{
+		root: &directory{
+			resource: newResource(defaultRootDirMode),
+			items: map[string]dirEntry{
+				"j": &file{
+					resource: newResource(jFileMode),
+					content:  readCloser("content j"),
+				},
+				"s": &directory{
+					resource: newResource(subDirMode),
+					items: map[string]dirEntry{
+						"k": &file{
+							resource: newResource(defaultFileMode),
+							content:  readCloser("content k"),
+						},
+					},
+				},
+				"f": &symlink{
+					resource: newResource(defaultSymlinkMode),
+					target:   srcDir.Join("j"),
+				},
+				"x": &file{
+					resource: expectedUserResource,
+					content:  readCloser("content x"),
+				},
+			},
+		},
+	}
+	actual := ManifestFromDir(t, srcDir.Path())
+	assert.DeepEqual(t, actual, expected, cmpManifest)
+	actual.root.items["j"].(*file).content.Close()
+	actual.root.items["x"].(*file).content.Close()
+	actual.root.items["s"].(*directory).items["k"].(*file).content.Close()
+}
+
+var cmpManifest = cmp.Options{
+	cmp.AllowUnexported(Manifest{}, resource{}, file{}, symlink{}, directory{}),
+	cmp.Comparer(func(x, y io.ReadCloser) bool {
+		if x == nil || y == nil {
+			return x == y
+		}
+		xContent, err := ioutil.ReadAll(x)
+		if err != nil {
+			return false
+		}
+
+		yContent, err := ioutil.ReadAll(y)
+		if err != nil {
+			return false
+		}
+		return bytes.Equal(xContent, yContent)
+	}),
+}
+
+func readCloser(s string) io.ReadCloser {
+	return ioutil.NopCloser(strings.NewReader(s))
+}
diff --git a/fs/manifest_unix.go b/fs/manifest_unix.go
new file mode 100644
index 0000000..bba2fcd
--- /dev/null
+++ b/fs/manifest_unix.go
@@ -0,0 +1,30 @@
+// +build !windows
+
+package fs
+
+import (
+	"os"
+	"syscall"
+)
+
+const (
+	defaultRootDirMode = os.ModeDir | 0700
+	defaultSymlinkMode = os.ModeSymlink | 0777
+)
+
+func newResourceFromInfo(info os.FileInfo) resource {
+	statT := info.Sys().(*syscall.Stat_t)
+	return resource{
+		mode: info.Mode(),
+		uid:  statT.Uid,
+		gid:  statT.Gid,
+	}
+}
+
+func (p *filePath) SetMode(mode os.FileMode) {
+	p.file.mode = mode
+}
+
+func (p *directoryPath) SetMode(mode os.FileMode) {
+	p.directory.mode = mode | os.ModeDir
+}
diff --git a/fs/manifest_windows.go b/fs/manifest_windows.go
new file mode 100644
index 0000000..1c1a093
--- /dev/null
+++ b/fs/manifest_windows.go
@@ -0,0 +1,22 @@
+package fs
+
+import "os"
+
+const (
+	defaultRootDirMode = os.ModeDir | 0777
+	defaultSymlinkMode = os.ModeSymlink | 0666
+)
+
+func newResourceFromInfo(info os.FileInfo) resource {
+	return resource{mode: info.Mode()}
+}
+
+func (p *filePath) SetMode(mode os.FileMode) {
+	bits := mode & 0600
+	p.file.mode = bits + bits/010 + bits/0100
+}
+
+// TODO: is mode ignored on windows?
+func (p *directoryPath) SetMode(mode os.FileMode) {
+	p.directory.mode = defaultRootDirMode
+}
diff --git a/fs/ops.go b/fs/ops.go
index bf9d215..ec9d11c 100644
--- a/fs/ops.go
+++ b/fs/ops.go
@@ -1,32 +1,73 @@
 package fs
 
 import (
+	"bytes"
+	"io"
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
+
+	"github.com/pkg/errors"
 )
 
-// PathOp is a function which accepts a Path to perform some operation
+const defaultFileMode = 0644
+
+// PathOp is a function which accepts a Path and performs an operation on that
+// path. When called with real filesystem objects (File or Dir) a PathOp modifies
+// the filesystem at the path. When used with a Manifest object a PathOp updates
+// the manifest to expect a value.
 type PathOp func(path Path) error
 
+type manifestResource interface {
+	SetMode(mode os.FileMode)
+	SetUID(uid uint32)
+	SetGID(gid uint32)
+}
+
+type manifestFile interface {
+	manifestResource
+	SetContent(content io.ReadCloser)
+}
+
+type manifestDirectory interface {
+	manifestResource
+	AddSymlink(path, target string) error
+	AddFile(path string, ops ...PathOp) error
+	AddDirectory(path string, ops ...PathOp) error
+}
+
 // WithContent writes content to a file at Path
 func WithContent(content string) PathOp {
 	return func(path Path) error {
-		return ioutil.WriteFile(path.Path(), []byte(content), 0644)
+		if m, ok := path.(manifestFile); ok {
+			m.SetContent(ioutil.NopCloser(strings.NewReader(content)))
+			return nil
+		}
+		return ioutil.WriteFile(path.Path(), []byte(content), defaultFileMode)
 	}
 }
 
 // WithBytes write bytes to a file at Path
 func WithBytes(raw []byte) PathOp {
 	return func(path Path) error {
-		return ioutil.WriteFile(path.Path(), raw, 0644)
+		if m, ok := path.(manifestFile); ok {
+			m.SetContent(ioutil.NopCloser(bytes.NewReader(raw)))
+			return nil
+		}
+		return ioutil.WriteFile(path.Path(), raw, defaultFileMode)
 	}
 }
 
 // AsUser changes ownership of the file system object at Path
 func AsUser(uid, gid int) PathOp {
 	return func(path Path) error {
+		if m, ok := path.(manifestResource); ok {
+			m.SetUID(uint32(uid))
+			m.SetGID(uint32(gid))
+			return nil
+		}
 		return os.Chown(path.Path(), uid, gid)
 	}
 }
@@ -34,6 +75,11 @@
 // WithFile creates a file in the directory at path with content
 func WithFile(filename, content string, ops ...PathOp) PathOp {
 	return func(path Path) error {
+		if m, ok := path.(manifestDirectory); ok {
+			ops = append([]PathOp{WithContent(content), WithMode(defaultFileMode)}, ops...)
+			return m.AddFile(filename, ops...)
+		}
+
 		fullpath := filepath.Join(path.Path(), filepath.FromSlash(filename))
 		if err := createFile(fullpath, content); err != nil {
 			return err
@@ -43,12 +89,22 @@
 }
 
 func createFile(fullpath string, content string) error {
-	return ioutil.WriteFile(fullpath, []byte(content), 0644)
+	return ioutil.WriteFile(fullpath, []byte(content), defaultFileMode)
 }
 
 // WithFiles creates all the files in the directory at path with their content
 func WithFiles(files map[string]string) PathOp {
 	return func(path Path) error {
+		if m, ok := path.(manifestDirectory); ok {
+			for filename, content := range files {
+				// TODO: remove duplication with WithFile
+				if err := m.AddFile(filename, WithContent(content), WithMode(defaultFileMode)); err != nil {
+					return err
+				}
+			}
+			return nil
+		}
+
 		for filename, content := range files {
 			fullpath := filepath.Join(path.Path(), filepath.FromSlash(filename))
 			if err := createFile(fullpath, content); err != nil {
@@ -62,6 +118,9 @@
 // FromDir copies the directory tree from the source path into the new Dir
 func FromDir(source string) PathOp {
 	return func(path Path) error {
+		if _, ok := path.(manifestDirectory); ok {
+			return errors.New("use manifest.FromDir")
+		}
 		return copyDirectory(source, path.Path())
 	}
 }
@@ -69,9 +128,15 @@
 // WithDir creates a subdirectory in the directory at path. Additional PathOp
 // can be used to modify the subdirectory
 func WithDir(name string, ops ...PathOp) PathOp {
+	const defaultMode = 0755
 	return func(path Path) error {
+		if m, ok := path.(manifestDirectory); ok {
+			ops = append([]PathOp{WithMode(defaultMode)}, ops...)
+			return m.AddDirectory(name, ops...)
+		}
+
 		fullpath := filepath.Join(path.Path(), filepath.FromSlash(name))
-		err := os.MkdirAll(fullpath, 0755)
+		err := os.MkdirAll(fullpath, defaultMode)
 		if err != nil {
 			return err
 		}
@@ -91,6 +156,10 @@
 // WithMode sets the file mode on the directory or file at path
 func WithMode(mode os.FileMode) PathOp {
 	return func(path Path) error {
+		if m, ok := path.(manifestResource); ok {
+			m.SetMode(mode)
+			return nil
+		}
 		return os.Chmod(path.Path(), mode)
 	}
 }
@@ -112,6 +181,7 @@
 			}
 			continue
 		}
+		// TODO: handle symlinks
 		if err := copyFile(sourcePath, destPath); err != nil {
 			return err
 		}
@@ -134,6 +204,9 @@
 // the other functions in this package.
 func WithSymlink(path, target string) PathOp {
 	return func(root Path) error {
+		if v, ok := root.(manifestDirectory); ok {
+			return v.AddSymlink(path, target)
+		}
 		return os.Symlink(filepath.Join(root.Path(), target), filepath.Join(root.Path(), path))
 	}
 }
@@ -145,6 +218,9 @@
 // the other functions in this package.
 func WithHardlink(path, target string) PathOp {
 	return func(root Path) error {
+		if _, ok := root.(manifestDirectory); ok {
+			return errors.New("WithHardlink yet implemented for manifests")
+		}
 		return os.Link(filepath.Join(root.Path(), target), filepath.Join(root.Path(), path))
 	}
 }
@@ -153,6 +229,9 @@
 // at path.
 func WithTimestamps(atime, mtime time.Time) PathOp {
 	return func(root Path) error {
+		if _, ok := root.(manifestDirectory); ok {
+			return errors.New("WithTimestamp yet implemented for manifests")
+		}
 		return os.Chtimes(root.Path(), atime, mtime)
 	}
 }
diff --git a/fs/ops_test.go b/fs/ops_test.go
index 5a25a49..635b0cc 100644
--- a/fs/ops_test.go
+++ b/fs/ops_test.go
@@ -1,25 +1,23 @@
-package fs
+package fs_test
 
 import (
-	"io/ioutil"
 	"testing"
 
 	"github.com/gotestyourself/gotestyourself/assert"
+	"github.com/gotestyourself/gotestyourself/fs"
 )
 
 func TestFromDir(t *testing.T) {
-	dir := NewDir(t, "test-from-dir", FromDir("testdata/copy-test"))
+	dir := fs.NewDir(t, "test-from-dir", fs.FromDir("testdata/copy-test"))
 	defer dir.Remove()
 
-	assertFileWithContent(t, dir.Join("1"), "1\n")
-	assertFileWithContent(t, dir.Join("a/1"), "1\n")
-	assertFileWithContent(t, dir.Join("a/2"), "2\n")
-	assertFileWithContent(t, dir.Join("a/b/1"), "1\n")
-}
+	expected := fs.Expected(t,
+		fs.WithFile("1", "1\n"),
+		fs.WithDir("a",
+			fs.WithFile("1", "1\n"),
+			fs.WithFile("2", "2\n"),
+			fs.WithDir("b",
+				fs.WithFile("1", "1\n"))))
 
-func assertFileWithContent(t *testing.T, path, content string) {
-	actual, err := ioutil.ReadFile(path)
-	assert.NilError(t, err)
-
-	assert.Equal(t, content, string(actual), "file %s", path)
+	assert.Assert(t, fs.Equal(dir.Path(), expected))
 }
diff --git a/fs/path.go b/fs/path.go
new file mode 100644
index 0000000..660ec17
--- /dev/null
+++ b/fs/path.go
@@ -0,0 +1,139 @@
+package fs
+
+import (
+	"bytes"
+	"io"
+	"io/ioutil"
+	"os"
+
+	"github.com/gotestyourself/gotestyourself/assert"
+)
+
+// resourcePath is an adaptor for resources so they can be used as a Path
+// with PathOps.
+type resourcePath struct{}
+
+func (p *resourcePath) Path() string {
+	return "manifest: not a filesystem path"
+}
+
+func (p *resourcePath) Remove() {}
+
+type filePath struct {
+	resourcePath
+	file *file
+}
+
+func (p *filePath) SetContent(content io.ReadCloser) {
+	p.file.content = content
+}
+
+func (p *filePath) SetUID(uid uint32) {
+	p.file.uid = uid
+}
+
+func (p *filePath) SetGID(gid uint32) {
+	p.file.gid = gid
+}
+
+type directoryPath struct {
+	resourcePath
+	directory *directory
+}
+
+func (p *directoryPath) SetUID(uid uint32) {
+	p.directory.uid = uid
+}
+
+func (p *directoryPath) SetGID(gid uint32) {
+	p.directory.gid = gid
+}
+
+func (p *directoryPath) AddSymlink(path, target string) error {
+	p.directory.items[path] = &symlink{
+		resource: newResource(defaultSymlinkMode),
+		target:   target,
+	}
+	return nil
+}
+
+func (p *directoryPath) AddFile(path string, ops ...PathOp) error {
+	newFile := &file{resource: newResource(0)}
+	p.directory.items[path] = newFile
+	exp := &filePath{file: newFile}
+	return applyPathOps(exp, ops)
+}
+
+func (p *directoryPath) AddDirectory(path string, ops ...PathOp) error {
+	newDir := newDirectoryWithDefaults()
+	p.directory.items[path] = newDir
+	exp := &directoryPath{directory: newDir}
+	return applyPathOps(exp, ops)
+}
+
+// Expected returns a Manifest with a directory structured created by ops. The
+// PathOp operations are applied to the manifest as expectations of the
+// filesystem structure and properties.
+func Expected(t assert.TestingT, ops ...PathOp) Manifest {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+
+	newDir := newDirectoryWithDefaults()
+	e := &directoryPath{directory: newDir}
+	assert.NilError(t, applyPathOps(e, ops))
+	return Manifest{root: newDir}
+}
+
+func newDirectoryWithDefaults() *directory {
+	return &directory{
+		resource: newResource(defaultRootDirMode),
+		items:    make(map[string]dirEntry),
+	}
+}
+
+func newResource(mode os.FileMode) resource {
+	return resource{
+		mode: mode,
+		uid:  currentUID(),
+		gid:  currentGID(),
+	}
+}
+
+func currentUID() uint32 {
+	return normalizeID(os.Getuid())
+}
+
+func currentGID() uint32 {
+	return normalizeID(os.Getgid())
+}
+
+func normalizeID(id int) uint32 {
+	// ids will be -1 on windows
+	if id < 0 {
+		return 0
+	}
+	return uint32(id)
+}
+
+var anyFileContent = ioutil.NopCloser(bytes.NewReader(nil))
+
+// MatchAnyFileContent is a PathOp that updates a Manifest so that the file
+// at path may contain any content.
+func MatchAnyFileContent(path Path) error {
+	if m, ok := path.(*filePath); ok {
+		m.SetContent(anyFileContent)
+	}
+	return nil
+}
+
+const anyFile = "*"
+
+// MatchExtraFiles is a PathOp that updates a Manifest to allow a directory
+// to contain unspecified files.
+func MatchExtraFiles(path Path) error {
+	if m, ok := path.(*directoryPath); ok {
+		m.AddFile(anyFile)
+	}
+	return nil
+}
diff --git a/fs/report.go b/fs/report.go
new file mode 100644
index 0000000..669c42a
--- /dev/null
+++ b/fs/report.go
@@ -0,0 +1,215 @@
+package fs
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/gotestyourself/gotestyourself/assert/cmp"
+	"github.com/gotestyourself/gotestyourself/internal/format"
+)
+
+// Equal compares a directory to the expected structured described by a manifest
+// and returns success if they match. If they do not match the failure message
+// will contain all the differences between the directory structure and the
+// expected structure defined by the Manifest.
+//
+// Equal is a cmp.Comparison which can be used with assert.Assert().
+func Equal(path string, expected Manifest) cmp.Comparison {
+	return func() cmp.Result {
+		actual, err := manifestFromDir(path)
+		if err != nil {
+			return cmp.ResultFromError(err)
+		}
+		failures := eqDirectory(string(os.PathSeparator), expected.root, actual.root)
+		if len(failures) == 0 {
+			return cmp.ResultSuccess
+		}
+		msg := fmt.Sprintf("directory %s does not match expected:\n", path)
+		return cmp.ResultFailure(msg + formatFailures(failures))
+	}
+}
+
+type failure struct {
+	path     string
+	problems []problem
+}
+
+type problem string
+
+func notEqual(property string, x, y interface{}) problem {
+	return problem(fmt.Sprintf("%s: expected %s got %s", property, x, y))
+}
+
+func errProblem(reason string, err error) problem {
+	return problem(fmt.Sprintf("%s: %s", reason, err))
+}
+
+func existenceProblem(filename, reason string, args ...interface{}) problem {
+	return problem(filename + ": " + fmt.Sprintf(reason, args...))
+}
+
+func eqResource(x, y resource) []problem {
+	var p []problem
+	if x.uid != y.uid {
+		p = append(p, notEqual("uid", x.uid, y.uid))
+	}
+	if x.gid != y.gid {
+		p = append(p, notEqual("gid", x.gid, y.gid))
+	}
+	if x.mode != y.mode {
+		p = append(p, notEqual("mode", x.mode, y.mode))
+	}
+	return p
+}
+
+func eqFile(x, y *file) []problem {
+	p := eqResource(x.resource, y.resource)
+
+	switch {
+	case x.content == nil:
+		p = append(p, existenceProblem("content", "expected content is nil"))
+		return p
+	case x.content == anyFileContent:
+		return p
+	case y.content == nil:
+		p = append(p, existenceProblem("content", "actual content is nil"))
+		return p
+	}
+
+	xContent, xErr := ioutil.ReadAll(x.content)
+	defer x.content.Close()
+	yContent, yErr := ioutil.ReadAll(y.content)
+	defer y.content.Close()
+
+	if xErr != nil {
+		p = append(p, errProblem("failed to read expected content", xErr))
+	}
+	if yErr != nil {
+		p = append(p, errProblem("failed to read actual content", xErr))
+	}
+	if xErr != nil || yErr != nil {
+		return p
+	}
+
+	if !bytes.Equal(xContent, yContent) {
+		p = append(p, diffContent(xContent, yContent))
+	}
+	return p
+}
+
+func diffContent(x, y []byte) problem {
+	diff := format.UnifiedDiff(format.DiffConfig{
+		A:    string(x),
+		B:    string(y),
+		From: "expected",
+		To:   "actual",
+	})
+	// Remove the trailing newline in the diff. A trailing newline is always
+	// added to a problem by formatFailures.
+	diff = strings.TrimSuffix(diff, "\n")
+	return problem("content:\n" + indent(diff, "    "))
+}
+
+func indent(s, prefix string) string {
+	buf := new(bytes.Buffer)
+	lines := strings.SplitAfter(s, "\n")
+	for _, line := range lines {
+		buf.WriteString(prefix + line)
+	}
+	return buf.String()
+}
+
+func eqSymlink(x, y *symlink) []problem {
+	p := eqResource(x.resource, y.resource)
+	if x.target != y.target {
+		p = append(p, notEqual("target", x.target, y.target))
+	}
+	return p
+}
+
+func eqDirectory(path string, x, y *directory) []failure {
+	p := eqResource(x.resource, y.resource)
+	var f []failure
+
+	for _, name := range sortedKeys(x.items) {
+		if name == anyFile {
+			continue
+		}
+		xEntry := x.items[name]
+		yEntry, ok := y.items[name]
+		if !ok {
+			p = append(p, existenceProblem(name, "expected %s to exist", xEntry.Type()))
+			continue
+		}
+
+		if xEntry.Type() != yEntry.Type() {
+			p = append(p, notEqual(name, xEntry.Type(), yEntry.Type()))
+			continue
+		}
+
+		f = append(f, eqEntry(filepath.Join(path, name), xEntry, yEntry)...)
+	}
+
+	if _, ok := x.items[anyFile]; !ok {
+		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()))
+			}
+		}
+	}
+
+	if len(p) > 0 {
+		f = append(f, failure{path: path, problems: p})
+	}
+	return f
+}
+
+func sortedKeys(items map[string]dirEntry) []string {
+	var keys []string
+	for key := range items {
+		keys = append(keys, key)
+	}
+	sort.Strings(keys)
+	return keys
+}
+
+// eqEntry assumes x and y to be the same type
+func eqEntry(path string, x, y dirEntry) []failure {
+	resp := func(problems []problem) []failure {
+		if len(problems) == 0 {
+			return nil
+		}
+		return []failure{{path: path, problems: problems}}
+	}
+
+	switch typed := x.(type) {
+	case *file:
+		return resp(eqFile(typed, y.(*file)))
+	case *symlink:
+		return resp(eqSymlink(typed, y.(*symlink)))
+	case *directory:
+		return eqDirectory(path, typed, y.(*directory))
+	}
+	return nil
+}
+
+func formatFailures(failures []failure) string {
+	sort.Slice(failures, func(i, j int) bool {
+		return failures[i].path < failures[j].path
+	})
+
+	buf := new(bytes.Buffer)
+	for _, failure := range failures {
+		buf.WriteString(failure.path + "\n")
+		for _, problem := range failure.problems {
+			buf.WriteString("  " + string(problem) + "\n")
+		}
+	}
+	return buf.String()
+}
diff --git a/fs/report_test.go b/fs/report_test.go
new file mode 100644
index 0000000..fde60ac
--- /dev/null
+++ b/fs/report_test.go
@@ -0,0 +1,155 @@
+package fs
+
+import (
+	"fmt"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/gotestyourself/gotestyourself/assert"
+	is "github.com/gotestyourself/gotestyourself/assert/cmp"
+)
+
+func TestEqualMissingRoot(t *testing.T) {
+	result := Equal("/bogus/path/does/not/exist", Expected(t))()
+	assert.Assert(t, !result.Success())
+	expected := "stat /bogus/path/does/not/exist: no such file or directory"
+	if runtime.GOOS == "windows" {
+		expected = "CreateFile /bogus/path/does/not/exist"
+	}
+	assert.Assert(t, is.Contains(result.(cmpFailure).FailureMessage(), expected))
+}
+
+func TestEqualModeMismatch(t *testing.T) {
+	dir := NewDir(t, t.Name(), WithMode(0500))
+	defer dir.Remove()
+
+	result := Equal(dir.Path(), Expected(t))()
+	assert.Assert(t, !result.Success())
+	expected := fmtExpected(`directory %s does not match expected:
+/
+  mode: expected drwx------ got dr-x------
+`, dir.Path())
+	if runtime.GOOS == "windows" {
+		expected = fmtExpected(`directory %s does not match expected:
+\
+  mode: expected drwxrwxrwx got dr-xr-xr-x
+`, dir.Path())
+	}
+	assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+}
+
+func TestEqualRootIsAFile(t *testing.T) {
+	file := NewFile(t, t.Name())
+	defer file.Remove()
+
+	result := Equal(file.Path(), Expected(t))()
+	assert.Assert(t, !result.Success())
+	expected := fmt.Sprintf("path %s must be a directory", file.Path())
+	assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+}
+
+func TestEqualSuccess(t *testing.T) {
+	dir := NewDir(t, t.Name(), WithMode(0700))
+	defer dir.Remove()
+
+	assert.Assert(t, Equal(dir.Path(), Expected(t)))
+}
+
+func TestEqualDirectoryHasWithExtraFiles(t *testing.T) {
+	dir := NewDir(t, t.Name(),
+		WithFile("extra1", "content"))
+	defer dir.Remove()
+
+	manifest := Expected(t, WithFile("file1", "content"))
+	result := Equal(dir.Path(), manifest)()
+	assert.Assert(t, !result.Success())
+	expected := fmtExpected(`directory %s does not match expected:
+/
+  file1: expected file to exist
+  extra1: unexpected file
+`, dir.Path())
+	assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+}
+
+func fmtExpected(format string, args ...interface{}) string {
+	return filepath.FromSlash(fmt.Sprintf(format, args...))
+}
+
+func TestEqualWithAllowAnyFileContent(t *testing.T) {
+	dir := NewDir(t, t.Name(),
+		WithFile("data", "this is some data"))
+	defer dir.Remove()
+
+	expected := Expected(t,
+		WithFile("data", "different content", MatchAnyFileContent))
+	assert.Assert(t, Equal(dir.Path(), expected))
+}
+
+func TestEqualWithFileContent(t *testing.T) {
+	dir := NewDir(t, "assert-test-root",
+		WithFile("file1", "line1\nline2\nline3"))
+	defer dir.Remove()
+
+	manifest := Expected(t,
+		WithFile("file1", "line2\nline3"))
+
+	result := Equal(dir.Path(), manifest)()
+	expected := fmtExpected(`directory %s does not match expected:
+/file1
+  content:
+    --- expected
+    +++ actual
+    @@ -1,2 +1,3 @@
+    +line1
+     line2
+     line3
+`, dir.Path())
+	assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+}
+
+func TestEqualDirectoryWithAllowExtraFiles(t *testing.T) {
+	file1 := WithFile("file1", "same in both")
+	dir := NewDir(t, t.Name(),
+		file1,
+		WithFile("extra", "some content"))
+	defer dir.Remove()
+
+	expected := Expected(t, file1, MatchExtraFiles)
+	assert.Assert(t, Equal(dir.Path(), expected))
+}
+
+func TestEqualManyFailures(t *testing.T) {
+	dir := NewDir(t, t.Name(),
+		WithFile("file1", "same in both"),
+		WithFile("extra", "some content"),
+		WithSymlink("sym1", "extra"))
+	defer dir.Remove()
+
+	manifest := Expected(t,
+		WithDir("subdir",
+			WithFile("somefile", "")),
+		WithFile("file1", "not the\nsame in both"))
+
+	result := Equal(dir.Path(), manifest)()
+	assert.Assert(t, !result.Success())
+
+	expected := fmtExpected(`directory %s does not match expected:
+/
+  subdir: expected directory to exist
+  extra: unexpected file
+  sym1: unexpected symlink
+/file1
+  content:
+    --- expected
+    +++ actual
+    @@ -1,2 +1 @@
+    -not the
+     same in both
+`, dir.Path())
+	assert.Equal(t, result.(cmpFailure).FailureMessage(), expected)
+}
+
+type cmpFailure interface {
+	FailureMessage() string
+}