Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/commands/openfeature_compare.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ openfeature compare [flags]
### Options

```
-a, --against string Path to the target manifest file to compare against
-h, --help help for compare
-o, --output string Output format. Valid formats: tree, flat, json, yaml (default "tree")
-a, --against string Path to the target manifest file to compare against
-h, --help help for compare
-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.*')
-o, --output string Output format. Valid formats: tree, flat, json, yaml (default "tree")
```

### Options inherited from parent commands
Expand Down
140 changes: 125 additions & 15 deletions internal/cmd/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"os"
"reflect"
"sort"
"strings"

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

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

// Compare manifests
changes, err := manifest.Compare(sourceManifest, targetManifest)
// Compare manifests with ignore patterns
changes, err := manifest.Compare(sourceManifest, targetManifest, manifest.CompareOptions{
IgnorePatterns: ignorePatterns,
})
if err != nil {
return fmt.Errorf("error comparing manifests: %w", err)
}
Expand Down Expand Up @@ -79,6 +84,9 @@ func GetCompareCmd() *cobra.Command {
compareCmd.Flags().StringP("against", "a", "", "Path to the target manifest file to compare against")
compareCmd.Flags().StringP("output", "o", string(manifest.OutputFormatTree),
fmt.Sprintf("Output format. Valid formats: %s", strings.Join(manifest.GetValidOutputFormats(), ", ")))
compareCmd.Flags().StringArrayP("ignore", "i", []string{},
"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.*')")

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

// Marshall the values
oldJSON, _ := json.MarshalIndent(change.OldValue, "", " ")
newJSON, _ := json.MarshalIndent(change.NewValue, "", " ")

// Print the diff
fmt.Println(" Before:")
for _, line := range strings.Split(string(oldJSON), "\n") {
fmt.Printf(" %s\n", line)
}

fmt.Println(" After:")
for _, line := range strings.Split(string(newJSON), "\n") {
fmt.Printf(" %s\n", line)
// Show field-level diff
fieldChanges := getFieldChanges(flagName, change.OldValue, change.NewValue)
if len(fieldChanges) > 0 {
for _, fc := range fieldChanges {
fmt.Printf(" • %s: %s → %s\n", fc.Field, fc.OldValue, fc.NewValue)
}
} else {
// Fallback to full object display if we can't parse
oldJSON, _ := json.MarshalIndent(change.OldValue, " ", " ")
newJSON, _ := json.MarshalIndent(change.NewValue, " ", " ")
fmt.Println(" Before:")
fmt.Printf(" %s\n", oldJSON)
fmt.Println(" After:")
fmt.Printf(" %s\n", newJSON)
}
}
}

return nil
}

// fieldChange represents a change to a specific field
type fieldChange struct {
Field string
OldValue string
NewValue string
}

// getFieldChanges extracts field-level changes between two flag objects
func getFieldChanges(flagName string, oldVal, newVal any) []fieldChange {
var changes []fieldChange

// Convert to maps
oldMap, oldOk := oldVal.(map[string]any)
newMap, newOk := newVal.(map[string]any)

if !oldOk || !newOk {
return changes // Return empty if not maps
}

// Get all unique field names
allFields := make(map[string]bool)
for field := range oldMap {
allFields[field] = true
}
for field := range newMap {
allFields[field] = true
}

// Compare each field
// Note: Fields are already filtered at the Compare() level, so we don't need to filter here
for field := range allFields {
oldFieldVal, oldExists := oldMap[field]
newFieldVal, newExists := newMap[field]

// Field was added
if !oldExists && newExists {
changes = append(changes, fieldChange{
Field: field,
OldValue: "(not set)",
NewValue: formatFieldValue(newFieldVal),
})
continue
}

// Field was removed
if oldExists && !newExists {
changes = append(changes, fieldChange{
Field: field,
OldValue: formatFieldValue(oldFieldVal),
NewValue: "(removed)",
})
continue
}

// Field changed
if !reflect.DeepEqual(oldFieldVal, newFieldVal) {
changes = append(changes, fieldChange{
Field: field,
OldValue: formatFieldValue(oldFieldVal),
NewValue: formatFieldValue(newFieldVal),
})
}
}

// Sort changes by field name for consistent output
sort.Slice(changes, func(i, j int) bool {
return changes[i].Field < changes[j].Field
})

return changes
}

// formatFieldValue converts a value to a human-readable string for field-level diff display
func formatFieldValue(val any) string {
if val == nil {
return "null"
}

switch v := val.(type) {
case string:
return fmt.Sprintf("%q", v)
case bool:
return fmt.Sprintf("%t", v)
case float64:
// Check if it's actually an integer
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return fmt.Sprintf("%g", v)
case map[string]any, []any:
// For complex types, marshal to compact JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(jsonBytes)
default:
return fmt.Sprintf("%v", v)
}
}

// renderFlatDiff renders changes in a flat format
func renderFlatDiff(changes []manifest.Change, cmd *cobra.Command) error {
pterm.Info.Printf("Found %d difference(s) between manifests:\n\n", len(changes))
Expand Down
113 changes: 113 additions & 0 deletions internal/cmd/compare_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package cmd

import (
"bytes"
"fmt"
"io"
"os"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -21,6 +24,10 @@ func TestGetCompareCmd(t *testing.T) {
outputFlag := cmd.Flag("output")
assert.NotNil(t, outputFlag)
assert.Equal(t, "tree", outputFlag.DefValue)

// Verify ignore flag
ignoreFlag := cmd.Flag("ignore")
assert.NotNil(t, ignoreFlag)
}

func TestCompareManifests(t *testing.T) {
Expand Down Expand Up @@ -48,3 +55,109 @@ func TestCompareManifests(t *testing.T) {
})
}
}

// captureStdout captures stdout during test execution
func captureStdout(f func()) string {
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

f()

w.Close()
os.Stdout = old

var buf bytes.Buffer
_, err := io.Copy(&buf, r)
if err != nil {
fmt.Fprintf(os.Stderr, "captureStdout: error copying output: %v\n", err)
}
return buf.String()
}

func TestCompareWithIgnoreFlag(t *testing.T) {
// Test that the ignore flag is properly parsed and passed to comparison

t.Run("single_ignore_pattern", func(t *testing.T) {
output := captureStdout(func() {
rootCmd := GetRootCmd()

rootCmd.SetArgs([]string{
"compare",
"--manifest", "testdata/source_manifest.json",
"--against", "testdata/target_manifest.json",
"--ignore", "description",
})

err := rootCmd.Execute()
assert.NoError(t, err, "Command should execute with single ignore pattern")
})

// Verify that the output doesn't contain description changes in the field-level diff
// The word "description" may still appear in JSON for additions/removals, which is fine
assert.NotContains(t, output, "• description:",
"Output should not show description field changes when it's ignored")
})

t.Run("multiple_ignore_patterns", func(t *testing.T) {
output := captureStdout(func() {
rootCmd := GetRootCmd()

rootCmd.SetArgs([]string{
"compare",
"--manifest", "testdata/source_manifest.json",
"--against", "testdata/target_manifest.json",
"--ignore", "description",
"--ignore", "metadata.*",
})

err := rootCmd.Execute()
assert.NoError(t, err, "Command should execute with multiple ignore patterns")
})

// Verify that the output doesn't contain ignored fields in the field-level diff
assert.NotContains(t, output, "• description:",
"Output should not show description field changes when it's ignored")
assert.NotContains(t, output, "• metadata",
"Output should not show metadata field changes when it's ignored")
})

t.Run("ignore_with_wildcard", func(t *testing.T) {
output := captureStdout(func() {
rootCmd := GetRootCmd()

rootCmd.SetArgs([]string{
"compare",
"--manifest", "testdata/source_manifest.json",
"--against", "testdata/target_manifest.json",
"--ignore", "flags.*.description",
})

err := rootCmd.Execute()
assert.NoError(t, err, "Command should execute with wildcard ignore pattern")
})

// Verify that the output doesn't contain description changes in the field-level diff
assert.NotContains(t, output, "• description:",
"Output should not show description field changes when using wildcard pattern 'flags.*.description'")
})

t.Run("without_ignore_shows_description", func(t *testing.T) {
output := captureStdout(func() {
rootCmd := GetRootCmd()

rootCmd.SetArgs([]string{
"compare",
"--manifest", "testdata/source_manifest.json",
"--against", "testdata/target_manifest.json",
})

err := rootCmd.Execute()
assert.NoError(t, err, "Command should execute without ignore pattern")
})

// Verify that the output DOES contain description field changes when not ignored
assert.Contains(t, output, "• description:",
"Output should show description field changes when it's not ignored")
})
}
Loading
Loading