diff --git a/internal/cmd/between.go b/internal/cmd/between.go index 806a186..1135952 100644 --- a/internal/cmd/between.go +++ b/internal/cmd/between.go @@ -31,6 +31,11 @@ import ( var style string var swap bool +var translateListToDocuments bool +var chroot string +var chrootFrom string +var chrootTo string + // betweenCmd represents the between command var betweenCmd = &cobra.Command{ Use: "between", @@ -57,6 +62,26 @@ document types are: YAML (http://yaml.org/) and JSON (http://json.org/). exitWithError("Failed to load input files", err) } + // If the main change root flag is set, this (re-)sets the individual change roots of the two input files + if chroot != "" { + chrootFrom = chroot + chrootTo = chroot + } + + // Change root of from input file if change root flag for form is set + if chrootFrom != "" { + if err = dyff.ChangeRoot(&from, chrootFrom, translateListToDocuments); err != nil { + exitWithError(fmt.Sprintf("Failed to change root of %s to path %s", from.Location, chrootFrom), err) + } + } + + // Change root of to input file if change root flag for to is set + if chrootTo != "" { + if err = dyff.ChangeRoot(&to, chrootTo, translateListToDocuments); err != nil { + exitWithError(fmt.Sprintf("Failed to change root of %s to path %s", to.Location, chrootTo), err) + } + } + report, err := dyff.CompareInputFiles(from, to) if err != nil { exitWithError("Failed to compare input files", err) @@ -84,8 +109,14 @@ func init() { // TODO Add flag for filter on path betweenCmd.PersistentFlags().StringVarP(&style, "output", "o", "human", "Specify the output style, e.g. 'human' (more to come ...)") - betweenCmd.PersistentFlags().BoolVarP(&swap, "swap", "s", false, "Swap `from` and `to` for compare") + betweenCmd.PersistentFlags().BoolVarP(&swap, "swap", "s", false, "Swap 'from' and 'to' for comparison") + betweenCmd.PersistentFlags().BoolVarP(&dyff.NoTableStyle, "no-table-style", "t", false, "Disable the table output") betweenCmd.PersistentFlags().BoolVarP(&dyff.DoNotInspectCerts, "no-cert-inspection", "c", false, "Disable certificate inspection (compare as raw text)") betweenCmd.PersistentFlags().BoolVarP(&dyff.UseGoPatchPaths, "use-go-patch-style", "g", false, "Use Go-Patch style paths instead of Spruce Dot-Style") + + betweenCmd.PersistentFlags().BoolVar(&translateListToDocuments, "chroot-list-to-documents", false, "usage chroot-list-to-documents") + betweenCmd.PersistentFlags().StringVar(&chroot, "chroot", "", "usage chroot") + betweenCmd.PersistentFlags().StringVar(&chrootFrom, "chroot-of-from", "", "usage chroot from") + betweenCmd.PersistentFlags().StringVar(&chrootTo, "chroot-of-to", "", "usage chroot ro") } diff --git a/pkg/dyff/compare_test.go b/pkg/dyff/compare_test.go index cd48930..6eedddb 100644 --- a/pkg/dyff/compare_test.go +++ b/pkg/dyff/compare_test.go @@ -637,5 +637,40 @@ listY: [ Yo, Yo, Yo ] } }) }) + + Context("change root for comparison", func() { + It("should change the root of an input file", func() { + from := InputFile{Location: "/ginkgo/compare/test/from", Documents: loadTestDocuments(`--- +a: foo +--- +b: bar +`)} + + to := InputFile{Location: "/ginkgo/compare/test/to", Documents: loadTestDocuments(`{ +"items": [ + {"a": "Foo"}, + {"b": "Bar"} +] +}`)} + + err := ChangeRoot(&to, "/items", true) + if err != nil { + Fail(err.Error()) + } + + results, err := CompareInputFiles(from, to) + Expect(err).To(BeNil()) + + expected := []Diff{ + singleDiff("#0/a", MODIFICATION, "foo", "Foo"), + singleDiff("#1/b", MODIFICATION, "bar", "Bar"), + } + + Expect(len(results.Diffs)).To(BeEquivalentTo(len(expected))) + for i, result := range results.Diffs { + Expect(result).To(BeEquivalentTo(expected[i])) + } + }) + }) }) }) diff --git a/pkg/dyff/core.go b/pkg/dyff/core.go index 4dad829..fdd95b1 100644 --- a/pkg/dyff/core.go +++ b/pkg/dyff/core.go @@ -619,7 +619,11 @@ func getValueByKey(mapslice yaml.MapSlice, key string) (interface{}, error) { } } - return nil, fmt.Errorf("no map key %s found in %v", key, mapslice) + if names, err := ListStringKeys(mapslice); err == nil { + return nil, fmt.Errorf("no key '%s' found in map, available keys are: %s", key, strings.Join(names, ", ")) + } + + return nil, fmt.Errorf("no key '%s' found in map and also failed to get a list of key for this map", key) } // getEntryFromNamedList returns the entry that is identified by the identifier key and a name, for example: `name: one` where name is the identifier key and one the name. Function will return nil with bool false if there is no such entry. @@ -667,6 +671,26 @@ func GetIdentifierFromNamedList(list []interface{}) string { return "" } +func listNamesOfNamedList(list []interface{}, identifier string) ([]string, error) { + result := make([]string, len(list)) + for i, entry := range list { + switch entry.(type) { + case yaml.MapSlice: + value, err := getValueByKey(entry.(yaml.MapSlice), identifier) + if err != nil { + return nil, errors.Wrap(err, "unable to list names of a names list") + } + + result[i] = value.(string) + + default: + return nil, fmt.Errorf("unable to list names of a names list, because list entry #%d is not a YAML map but %s", i, typeToName(entry)) + } + } + + return result, nil +} + func createLookUpMap(list []interface{}) (map[uint64]int, error) { result := make(map[uint64]int, len(list)) for idx, entry := range list { @@ -740,3 +764,192 @@ func SimplifyList(input []yaml.MapSlice) []interface{} { return result } + +// StringToPath creates a new Path using the provided serialized path string. In case of Spruce paths, we need the actual tree as a reference to create the correct path. +func StringToPath(path string, obj interface{}) (Path, error) { + elements := make([]PathElement, 0) + + if strings.HasPrefix(path, "/") { // Go-path path in case it starts with a slash + for i, section := range strings.Split(path, "/") { + if i == 0 { + continue + } + + keyNameSplit := strings.Split(section, "=") + switch len(keyNameSplit) { + case 1: + elements = append(elements, PathElement{Name: keyNameSplit[0]}) + + case 2: + elements = append(elements, PathElement{Key: keyNameSplit[0], Name: keyNameSplit[1]}) + + default: + return Path{}, fmt.Errorf("invalid Go-patch style path, element '%s' cannot contain more than one equal sign", section) + } + } + + } else { // Spruce path + pointer := obj + for _, section := range strings.Split(path, ".") { + if isMapSlice(pointer) { + mapslice := pointer.(yaml.MapSlice) + value, err := getValueByKey(mapslice, section) + if err != nil { + return Path{}, errors.Wrap(err, "foobar #1") + } + + pointer = value + elements = append(elements, PathElement{Name: section}) + + } else if isList(pointer) { + list := pointer.([]interface{}) + if id, err := strconv.Atoi(section); err == nil { + if id < 0 || id >= len(list) { + return Path{}, fmt.Errorf("failed to parse path %s, provided list index %d is not in range: 0..%d", path, id, len(list)-1) + } + + pointer = list[id] + elements = append(elements, PathElement{Name: section}) + + } else { + identifier := GetIdentifierFromNamedList(list) + value, ok := getEntryFromNamedList(list, identifier, section) + if !ok { + names, err := listNamesOfNamedList(list, identifier) + if err != nil { + return Path{}, fmt.Errorf("failed to parse path %s, provided named list entry '%s' cannot be found in list", path, section) + } + + return Path{}, fmt.Errorf("failed to parse path %s, provided named list entry '%s' cannot be found in list, available names are: %s", path, section, strings.Join(names, ", ")) + } + + pointer = value + elements = append(elements, PathElement{Key: identifier, Name: section}) + } + } + } + } + + return Path{DocumentIdx: 0, PathElements: elements}, nil +} + +func isList(obj interface{}) bool { + switch obj.(type) { + case []interface{}: + return true + + default: + return false + } +} + +func isMapSlice(obj interface{}) bool { + switch obj.(type) { + case yaml.MapSlice: + return true + + default: + return false + } +} + +// Grab get the value from the provided YAML tree using a path to traverse through the tree structure +func Grab(obj interface{}, pathString string) (interface{}, error) { + path, err := StringToPath(pathString, obj) + if err != nil { + return nil, err + } + + pointer := obj + pointerPath := Path{DocumentIdx: path.DocumentIdx} + + for _, element := range path.PathElements { + if element.Key != "" { // List + if !isList(pointer) { + return nil, fmt.Errorf("failed to traverse tree, expected a list but found type %s at %s", typeToName(pointer), ToGoPatchStyle(pointerPath, false)) + } + + entry, ok := getEntryFromNamedList(pointer.([]interface{}), element.Key, element.Name) + if !ok { + return nil, fmt.Errorf("there is no entry %s: %s in the list", element.Key, element.Name) + } + + pointer = entry + + } else if id, err := strconv.Atoi(element.Name); err == nil { // List (entry referenced by its index) + if !isList(pointer) { + return nil, fmt.Errorf("failed to traverse tree, expected a list but found type %s at %s", typeToName(pointer), ToGoPatchStyle(pointerPath, false)) + } + + list := pointer.([]interface{}) + if id < 0 || id >= len(list) { + return nil, fmt.Errorf("failed to traverse tree, provided list index %d is not in range: 0..%d", id, len(list)-1) + } + + pointer = list[id] + + } else { // Map + if !isMapSlice(pointer) { + return nil, fmt.Errorf("failed to traverse tree, expected a YAML map but found type %s at %s", typeToName(pointer), ToGoPatchStyle(pointerPath, false)) + } + + entry, err := getValueByKey(pointer.(yaml.MapSlice), element.Name) + if err != nil { + return nil, err + } + + pointer = entry + } + + // Update the path that the current pointer has (only used in error case to point to the right position) + pointerPath.PathElements = append(pointerPath.PathElements, element) + } + + return pointer, nil +} + +// ChangeRoot changes the root of an input file to a position inside its document based on the given path. Input files with more than one document are not supported, since they could have multiple elements with that path. +func ChangeRoot(inputFile *InputFile, path string, translateListToDocuments bool) error { + if len(inputFile.Documents) != 1 { + return fmt.Errorf("change root for an input file is only possible if there is only one document, but %s contains %s", + inputFile.Location, + Plural(len(inputFile.Documents), "document")) + } + + // Find the object at the given path + obj, err := Grab(inputFile.Documents[0], path) + if err != nil { + return err + } + + if translateListToDocuments && isList(obj) { + // Change root of input file main document to a new list of documents based on the the list that was found + inputFile.Documents = obj.([]interface{}) + + } else { + // Change root of input file main document to the object that was found + inputFile.Documents = []interface{}{obj} + } + + // Parse path string and create nicely formatted output path + if resolvedPath, err := StringToPath(path, obj); err == nil { + path = PathToString(resolvedPath, false) + } + + inputFile.Note = fmt.Sprintf("YAML root was changed to %s", path) + + return nil +} + +func typeToName(obj interface{}) string { + switch obj.(type) { + case yaml.MapSlice: + return "YAML map" + + case []yaml.MapSlice, []interface{}: + return "YAML list" + + default: + return reflect.TypeOf(obj).Kind().String() + } +} diff --git a/pkg/dyff/core_suite_test.go b/pkg/dyff/core_suite_test.go index cad361e..4d20a02 100644 --- a/pkg/dyff/core_suite_test.go +++ b/pkg/dyff/core_suite_test.go @@ -204,3 +204,35 @@ func compare(from interface{}, to interface{}) ([]Diff, error) { return report.Diffs, nil } + +func loadTestDocuments(input string) []interface{} { + documents, err := LoadDocuments([]byte(input)) + if err != nil { + Fail(err.Error()) + } + + return documents +} + +func grab(obj interface{}, path string) interface{} { + value, err := Grab(obj, path) + if err != nil { + out, _ := ToYAMLString(obj) + Fail(fmt.Sprintf("Failed to grab by path %s from %s", path, out)) + } + + return value +} + +func grabError(obj interface{}, path string) string { + value, err := Grab(obj, path) + Expect(value).To(BeNil()) + return err.Error() +} + +func pathFromString(path string, obj interface{}) Path { + result, err := StringToPath(path, obj) + Expect(err).To(BeNil()) + + return result +} diff --git a/pkg/dyff/core_test.go b/pkg/dyff/core_test.go index 1411cda..1dd6268 100644 --- a/pkg/dyff/core_test.go +++ b/pkg/dyff/core_test.go @@ -245,5 +245,63 @@ list: Expect(output).To(BeEmpty()) }) }) + + Context("Grabing values by path", func() { + It("should create the same path using Go-patch and Spruce style", func() { + obj := yml("../../assets/bosh-yaml/manifest.yml") + Expect(obj).ToNot(BeNil()) + + Expect(pathFromString("/name", obj)).To( + BeEquivalentTo(pathFromString("name", obj))) + + Expect(pathFromString("/releases/name=concourse", obj)).To( + BeEquivalentTo(pathFromString("releases.concourse", obj))) + + Expect(pathFromString("/instance_groups/name=web/networks/name=concourse/static_ips/0", obj)).To( + BeEquivalentTo(pathFromString("instance_groups.web.networks.concourse.static_ips.0", obj))) + + Expect(pathFromString("/networks/name=concourse/subnets/0/cloud_properties/name", obj)).To( + BeEquivalentTo(pathFromString("networks.concourse.subnets.0.cloud_properties.name", obj))) + }) + + It("should return the value referenced by the path", func() { + var example yaml.MapSlice + + example = yml("../../assets/examples/from.yml") + Expect(example).ToNot(BeNil()) + + Expect(grab(example, "/yaml/map/before")).To(BeEquivalentTo("after")) + Expect(grab(example, "/yaml/map/intA")).To(BeEquivalentTo(42)) + Expect(grab(example, "/yaml/map/mapA")).To(BeEquivalentTo(yml(`{ key0: A, key1: A }`))) + Expect(grab(example, "/yaml/map/listA")).To(BeEquivalentTo(list(`[ A, A, A ]`))) + + Expect(grab(example, "/yaml/named-entry-list-using-name/name=B")).To(BeEquivalentTo(yml(`{ name: B }`))) + Expect(grab(example, "/yaml/named-entry-list-using-key/key=B")).To(BeEquivalentTo(yml(`{ key: B }`))) + Expect(grab(example, "/yaml/named-entry-list-using-id/id=B")).To(BeEquivalentTo(yml(`{ id: B }`))) + + Expect(grab(example, "/yaml/simple-list/1")).To(BeEquivalentTo("B")) + Expect(grab(example, "/yaml/named-entry-list-using-key/3")).To(BeEquivalentTo(yml(`{ key: X }`))) + + // --- --- --- + + example = yml("../../assets/bosh-yaml/manifest.yml") + Expect(example).ToNot(BeNil()) + + Expect(grab(example, "/instance_groups/name=web/networks/name=concourse/static_ips/0")).To(BeEquivalentTo("XX.XX.XX.XX")) + Expect(grab(example, "/instance_groups/name=worker/jobs/name=baggageclaim/properties")).To(BeEquivalentTo(yml(`{}`))) + }) + + It("should return the value referenced by the path", func() { + example := yml("../../assets/examples/from.yml") + Expect(example).ToNot(BeNil()) + + Expect(grabError(example, "/yaml/simple-list/-1")).To(BeEquivalentTo("failed to traverse tree, provided list index -1 is not in range: 0..4")) + Expect(grabError(example, "/yaml/does-not-exist")).To(BeEquivalentTo("no key 'does-not-exist' found in map, available keys are: map, simple-list, named-entry-list-using-name, named-entry-list-using-key, named-entry-list-using-id")) + Expect(grabError(example, "/yaml/0")).To(BeEquivalentTo("failed to traverse tree, expected a list but found type YAML map at /yaml")) + Expect(grabError(example, "/yaml/simple-list/foobar")).To(BeEquivalentTo("failed to traverse tree, expected a YAML map but found type YAML list at /yaml/simple-list")) + Expect(grabError(example, "/yaml/map/foobar=0")).To(BeEquivalentTo("failed to traverse tree, expected a list but found type YAML map at /yaml/map")) + Expect(grabError(example, "/yaml/named-entry-list-using-id/id=0")).To(BeEquivalentTo("there is no entry id: 0 in the list")) + }) + }) }) }) diff --git a/pkg/dyff/input.go b/pkg/dyff/input.go index fb46847..174f1ba 100644 --- a/pkg/dyff/input.go +++ b/pkg/dyff/input.go @@ -39,12 +39,14 @@ import ( // InputFile represents the actual input file (either local, or fetched remotely) that needs to be processed. It can contain multiple documents, where a document is a map or a list of things. type InputFile struct { Location string + Note string Documents []interface{} } // HumanReadableLocationInformation create a nicely decorated information about the provided input location. It will output the absolut path of the file (rather than the possibly relative location), or it will show the URL in the usual look-and-feel of URIs. func HumanReadableLocationInformation(inputFile InputFile) string { location := inputFile.Location + note := inputFile.Note documents := len(inputFile.Documents) var buf bytes.Buffer @@ -63,9 +65,16 @@ func HumanReadableLocationInformation(inputFile InputFile) string { buf.WriteString(Color(location, color.FgHiBlue, color.Underline)) } + // Add additional note if it is set + if note != "" { + buf.WriteString(", ") + buf.WriteString(Color(note, color.FgCyan)) + } + // Add an information about how many documents are in the provided input file if documents > 1 { - buf.WriteString(Color(" ("+Plural(documents, "document")+")", color.FgHiCyan)) + buf.WriteString(", ") + buf.WriteString(Color(Plural(documents, "document"), color.FgHiCyan, color.Bold)) } return buf.String() diff --git a/pkg/dyff/output_human.go b/pkg/dyff/output_human.go index a2d6a70..f93feb7 100644 --- a/pkg/dyff/output_human.go +++ b/pkg/dyff/output_human.go @@ -50,7 +50,8 @@ const banner = ` _ __ __ |___/ ` -func pathToString(path Path, showDocumentIdx bool) string { +// PathToString returns a nicely formatted version of the provided path based on the user-preference for the style +func PathToString(path Path, showDocumentIdx bool) string { if UseGoPatchPaths { return ToGoPatchStyle(path, showDocumentIdx) } @@ -89,7 +90,7 @@ func CreateHumanStyleReport(report Report, showBanner bool) string { // generateHumanDiffOutput creates a human readable report of the provided diff and writes this into the given bytes buffer. There is an optional flag to indicate whether the document index (which documents of the input file) should be included in the report of the path of the difference. func generateHumanDiffOutput(output *bytes.Buffer, diff Diff, showDocumentIdx bool) error { output.WriteString("\n") - output.WriteString(pathToString(diff.Path, showDocumentIdx)) + output.WriteString(PathToString(diff.Path, showDocumentIdx)) output.WriteString("\n") blocks := make([]string, len(diff.Details))