package fs import ( "bytes" "fmt" "io" "os" "path/filepath" "runtime" "sort" "strings" "gotest.tools/v3/assert/cmp" "gotest.tools/v3/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 [gotest.tools/v3/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 != anyFileMode && x.mode != y.mode { p = append(p, notEqual("mode", x.mode, y.mode)) } return p } func removeCarriageReturn(in []byte) []byte { return bytes.Replace(in, []byte("\r\n"), []byte("\n"), -1) } 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 := io.ReadAll(x.content) defer x.content.Close() yContent, yErr := io.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 x.compareContentFunc != nil { r := x.compareContentFunc(yContent) if !r.Success() { p = append(p, existenceProblem("content", r.FailureMessage())) } return p } if x.ignoreCariageReturn || y.ignoreCariageReturn { xContent = removeCarriageReturn(xContent) yContent = removeCarriageReturn(yContent) } 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) xTarget := x.target yTarget := y.target if runtime.GOOS == "windows" { xTarget = strings.ToLower(xTarget) yTarget = strings.ToLower(yTarget) } if xTarget != yTarget { 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 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 { 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 len(x.filepathGlobs) != 0 { for _, name := range sortedKeys(y.items) { m := matchGlob(name, y.items[name], x.filepathGlobs) matchedFiles[name] = m.match f = append(f, m.failures...) } } if _, ok := x.items[anyFile]; ok { return maybeAppendFailure(f, path, p) } 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 { keys := make([]string, 0, len(items)) 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 } 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 }) 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() }