Skip to content

Commit a3c8d07

Browse files
authored
feat: improve compare command (#182)
Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
1 parent 9b90212 commit a3c8d07

5 files changed

Lines changed: 1005 additions & 35 deletions

File tree

docs/commands/openfeature_compare.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ openfeature compare [flags]
1515
### Options
1616

1717
```
18-
-a, --against string Path to the target manifest file to compare against
19-
-h, --help help for compare
20-
-o, --output string Output format. Valid formats: tree, flat, json, yaml (default "tree")
18+
-a, --against string Path to the target manifest file to compare against
19+
-h, --help help for compare
20+
-i, --ignore stringArray Field pattern to ignore during comparison (can be specified multiple times). Supports shorthand (e.g., 'description') and full paths with wildcards (e.g., 'flags.*.description', 'metadata.*')
21+
-o, --output string Output format. Valid formats: tree, flat, json, yaml (default "tree")
2122
```
2223

2324
### Options inherited from parent commands

internal/cmd/compare.go

Lines changed: 125 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"reflect"
8+
"sort"
79
"strings"
810

911
"github.com/open-feature/cli/internal/config"
@@ -26,6 +28,7 @@ func GetCompareCmd() *cobra.Command {
2628
sourcePath := config.GetManifestPath(cmd)
2729
targetPath, _ := cmd.Flags().GetString("against")
2830
outputFormat, _ := cmd.Flags().GetString("output")
31+
ignorePatterns, _ := cmd.Flags().GetStringArray("ignore")
2932

3033
// Validate flags
3134
if sourcePath == "" || targetPath == "" {
@@ -49,8 +52,10 @@ func GetCompareCmd() *cobra.Command {
4952
return fmt.Errorf("error loading target manifest: %w", err)
5053
}
5154

52-
// Compare manifests
53-
changes, err := manifest.Compare(sourceManifest, targetManifest)
55+
// Compare manifests with ignore patterns
56+
changes, err := manifest.Compare(sourceManifest, targetManifest, manifest.CompareOptions{
57+
IgnorePatterns: ignorePatterns,
58+
})
5459
if err != nil {
5560
return fmt.Errorf("error comparing manifests: %w", err)
5661
}
@@ -79,6 +84,9 @@ func GetCompareCmd() *cobra.Command {
7984
compareCmd.Flags().StringP("against", "a", "", "Path to the target manifest file to compare against")
8085
compareCmd.Flags().StringP("output", "o", string(manifest.OutputFormatTree),
8186
fmt.Sprintf("Output format. Valid formats: %s", strings.Join(manifest.GetValidOutputFormats(), ", ")))
87+
compareCmd.Flags().StringArrayP("ignore", "i", []string{},
88+
"Field pattern to ignore during comparison (can be specified multiple times). "+
89+
"Supports shorthand (e.g., 'description') and full paths with wildcards (e.g., 'flags.*.description', 'metadata.*')")
8290

8391
// Mark required flags
8492
_ = compareCmd.MarkFlagRequired("against")
@@ -156,26 +164,128 @@ func renderTreeDiff(changes []manifest.Change, cmd *cobra.Command) error {
156164
flagName := strings.TrimPrefix(change.Path, "flags.")
157165
pterm.FgYellow.Printf(" ~ %s\n", flagName)
158166

159-
// Marshall the values
160-
oldJSON, _ := json.MarshalIndent(change.OldValue, "", " ")
161-
newJSON, _ := json.MarshalIndent(change.NewValue, "", " ")
162-
163-
// Print the diff
164-
fmt.Println(" Before:")
165-
for _, line := range strings.Split(string(oldJSON), "\n") {
166-
fmt.Printf(" %s\n", line)
167-
}
168-
169-
fmt.Println(" After:")
170-
for _, line := range strings.Split(string(newJSON), "\n") {
171-
fmt.Printf(" %s\n", line)
167+
// Show field-level diff
168+
fieldChanges := getFieldChanges(flagName, change.OldValue, change.NewValue)
169+
if len(fieldChanges) > 0 {
170+
for _, fc := range fieldChanges {
171+
fmt.Printf(" • %s: %s → %s\n", fc.Field, fc.OldValue, fc.NewValue)
172+
}
173+
} else {
174+
// Fallback to full object display if we can't parse
175+
oldJSON, _ := json.MarshalIndent(change.OldValue, " ", " ")
176+
newJSON, _ := json.MarshalIndent(change.NewValue, " ", " ")
177+
fmt.Println(" Before:")
178+
fmt.Printf(" %s\n", oldJSON)
179+
fmt.Println(" After:")
180+
fmt.Printf(" %s\n", newJSON)
172181
}
173182
}
174183
}
175184

176185
return nil
177186
}
178187

188+
// fieldChange represents a change to a specific field
189+
type fieldChange struct {
190+
Field string
191+
OldValue string
192+
NewValue string
193+
}
194+
195+
// getFieldChanges extracts field-level changes between two flag objects
196+
func getFieldChanges(flagName string, oldVal, newVal any) []fieldChange {
197+
var changes []fieldChange
198+
199+
// Convert to maps
200+
oldMap, oldOk := oldVal.(map[string]any)
201+
newMap, newOk := newVal.(map[string]any)
202+
203+
if !oldOk || !newOk {
204+
return changes // Return empty if not maps
205+
}
206+
207+
// Get all unique field names
208+
allFields := make(map[string]bool)
209+
for field := range oldMap {
210+
allFields[field] = true
211+
}
212+
for field := range newMap {
213+
allFields[field] = true
214+
}
215+
216+
// Compare each field
217+
// Note: Fields are already filtered at the Compare() level, so we don't need to filter here
218+
for field := range allFields {
219+
oldFieldVal, oldExists := oldMap[field]
220+
newFieldVal, newExists := newMap[field]
221+
222+
// Field was added
223+
if !oldExists && newExists {
224+
changes = append(changes, fieldChange{
225+
Field: field,
226+
OldValue: "(not set)",
227+
NewValue: formatFieldValue(newFieldVal),
228+
})
229+
continue
230+
}
231+
232+
// Field was removed
233+
if oldExists && !newExists {
234+
changes = append(changes, fieldChange{
235+
Field: field,
236+
OldValue: formatFieldValue(oldFieldVal),
237+
NewValue: "(removed)",
238+
})
239+
continue
240+
}
241+
242+
// Field changed
243+
if !reflect.DeepEqual(oldFieldVal, newFieldVal) {
244+
changes = append(changes, fieldChange{
245+
Field: field,
246+
OldValue: formatFieldValue(oldFieldVal),
247+
NewValue: formatFieldValue(newFieldVal),
248+
})
249+
}
250+
}
251+
252+
// Sort changes by field name for consistent output
253+
sort.Slice(changes, func(i, j int) bool {
254+
return changes[i].Field < changes[j].Field
255+
})
256+
257+
return changes
258+
}
259+
260+
// formatFieldValue converts a value to a human-readable string for field-level diff display
261+
func formatFieldValue(val any) string {
262+
if val == nil {
263+
return "null"
264+
}
265+
266+
switch v := val.(type) {
267+
case string:
268+
return fmt.Sprintf("%q", v)
269+
case bool:
270+
return fmt.Sprintf("%t", v)
271+
case float64:
272+
// Check if it's actually an integer
273+
if v == float64(int64(v)) {
274+
return fmt.Sprintf("%d", int64(v))
275+
}
276+
return fmt.Sprintf("%g", v)
277+
case map[string]any, []any:
278+
// For complex types, marshal to compact JSON
279+
jsonBytes, err := json.Marshal(v)
280+
if err != nil {
281+
return fmt.Sprintf("%v", v)
282+
}
283+
return string(jsonBytes)
284+
default:
285+
return fmt.Sprintf("%v", v)
286+
}
287+
}
288+
179289
// renderFlatDiff renders changes in a flat format
180290
func renderFlatDiff(changes []manifest.Change, cmd *cobra.Command) error {
181291
pterm.Info.Printf("Found %d difference(s) between manifests:\n\n", len(changes))

internal/cmd/compare_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"fmt"
6+
"io"
7+
"os"
58
"testing"
69

710
"github.com/stretchr/testify/assert"
@@ -21,6 +24,10 @@ func TestGetCompareCmd(t *testing.T) {
2124
outputFlag := cmd.Flag("output")
2225
assert.NotNil(t, outputFlag)
2326
assert.Equal(t, "tree", outputFlag.DefValue)
27+
28+
// Verify ignore flag
29+
ignoreFlag := cmd.Flag("ignore")
30+
assert.NotNil(t, ignoreFlag)
2431
}
2532

2633
func TestCompareManifests(t *testing.T) {
@@ -48,3 +55,109 @@ func TestCompareManifests(t *testing.T) {
4855
})
4956
}
5057
}
58+
59+
// captureStdout captures stdout during test execution
60+
func captureStdout(f func()) string {
61+
old := os.Stdout
62+
r, w, _ := os.Pipe()
63+
os.Stdout = w
64+
65+
f()
66+
67+
w.Close()
68+
os.Stdout = old
69+
70+
var buf bytes.Buffer
71+
_, err := io.Copy(&buf, r)
72+
if err != nil {
73+
fmt.Fprintf(os.Stderr, "captureStdout: error copying output: %v\n", err)
74+
}
75+
return buf.String()
76+
}
77+
78+
func TestCompareWithIgnoreFlag(t *testing.T) {
79+
// Test that the ignore flag is properly parsed and passed to comparison
80+
81+
t.Run("single_ignore_pattern", func(t *testing.T) {
82+
output := captureStdout(func() {
83+
rootCmd := GetRootCmd()
84+
85+
rootCmd.SetArgs([]string{
86+
"compare",
87+
"--manifest", "testdata/source_manifest.json",
88+
"--against", "testdata/target_manifest.json",
89+
"--ignore", "description",
90+
})
91+
92+
err := rootCmd.Execute()
93+
assert.NoError(t, err, "Command should execute with single ignore pattern")
94+
})
95+
96+
// Verify that the output doesn't contain description changes in the field-level diff
97+
// The word "description" may still appear in JSON for additions/removals, which is fine
98+
assert.NotContains(t, output, "• description:",
99+
"Output should not show description field changes when it's ignored")
100+
})
101+
102+
t.Run("multiple_ignore_patterns", func(t *testing.T) {
103+
output := captureStdout(func() {
104+
rootCmd := GetRootCmd()
105+
106+
rootCmd.SetArgs([]string{
107+
"compare",
108+
"--manifest", "testdata/source_manifest.json",
109+
"--against", "testdata/target_manifest.json",
110+
"--ignore", "description",
111+
"--ignore", "metadata.*",
112+
})
113+
114+
err := rootCmd.Execute()
115+
assert.NoError(t, err, "Command should execute with multiple ignore patterns")
116+
})
117+
118+
// Verify that the output doesn't contain ignored fields in the field-level diff
119+
assert.NotContains(t, output, "• description:",
120+
"Output should not show description field changes when it's ignored")
121+
assert.NotContains(t, output, "• metadata",
122+
"Output should not show metadata field changes when it's ignored")
123+
})
124+
125+
t.Run("ignore_with_wildcard", func(t *testing.T) {
126+
output := captureStdout(func() {
127+
rootCmd := GetRootCmd()
128+
129+
rootCmd.SetArgs([]string{
130+
"compare",
131+
"--manifest", "testdata/source_manifest.json",
132+
"--against", "testdata/target_manifest.json",
133+
"--ignore", "flags.*.description",
134+
})
135+
136+
err := rootCmd.Execute()
137+
assert.NoError(t, err, "Command should execute with wildcard ignore pattern")
138+
})
139+
140+
// Verify that the output doesn't contain description changes in the field-level diff
141+
assert.NotContains(t, output, "• description:",
142+
"Output should not show description field changes when using wildcard pattern 'flags.*.description'")
143+
})
144+
145+
t.Run("without_ignore_shows_description", func(t *testing.T) {
146+
output := captureStdout(func() {
147+
rootCmd := GetRootCmd()
148+
149+
rootCmd.SetArgs([]string{
150+
"compare",
151+
"--manifest", "testdata/source_manifest.json",
152+
"--against", "testdata/target_manifest.json",
153+
})
154+
155+
err := rootCmd.Execute()
156+
assert.NoError(t, err, "Command should execute without ignore pattern")
157+
})
158+
159+
// Verify that the output DOES contain description field changes when not ignored
160+
assert.Contains(t, output, "• description:",
161+
"Output should show description field changes when it's not ignored")
162+
})
163+
}

0 commit comments

Comments
 (0)