diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ ## Usage +### Basic Usage + ```go package package_test @@ -22,6 +24,134 @@ freeze.Snap(t, result) } ``` + +### Advanced Usage: Scrubbers and Ignore Patterns + +Freeze supports data scrubbing and field filtering to handle dynamic or sensitive data in snapshots. + +#### Scrubbers + +Scrubbers transform content before snapshotting, typically to replace dynamic or sensitive data with placeholders: + +```go +func TestUserAPI(t *testing.T) { + user := api.GetUser("123") + + // Replace UUIDs and timestamps with placeholders + freeze.SnapWithOptions(t, "user", []freeze.SnapshotOption{ + freeze.ScrubUUIDs(), + freeze.ScrubTimestamps(), + }, user) +} +``` + +**Built-in Scrubbers:** +- `ScrubUUIDs()` - Replaces UUIDs with `` +- `ScrubTimestamps()` - Replaces ISO8601 timestamps with `` +- `ScrubEmails()` - Replaces email addresses with `` +- `ScrubIPAddresses()` - Replaces IPv4 addresses with `` +- `ScrubJWTs()` - Replaces JWT tokens with `` +- `ScrubCreditCards()` - Replaces credit card numbers with `` +- `ScrubAPIKeys()` - Replaces API keys with `` +- `ScrubDates()` - Replaces various date formats with `` +- `ScrubUnixTimestamps()` - Replaces Unix timestamps with `` + +**Custom Scrubbers:** + +```go +// Using regex patterns +freeze.RegexScrubber(`user-\d+`, "") + +// Using exact string matching +freeze.ExactMatchScrubber("secret_value", "") + +// Using custom functions +freeze.CustomScrubber(func(content string) string { + return strings.ReplaceAll(content, "localhost", "") +}) +``` + +#### Ignore Patterns + +Ignore patterns remove specific fields from JSON structures before snapshotting: + +```go +func TestAPIResponse(t *testing.T) { + response := api.GetData() + + // Ignore sensitive fields and null values + freeze.SnapJSONWithOptions(t, "response", response, []freeze.SnapshotOption{ + freeze.IgnoreSensitiveKeys(), + freeze.IgnoreNullValues(), + freeze.IgnoreKeys("created_at", "updated_at"), + }) +} +``` + +**Built-in Ignore Patterns:** +- `IgnoreSensitiveKeys()` - Ignores common sensitive keys (password, token, api_key, etc.) +- `IgnoreEmptyValues()` - Ignores fields with empty string values +- `IgnoreNullValues()` - Ignores fields with null values + +**Custom Ignore Patterns:** + +```go +// Ignore specific keys +freeze.IgnoreKeys("id", "timestamp", "version") + +// Ignore key-value pairs +freeze.IgnoreKeyValue("status", "pending") + +// Ignore keys matching a regex pattern +freeze.IgnoreKeysMatching(`^_.*`) // Ignore all keys starting with underscore + +// Ignore specific values +freeze.IgnoreValues("null", "undefined", "") + +// Using custom functions +freeze.CustomIgnore(func(key, value string) bool { + return strings.HasPrefix(key, "temp_") +}) +``` + +#### Combining Options + +You can combine multiple scrubbers and ignore patterns: + +```go +func TestComplexData(t *testing.T) { + data := generateTestData() + + freeze.SnapWithOptions(t, "data", []freeze.SnapshotOption{ + // Scrubbers + freeze.ScrubUUIDs(), + freeze.ScrubTimestamps(), + freeze.ScrubEmails(), + + // Ignore patterns + freeze.IgnoreSensitiveKeys(), + freeze.IgnoreKeys("debug_info"), + freeze.IgnoreNullValues(), + }, data) +} +``` + +#### API Reference + +Three snapshot functions support options: + +```go +// For general values (structs, maps, slices, etc.) +freeze.SnapWithOptions(t, "title", []freeze.SnapshotOption{...}, value) + +// For JSON strings +freeze.SnapJSONWithOptions(t, "title", jsonString, []freeze.SnapshotOption{...}) + +// For plain strings +freeze.SnapStringWithOptions(t, "title", content, []freeze.SnapshotOption{...}) +``` + +### Reviewing Snapshots To review a set of snapshots, run: diff --git a/config.go b/config.go new file mode 100644 --- /dev/null +++ b/config.go @@ -0,0 +1,36 @@ +package freeze + +// SnapshotOption is a function that configures a SnapshotConfig. +type SnapshotOption func(*SnapshotConfig) + +// SnapshotConfig holds configuration for snapshot scrubbing and filtering. +type SnapshotConfig struct { + Scrubbers []Scrubber + Ignore []IgnorePattern +} + +// newSnapshotConfig creates a new SnapshotConfig with the given options applied. +func newSnapshotConfig(opts []SnapshotOption) *SnapshotConfig { + config := &SnapshotConfig{ + Scrubbers: []Scrubber{}, + Ignore: []IgnorePattern{}, + } + for _, opt := range opts { + opt(config) + } + return config +} + +// WithScrubber adds a custom scrubber to the configuration. +func WithScrubber(scrubber Scrubber) SnapshotOption { + return func(c *SnapshotConfig) { + c.Scrubbers = append(c.Scrubbers, scrubber) + } +} + +// WithIgnorePattern adds a custom ignore pattern to the configuration. +func WithIgnorePattern(pattern IgnorePattern) SnapshotOption { + return func(c *SnapshotConfig) { + c.Ignore = append(c.Ignore, pattern) + } +} diff --git a/freeze.go b/freeze.go --- a/freeze.go +++ b/freeze.go @@ -1,7 +1,6 @@ package freeze import ( - "encoding/json" "fmt" "github.com/kortschak/utter" @@ -9,6 +8,7 @@ "github.com/ptdewey/freeze/internal/files" "github.com/ptdewey/freeze/internal/pretty" "github.com/ptdewey/freeze/internal/review" + "github.com/ptdewey/freeze/internal/transform" ) const version = "0.1.0" @@ -21,7 +21,18 @@ func SnapString(t testingT, title string, content string) { t.Helper() - snap(t, title, content) + SnapStringWithOptions(t, title, content, nil) +} + +// SnapStringWithOptions takes a string and applies scrubbers before snapshotting. +func SnapStringWithOptions(t testingT, title string, content string, opts []SnapshotOption) { + t.Helper() + config := newSnapshotConfig(opts) + + // Apply scrubbers to the content + scrubbedContent := transform.ApplyScrubbers(content, adaptScrubbers(config.Scrubbers)) + + snap(t, title, scrubbedContent) } // SnapJSON takes a JSON string, validates it, and pretty-prints it with @@ -29,27 +40,48 @@ // format while ensuring valid JSON structure. func SnapJSON(t testingT, title string, jsonStr string) { t.Helper() + SnapJSONWithOptions(t, title, jsonStr, nil) +} - var data interface{} - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - t.Error("failed to unmarshal JSON:", err) - return +// SnapJSONWithOptions takes a JSON string and applies scrubbers and ignore patterns +// before snapshotting. This allows filtering sensitive data and normalizing dynamic values. +func SnapJSONWithOptions(t testingT, title string, jsonStr string, opts []SnapshotOption) { + t.Helper() + + config := newSnapshotConfig(opts) + + // Transform the JSON with ignore patterns and scrubbers + transformConfig := &transform.Config{ + Scrubbers: adaptScrubbers(config.Scrubbers), + Ignore: adaptIgnorePatterns(config.Ignore), } - // Pretty-print the JSON with consistent indentation - prettyJSON, err := json.MarshalIndent(data, "", " ") + transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig) if err != nil { - t.Error("failed to marshal JSON:", err) + t.Error("failed to transform JSON:", err) return } - snap(t, title, string(prettyJSON)) + snap(t, title, transformedJSON) } func Snap(t testingT, title string, values ...any) { t.Helper() + SnapWithOptions(t, title, nil, values...) +} + +// SnapWithOptions takes any values, formats them, and applies scrubbers before snapshotting. +// For structured data (maps, slices, structs), scrubbers are applied to the formatted output. +func SnapWithOptions(t testingT, title string, opts []SnapshotOption, values ...any) { + t.Helper() + config := newSnapshotConfig(opts) + content := formatValues(values...) - snap(t, title, content) + + // Apply scrubbers to the formatted content + scrubbedContent := transform.ApplyScrubbers(content, adaptScrubbers(config.Scrubbers)) + + snap(t, title, scrubbedContent) } func snap(t testingT, title string, content string) { @@ -141,4 +173,38 @@ Error(...any) Log(...any) Cleanup(func()) +} + +// Adapter types to bridge freeze package types with transform package types + +type scrubberAdapter struct { + scrubber Scrubber +} + +func (s *scrubberAdapter) Scrub(content string) string { + return s.scrubber.Scrub(content) +} + +func adaptScrubbers(scrubbers []Scrubber) []transform.Scrubber { + result := make([]transform.Scrubber, len(scrubbers)) + for i, s := range scrubbers { + result[i] = &scrubberAdapter{scrubber: s} + } + return result +} + +type ignorePatternAdapter struct { + pattern IgnorePattern +} + +func (i *ignorePatternAdapter) ShouldIgnore(key, value string) bool { + return i.pattern.ShouldIgnore(key, value) +} + +func adaptIgnorePatterns(patterns []IgnorePattern) []transform.IgnorePattern { + result := make([]transform.IgnorePattern, len(patterns)) + for i, p := range patterns { + result[i] = &ignorePatternAdapter{pattern: p} + } + return result } diff --git a/freeze_test.go b/freeze_test.go --- a/freeze_test.go +++ b/freeze_test.go @@ -214,7 +214,6 @@ // COMPLEX GO STRUCTURES TESTS // ============================================================================ -// User represents a user in a system type User struct { ID int Username string @@ -225,7 +224,6 @@ Metadata map[string]interface{} } -// Post represents a blog post type Post struct { ID int Title string @@ -238,7 +236,6 @@ CreatedAt time.Time } -// Comment represents a comment on a post type Comment struct { ID int Author string @@ -247,7 +244,6 @@ Replies []Comment } -// TestComplexNestedStructure tests snapshot with deeply nested Go structures func TestComplexNestedStructure(t *testing.T) { user := User{ ID: 1, @@ -307,7 +303,6 @@ freeze.Snap(t, "Complex Nested Structure", post) } -// TestMultipleComplexStructures tests snapshot with multiple complex structures func TestMultipleComplexStructures(t *testing.T) { users := []User{ { @@ -348,7 +343,6 @@ freeze.Snap(t, "Multiple Complex Structures", users) } -// TestStructureWithInterface tests structures containing interface{} fields func TestStructureWithInterface(t *testing.T) { type Response struct { Status string @@ -402,7 +396,6 @@ freeze.Snap(t, "Structure with Interface Fields", responses) } -// TestNestedMapsAndSlices tests complex nested maps and slices func TestNestedMapsAndSlices(t *testing.T) { complexData := map[string]interface{}{ "users": map[string]interface{}{ @@ -449,7 +442,6 @@ freeze.Snap(t, "Nested Maps and Slices", complexData) } -// TestStructureWithPointers tests structures with pointer fields func TestStructureWithPointers(t *testing.T) { type Address struct { Street string @@ -492,7 +484,6 @@ freeze.Snap(t, "Structure with Pointers", person2) } -// TestStructureWithEmptyValues tests structures with empty slices, maps, nil values func TestStructureWithEmptyValues(t *testing.T) { type Container struct { Items []string @@ -520,7 +511,7 @@ { Items: []string{"a", "b", "c"}, Tags: map[string]string{"type": "test", "env": "dev"}, - OptionalID: intPtr(42), + OptionalID: ptr(42), Count: 3, Active: true, }, @@ -533,7 +524,6 @@ // JSON OBJECT TESTS // ============================================================================ -// TestJSONObject tests snapshot with JSON objects func TestJsonObject(t *testing.T) { jsonStr := `{ "user": { @@ -561,7 +551,6 @@ freeze.Snap(t, "JSON Object", data) } -// TestComplexJSONStructure tests complex nested JSON structures func TestComplexJsonStructure(t *testing.T) { jsonStr := `{ "api": { @@ -630,7 +619,6 @@ freeze.Snap(t, "Complex JSON Structure", data) } -// TestJSONArrayOfObjects tests JSON arrays with multiple object types func TestJsonArrayOfObjects(t *testing.T) { jsonStr := `[ { @@ -669,7 +657,6 @@ freeze.Snap(t, "JSON Array of Objects", data) } -// TestJSONWithVariousTypes tests JSON with various data types func TestJsonWithVariousTypes(t *testing.T) { jsonStr := `{ "string": "hello world", @@ -696,7 +683,6 @@ freeze.Snap(t, "JSON with Various Types", data) } -// TestJSONNumbers tests JSON with various number formats func TestJsonNumbers(t *testing.T) { jsonStr := `{ "integers": { @@ -725,7 +711,6 @@ freeze.Snap(t, "JSON Numbers", data) } -// TestJSONWithSpecialCharacters tests JSON with special characters and unicode func TestJsonWithSpecialCharacters(t *testing.T) { jsonStr := `{ "english": "Hello, World!", @@ -746,7 +731,6 @@ freeze.Snap(t, "JSON with Special Characters", data) } -// TestGoStructMarshalledToJSON tests Go struct marshalled to JSON func TestGoStructMarshalledToJson(t *testing.T) { type Address struct { Street string `json:"street"` @@ -791,7 +775,6 @@ freeze.Snap(t, "Go Struct Marshalled to JSON", data) } -// TestDeeplyNestedJSON tests deeply nested JSON structure func TestDeeplyNestedJson(t *testing.T) { type Level4 struct { Value string @@ -832,7 +815,6 @@ freeze.Snap(t, "Deeply Nested JSON", data) } -// TestLargeJSON tests larger JSON structure with many fields func TestLargeJson(t *testing.T) { type Product struct { ID int `json:"id"` @@ -909,7 +891,6 @@ freeze.Snap(t, "Large JSON Structure", data) } -// TestJSONWithMixedArrays tests JSON with arrays containing different types func TestJsonWithMixedArrays(t *testing.T) { jsonStr := `{ "heterogeneous_array": [ @@ -945,7 +926,6 @@ // SNAPJSON FUNCTION TESTS - Serialized JSON Strings // ============================================================================ -// TestSnapJSONBasic tests the SnapJSON function with basic JSON func TestSnapJsonBasic(t *testing.T) { jsonStr := `{ "name": "John Doe", @@ -957,7 +937,6 @@ freeze.SnapJSON(t, "SnapJSON Basic Object", jsonStr) } -// TestSnapJSONSimpleArray tests SnapJSON with simple arrays func TestSnapJsonSimpleArray(t *testing.T) { jsonStr := `[ "apple", @@ -969,14 +948,12 @@ freeze.SnapJSON(t, "SnapJSON Simple Array", jsonStr) } -// TestSnapJSONCompactFormat tests SnapJSON with compact (minified) JSON func TestSnapJsonCompactFormat(t *testing.T) { jsonStr := `{"id":1,"name":"Product","price":99.99,"in_stock":true,"tags":["electronics","gadgets"]}` freeze.SnapJSON(t, "SnapJSON Compact Format", jsonStr) } -// TestSnapJSONWithNestedObjects tests SnapJSON with nested JSON structures func TestSnapJsonWithNestedObjects(t *testing.T) { jsonStr := `{ "user": { @@ -998,7 +975,6 @@ freeze.SnapJSON(t, "SnapJSON Nested Objects", jsonStr) } -// TestSnapJSONComplexAPI tests SnapJSON with complex API response func TestSnapJsonComplexAPI(t *testing.T) { jsonStr := `{ "status": "success", @@ -1040,7 +1016,6 @@ freeze.SnapJSON(t, "SnapJSON Complex API Response", jsonStr) } -// TestSnapJSONWithNulls tests SnapJSON handling of null values func TestSnapJsonWithNulls(t *testing.T) { jsonStr := `{ "id": 1, @@ -1058,7 +1033,6 @@ freeze.SnapJSON(t, "SnapJSON With Nulls", jsonStr) } -// TestSnapJSONArrayOfObjects tests SnapJSON with arrays of objects func TestSnapJsonArrayOfObjects(t *testing.T) { jsonStr := `[ { @@ -1087,7 +1061,6 @@ freeze.SnapJSON(t, "SnapJSON Array of Objects", jsonStr) } -// TestSnapJSONLargeNestedStructure tests SnapJSON with deeply nested JSON func TestSnapJsonLargeNestedStructure(t *testing.T) { jsonStr := `{ "organization": { @@ -1150,7 +1123,6 @@ freeze.SnapJSON(t, "SnapJSON Large Nested Structure", jsonStr) } -// TestSnapJSONWithNumbers tests SnapJSON with various number formats func TestSnapJsonWithNumbers(t *testing.T) { jsonStr := `{ "integers": [0, 1, -1, 42, -100, 9999999], @@ -1170,7 +1142,6 @@ freeze.SnapJSON(t, "SnapJSON With Numbers", jsonStr) } -// TestSnapJSONWithSpecialCharacters tests SnapJSON with special chars func TestSnapJsonWithSpecialCharacters(t *testing.T) { jsonStr := `{ "special": "!@#$%^&*()_+-=[]{}|;:',.<>?/", @@ -1185,7 +1156,6 @@ freeze.SnapJSON(t, "SnapJSON With Special Characters", jsonStr) } -// TestSnapJSONEmptyStructures tests SnapJSON with empty collections func TestSnapJsonEmptyStructures(t *testing.T) { jsonStr := `{ "empty_array": [], @@ -1203,7 +1173,6 @@ freeze.SnapJSON(t, "SnapJSON Empty Structures", jsonStr) } -// TestSnapJSONMixedTypes tests SnapJSON with mixed array types func TestSnapJsonMixedTypes(t *testing.T) { jsonStr := `{ "mixed_array": [ @@ -1228,7 +1197,6 @@ freeze.SnapJSON(t, "SnapJSON Mixed Types", jsonStr) } -// TestSnapJSONRealWorldExample tests SnapJSON with real-world API data func TestSnapJsonRealWorldExample(t *testing.T) { jsonStr := `{ "success": true, @@ -1308,10 +1276,4 @@ freeze.SnapJSON(t, "SnapJSON Real World Example", jsonStr) } -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -func intPtr(i int) *int { - return &i -} +func ptr[T any](t T) *T { return &t } diff --git a/ignore.go b/ignore.go new file mode 100644 --- /dev/null +++ b/ignore.go @@ -0,0 +1,165 @@ +package freeze + +import ( + "regexp" + "strings" +) + +// IgnorePattern determines whether a key-value pair should be excluded +// from the snapshot. This is primarily used for JSON and map structures. +type IgnorePattern interface { + ShouldIgnore(key, value string) bool +} + +// exactKeyValueIgnore ignores exact key-value matches. +type exactKeyValueIgnore struct { + key string + value string +} + +func (e *exactKeyValueIgnore) ShouldIgnore(key, value string) bool { + return e.key == key && (e.value == "*" || e.value == value) +} + +// IgnoreKeyValue creates an ignore pattern that matches exact key-value pairs. +// Use "*" as the value to ignore any value for the given key. +func IgnoreKeyValue(key, value string) SnapshotOption { + return WithIgnorePattern(&exactKeyValueIgnore{ + key: key, + value: value, + }) +} + +// regexKeyValueIgnore ignores key-value pairs matching regex patterns. +type regexKeyValueIgnore struct { + keyPattern *regexp.Regexp + valuePattern *regexp.Regexp +} + +func (r *regexKeyValueIgnore) ShouldIgnore(key, value string) bool { + keyMatch := r.keyPattern == nil || r.keyPattern.MatchString(key) + valueMatch := r.valuePattern == nil || r.valuePattern.MatchString(value) + return keyMatch && valueMatch +} + +// IgnoreKeyPattern creates an ignore pattern using regex patterns for keys and values. +// Pass empty string for keyPattern or valuePattern to match any key or value. +func IgnoreKeyPattern(keyPattern, valuePattern string) SnapshotOption { + var keyRe, valueRe *regexp.Regexp + if keyPattern != "" { + keyRe = regexp.MustCompile(keyPattern) + } + if valuePattern != "" { + valueRe = regexp.MustCompile(valuePattern) + } + return WithIgnorePattern(®exKeyValueIgnore{ + keyPattern: keyRe, + valuePattern: valueRe, + }) +} + +// keyOnlyIgnore ignores any key matching the pattern, regardless of value. +type keyOnlyIgnore struct { + keys []string +} + +func (k *keyOnlyIgnore) ShouldIgnore(key, value string) bool { + for _, ignoreKey := range k.keys { + if ignoreKey == key { + return true + } + } + return false +} + +// IgnoreKeys creates an ignore pattern that ignores the specified keys +// regardless of their values. +func IgnoreKeys(keys ...string) SnapshotOption { + return WithIgnorePattern(&keyOnlyIgnore{ + keys: keys, + }) +} + +// regexKeyIgnore ignores keys matching a regex pattern. +type regexKeyIgnore struct { + pattern *regexp.Regexp +} + +func (r *regexKeyIgnore) ShouldIgnore(key, value string) bool { + return r.pattern.MatchString(key) +} + +// IgnoreKeysMatching creates an ignore pattern that ignores keys matching +// the given regex pattern. +func IgnoreKeysMatching(pattern string) SnapshotOption { + re := regexp.MustCompile(pattern) + return WithIgnorePattern(®exKeyIgnore{ + pattern: re, + }) +} + +// Common ignore patterns for sensitive data +var sensitiveKeys = []string{ + "password", "secret", "token", "api_key", "apiKey", + "access_token", "refresh_token", "private_key", "privateKey", + "authorization", "auth", "credentials", "passwd", +} + +// IgnoreSensitiveKeys ignores common sensitive key names like password, token, etc. +func IgnoreSensitiveKeys() SnapshotOption { + return WithIgnorePattern(&keyOnlyIgnore{ + keys: sensitiveKeys, + }) +} + +// valueOnlyIgnore ignores any value matching the pattern, regardless of key. +type valueOnlyIgnore struct { + values []string +} + +func (v *valueOnlyIgnore) ShouldIgnore(key, value string) bool { + for _, ignoreValue := range v.values { + if ignoreValue == value { + return true + } + } + return false +} + +// IgnoreValues creates an ignore pattern that ignores the specified values +// regardless of their keys. +func IgnoreValues(values ...string) SnapshotOption { + return WithIgnorePattern(&valueOnlyIgnore{ + values: values, + }) +} + +// customIgnore allows users to provide a custom ignore function. +type customIgnore struct { + ignoreFunc func(key, value string) bool +} + +func (c *customIgnore) ShouldIgnore(key, value string) bool { + return c.ignoreFunc(key, value) +} + +// CustomIgnore creates an ignore pattern using a custom function. +func CustomIgnore(ignoreFunc func(key, value string) bool) SnapshotOption { + return WithIgnorePattern(&customIgnore{ + ignoreFunc: ignoreFunc, + }) +} + +// IgnoreEmptyValues ignores fields with empty string values. +func IgnoreEmptyValues() SnapshotOption { + return CustomIgnore(func(key, value string) bool { + return strings.TrimSpace(value) == "" + }) +} + +// IgnoreNullValues ignores fields with null/nil values (represented as "null" in JSON). +func IgnoreNullValues() SnapshotOption { + return CustomIgnore(func(key, value string) bool { + return value == "null" || value == "" + }) +} diff --git a/ignore_test.go b/ignore_test.go new file mode 100644 --- /dev/null +++ b/ignore_test.go @@ -0,0 +1,248 @@ +package freeze_test + +import ( + "testing" + + "github.com/ptdewey/freeze" +) + +func TestIgnoreKeyValue(t *testing.T) { + jsonStr := `{ + "username": "john_doe", + "password": "secret123", + "email": "john@example.com", + "api_key": "sk_live_abc123" + }` + + freeze.SnapJSONWithOptions(t, "Ignore Password Field", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreKeyValue("password", "*"), + freeze.IgnoreKeyValue("api_key", "*"), + }) +} + +func TestIgnoreKeys(t *testing.T) { + jsonStr := `{ + "id": 1, + "name": "John Doe", + "password": "secret", + "secret": "confidential", + "token": "abc123", + "email": "john@example.com" + }` + + freeze.SnapJSONWithOptions(t, "Ignore Multiple Keys", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreKeys("password", "secret", "token"), + }) +} + +func TestIgnoreSensitiveKeys(t *testing.T) { + jsonStr := `{ + "username": "john_doe", + "password": "secret123", + "api_key": "sk_live_abc123", + "access_token": "token123", + "refresh_token": "refresh123", + "email": "john@example.com", + "name": "John Doe" + }` + + freeze.SnapJSONWithOptions(t, "Ignore Sensitive Keys", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreSensitiveKeys(), + }) +} + +func TestIgnoreKeysMatching(t *testing.T) { + jsonStr := `{ + "user_id": 1, + "user_name": "john", + "user_email": "john@example.com", + "product_id": 100, + "product_name": "Widget" + }` + + freeze.SnapJSONWithOptions(t, "Ignore Keys Matching Pattern", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreKeysMatching(`^user_`), + }) +} + +func TestIgnoreKeyPattern(t *testing.T) { + jsonStr := `{ + "username": "john_doe", + "password": "secret", + "admin_password": "admin_secret", + "user_token": "token123", + "email": "john@example.com" + }` + + freeze.SnapJSONWithOptions(t, "Ignore Key Pattern", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreKeyPattern(`.*password.*`, ""), + freeze.IgnoreKeyPattern(`.*token.*`, ""), + }) +} + +func TestIgnoreValues(t *testing.T) { + jsonStr := `{ + "status": "pending", + "result": "pending", + "message": "Processing", + "state": "pending" + }` + + freeze.SnapJSONWithOptions(t, "Ignore Specific Values", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreValues("pending"), + }) +} + +func TestIgnoreEmptyValues(t *testing.T) { + jsonStr := `{ + "name": "John Doe", + "middle_name": "", + "nickname": " ", + "email": "john@example.com", + "phone": "" + }` + + freeze.SnapJSONWithOptions(t, "Ignore Empty Values", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreEmptyValues(), + }) +} + +func TestIgnoreNullValues(t *testing.T) { + jsonStr := `{ + "name": "John Doe", + "middle_name": null, + "email": "john@example.com", + "phone": null, + "age": 30 + }` + + freeze.SnapJSONWithOptions(t, "Ignore Null Values", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreNullValues(), + }) +} + +func TestCustomIgnore(t *testing.T) { + jsonStr := `{ + "id": 1, + "name": "John Doe", + "age": 25, + "score": 95, + "grade": "A" + }` + + freeze.SnapJSONWithOptions(t, "Custom Ignore Function", jsonStr, []freeze.SnapshotOption{ + freeze.CustomIgnore(func(key, value string) bool { + // Ignore numeric values + return value == "1" || value == "25" || value == "95" + }), + }) +} + +func TestNestedIgnorePatterns(t *testing.T) { + jsonStr := `{ + "user": { + "id": 1, + "name": "John Doe", + "password": "secret", + "email": "john@example.com", + "profile": { + "bio": "Developer", + "api_key": "sk_live_abc123", + "website": "https://example.com" + } + }, + "admin": { + "password": "admin_secret", + "token": "admin_token_123" + } + }` + + freeze.SnapJSONWithOptions(t, "Nested Ignore Patterns", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreSensitiveKeys(), + }) +} + +func TestCombinedIgnoreAndScrub(t *testing.T) { + jsonStr := `{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "email": "john@example.com", + "password": "secret123", + "created_at": "2023-01-15T10:30:00Z", + "api_key": "sk_live_abc123", + "ip_address": "192.168.1.1" + }` + + freeze.SnapJSONWithOptions(t, "Combined Ignore and Scrub", jsonStr, []freeze.SnapshotOption{ + // Ignore sensitive keys entirely + freeze.IgnoreKeys("password", "api_key"), + // Scrub dynamic/identifiable data + freeze.ScrubUUIDs(), + freeze.ScrubEmails(), + freeze.ScrubTimestamps(), + freeze.ScrubIPAddresses(), + }) +} + +func TestIgnoreInArrays(t *testing.T) { + jsonStr := `{ + "users": [ + { + "id": 1, + "name": "Alice", + "password": "secret1", + "email": "alice@example.com" + }, + { + "id": 2, + "name": "Bob", + "password": "secret2", + "email": "bob@example.com" + } + ] + }` + + freeze.SnapJSONWithOptions(t, "Ignore in Arrays", jsonStr, []freeze.SnapshotOption{ + freeze.IgnoreKeys("password"), + }) +} + +func TestComplexRealWorldExample(t *testing.T) { + jsonStr := `{ + "request_id": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2023-11-20T15:30:00Z", + "user": { + "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "email": "user@example.com", + "name": "John Doe", + "password": "hashed_password", + "api_key": "sk_live_abc123def456", + "ip_address": "192.168.1.1", + "created_at": "2023-01-15T10:30:00Z" + }, + "transaction": { + "id": "txn_abc123", + "amount": 99.99, + "currency": "USD", + "card_number": "4532-1234-5678-9010", + "timestamp": "2023-11-20T15:30:00Z" + }, + "metadata": { + "server_ip": "10.0.0.5", + "session_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "user_agent": "Mozilla/5.0" + } + }` + + freeze.SnapJSONWithOptions(t, "Real World API Response", jsonStr, []freeze.SnapshotOption{ + // Ignore sensitive fields + freeze.IgnoreSensitiveKeys(), + freeze.IgnoreKeys("card_number"), + // Scrub dynamic/identifiable data + freeze.ScrubUUIDs(), + freeze.ScrubEmails(), + freeze.ScrubTimestamps(), + freeze.ScrubIPAddresses(), + freeze.ScrubJWTs(), + }) +} diff --git a/scrubbers.go b/scrubbers.go new file mode 100644 --- /dev/null +++ b/scrubbers.go @@ -0,0 +1,153 @@ +package freeze + +import ( + "regexp" + "strings" +) + +// Scrubber transforms content before snapshotting, typically to remove +// or replace dynamic or sensitive data. +type Scrubber interface { + Scrub(content string) string +} + +// regexScrubber replaces all matches of a regex pattern with a replacement string. +type regexScrubber struct { + pattern *regexp.Regexp + replacement string +} + +func (r *regexScrubber) Scrub(content string) string { + return r.pattern.ReplaceAllString(content, r.replacement) +} + +// RegexScrubber creates a scrubber that replaces all matches of the given +// regex pattern with the replacement string. +func RegexScrubber(pattern string, replacement string) SnapshotOption { + re := regexp.MustCompile(pattern) + return WithScrubber(®exScrubber{ + pattern: re, + replacement: replacement, + }) +} + +// exactMatchScrubber replaces exact string matches with a replacement. +type exactMatchScrubber struct { + match string + replacement string +} + +func (e *exactMatchScrubber) Scrub(content string) string { + return strings.ReplaceAll(content, e.match, e.replacement) +} + +// ExactMatchScrubber creates a scrubber that replaces exact string matches. +func ExactMatchScrubber(match string, replacement string) SnapshotOption { + return WithScrubber(&exactMatchScrubber{ + match: match, + replacement: replacement, + }) +} + +// Common regex patterns for scrubbing +var ( + uuidPattern = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) + iso8601Pattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?`) + emailPattern = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`) + unixTsPattern = regexp.MustCompile(`\b\d{10,13}\b`) + ipv4Pattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) + creditCardPattern = regexp.MustCompile(`\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b`) + jwtPattern = regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`) +) + +// ScrubUUIDs replaces all UUIDs with "". +func ScrubUUIDs() SnapshotOption { + return WithScrubber(®exScrubber{ + pattern: uuidPattern, + replacement: "", + }) +} + +// ScrubTimestamps replaces ISO8601 timestamps with "". +func ScrubTimestamps() SnapshotOption { + return WithScrubber(®exScrubber{ + pattern: iso8601Pattern, + replacement: "", + }) +} + +// ScrubEmails replaces email addresses with "". +func ScrubEmails() SnapshotOption { + return WithScrubber(®exScrubber{ + pattern: emailPattern, + replacement: "", + }) +} + +// ScrubUnixTimestamps replaces Unix timestamps (10-13 digits) with "". +func ScrubUnixTimestamps() SnapshotOption { + return WithScrubber(®exScrubber{ + pattern: unixTsPattern, + replacement: "", + }) +} + +// ScrubIPAddresses replaces IPv4 addresses with "". +func ScrubIPAddresses() SnapshotOption { + return WithScrubber(®exScrubber{ + pattern: ipv4Pattern, + replacement: "", + }) +} + +// ScrubCreditCards replaces credit card numbers with "". +func ScrubCreditCards() SnapshotOption { + return WithScrubber(®exScrubber{ + pattern: creditCardPattern, + replacement: "", + }) +} + +// ScrubJWTs replaces JWT tokens with "". +func ScrubJWTs() SnapshotOption { + return WithScrubber(®exScrubber{ + pattern: jwtPattern, + replacement: "", + }) +} + +// ScrubDates replaces dates in various formats with "". +// Matches formats like: 2023-01-15, 01/15/2023, 15-01-2023 +func ScrubDates() SnapshotOption { + datePattern := regexp.MustCompile(`\b\d{4}[-/]\d{2}[-/]\d{2}\b|\b\d{2}[-/]\d{2}[-/]\d{4}\b`) + return WithScrubber(®exScrubber{ + pattern: datePattern, + replacement: "", + }) +} + +// ScrubAPIKeys replaces common API key patterns with "". +// Matches patterns like: sk_live_..., pk_test_..., api_key_... +func ScrubAPIKeys() SnapshotOption { + apiKeyPattern := regexp.MustCompile(`\b(sk|pk|api[_-]?key)[_-](live|test|prod|dev)[_-][a-zA-Z0-9]+\b`) + return WithScrubber(®exScrubber{ + pattern: apiKeyPattern, + replacement: "", + }) +} + +// customScrubber allows users to provide a custom scrubbing function. +type customScrubber struct { + scrubFunc func(string) string +} + +func (c *customScrubber) Scrub(content string) string { + return c.scrubFunc(content) +} + +// CustomScrubber creates a scrubber using a custom function. +func CustomScrubber(scrubFunc func(string) string) SnapshotOption { + return WithScrubber(&customScrubber{ + scrubFunc: scrubFunc, + }) +} diff --git a/scrubbers_test.go b/scrubbers_test.go new file mode 100644 --- /dev/null +++ b/scrubbers_test.go @@ -0,0 +1,182 @@ +package freeze_test + +import ( + "strings" + "testing" + + "github.com/ptdewey/freeze" +) + +func TestScrubUUIDs(t *testing.T) { + jsonStr := `{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "session_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "name": "John Doe" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed UUIDs", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubUUIDs(), + }) +} + +func TestScrubTimestamps(t *testing.T) { + jsonStr := `{ + "created_at": "2023-01-15T10:30:00Z", + "updated_at": "2023-11-20T15:45:30.123Z", + "deleted_at": "2023-12-01T08:00:00+05:00", + "name": "Test Event" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed Timestamps", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubTimestamps(), + }) +} + +func TestScrubEmails(t *testing.T) { + jsonStr := `{ + "email": "user@example.com", + "backup_email": "backup.user+tag@subdomain.example.co.uk", + "name": "John Doe" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed Emails", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubEmails(), + }) +} + +func TestScrubIPAddresses(t *testing.T) { + jsonStr := `{ + "client_ip": "192.168.1.1", + "server_ip": "10.0.0.5", + "message": "Connection from 172.16.0.100" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed IPs", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubIPAddresses(), + }) +} + +func TestScrubJWTs(t *testing.T) { + jsonStr := `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed JWTs", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubJWTs(), + }) +} + +func TestMultipleScrubbers(t *testing.T) { + jsonStr := `{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "created_at": "2023-01-15T10:30:00Z", + "ip_address": "192.168.1.1", + "name": "John Doe" + }` + + freeze.SnapJSONWithOptions(t, "Multiple Scrubbers", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubUUIDs(), + freeze.ScrubEmails(), + freeze.ScrubTimestamps(), + freeze.ScrubIPAddresses(), + }) +} + +func TestRegexScrubber(t *testing.T) { + jsonStr := `{ + "api_key": "sk_live_abc123def456", + "secret_key": "sk_test_xyz789uvw012", + "name": "Test User" + }` + + freeze.SnapJSONWithOptions(t, "Custom Regex Scrubber", jsonStr, []freeze.SnapshotOption{ + freeze.RegexScrubber(`sk_(live|test)_[a-zA-Z0-9]+`, ""), + }) +} + +func TestExactMatchScrubber(t *testing.T) { + content := "The secret password is 'p@ssw0rd123' and should be hidden." + + freeze.SnapStringWithOptions(t, "Exact Match Scrubber", content, []freeze.SnapshotOption{ + freeze.ExactMatchScrubber("p@ssw0rd123", ""), + }) +} + +func TestCustomScrubber(t *testing.T) { + content := "Hello World! This is a TEST." + + freeze.SnapStringWithOptions(t, "Custom Scrubber", content, []freeze.SnapshotOption{ + freeze.CustomScrubber(func(s string) string { + return strings.ToLower(s) + }), + }) +} + +func TestScrubDates(t *testing.T) { + jsonStr := `{ + "birth_date": "1990-05-15", + "hire_date": "2020-01-01", + "us_format": "12/25/2023", + "name": "John Doe" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed Dates", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubDates(), + }) +} + +func TestScrubAPIKeys(t *testing.T) { + jsonStr := `{ + "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", + "test_key": "pk_test_51HqZ2bKl4FGBMFpLxO0456", + "api_key_prod": "api_key_prod_abc123def456", + "name": "Test Config" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed API Keys", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubAPIKeys(), + }) +} + +func TestScrubWithSnapFunction(t *testing.T) { + data := map[string]interface{}{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "created_at": "2023-01-15T10:30:00Z", + "name": "John Doe", + } + + freeze.SnapWithOptions(t, "Scrub With Snap", []freeze.SnapshotOption{ + freeze.ScrubUUIDs(), + freeze.ScrubEmails(), + freeze.ScrubTimestamps(), + }, data) +} + +func TestCreditCardScrubbing(t *testing.T) { + jsonStr := `{ + "card_number": "4532-1234-5678-9010", + "backup_card": "4532 1234 5678 9010", + "another_card": "4532123456789010", + "name": "John Doe" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed Credit Cards", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubCreditCards(), + }) +} + +func TestUnixTimestampScrubbing(t *testing.T) { + jsonStr := `{ + "created": 1699999999, + "updated": 1700000000000, + "deleted": 1700000000, + "name": "Test Event" + }` + + freeze.SnapJSONWithOptions(t, "Scrubbed Unix Timestamps", jsonStr, []freeze.SnapshotOption{ + freeze.ScrubUnixTimestamps(), + }) +} diff --git a/__snapshots__/test_combined_ignore_and_scrub.snap b/__snapshots__/test_combined_ignore_and_scrub.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_combined_ignore_and_scrub.snap @@ -0,0 +1,14 @@ +--- +title: Combined Ignore and Scrub +test_name: TestCombinedIgnoreAndScrub +file_path: +func_name: +version: 0.1.0 +--- +{ + "created_at": "", + "email": "", + "ip_address": "", + "name": "John Doe", + "user_id": "" +} \ No newline at end of file diff --git a/__snapshots__/test_complex_real_world_example.snap b/__snapshots__/test_complex_real_world_example.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_complex_real_world_example.snap @@ -0,0 +1,29 @@ +--- +title: Real World API Response +test_name: TestComplexRealWorldExample +file_path: +func_name: +version: 0.1.0 +--- +{ + "metadata": { + "server_ip": "", + "session_token": "", + "user_agent": "Mozilla/5.0" + }, + "request_id": "", + "timestamp": "", + "transaction": { + "amount": 99.99, + "currency": "USD", + "id": "txn_abc123", + "timestamp": "" + }, + "user": { + "created_at": "", + "email": "", + "id": "", + "ip_address": "", + "name": "John Doe" + } +} \ No newline at end of file diff --git a/__snapshots__/test_credit_card_scrubbing.snap b/__snapshots__/test_credit_card_scrubbing.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_credit_card_scrubbing.snap @@ -0,0 +1,13 @@ +--- +title: Scrubbed Credit Cards +test_name: TestCreditCardScrubbing +file_path: +func_name: +version: 0.1.0 +--- +{ + "another_card": "", + "backup_card": "", + "card_number": "", + "name": "John Doe" +} \ No newline at end of file diff --git a/__snapshots__/test_custom_ignore.snap b/__snapshots__/test_custom_ignore.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_custom_ignore.snap @@ -0,0 +1,11 @@ +--- +title: Custom Ignore Function +test_name: TestCustomIgnore +file_path: +func_name: +version: 0.1.0 +--- +{ + "grade": "A", + "name": "John Doe" +} \ No newline at end of file diff --git a/__snapshots__/test_custom_scrubber.snap b/__snapshots__/test_custom_scrubber.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_custom_scrubber.snap @@ -0,0 +1,8 @@ +--- +title: Custom Scrubber +test_name: TestCustomScrubber +file_path: +func_name: +version: 0.1.0 +--- +hello world! this is a test. \ No newline at end of file diff --git a/__snapshots__/test_exact_match_scrubber.snap b/__snapshots__/test_exact_match_scrubber.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_exact_match_scrubber.snap @@ -0,0 +1,8 @@ +--- +title: Exact Match Scrubber +test_name: TestExactMatchScrubber +file_path: +func_name: +version: 0.1.0 +--- +The secret password is '' and should be hidden. \ No newline at end of file diff --git a/__snapshots__/test_ignore_empty_values.snap b/__snapshots__/test_ignore_empty_values.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_empty_values.snap @@ -0,0 +1,11 @@ +--- +title: Ignore Empty Values +test_name: TestIgnoreEmptyValues +file_path: +func_name: +version: 0.1.0 +--- +{ + "email": "john@example.com", + "name": "John Doe" +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_in_arrays.snap b/__snapshots__/test_ignore_in_arrays.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_in_arrays.snap @@ -0,0 +1,21 @@ +--- +title: Ignore in Arrays +test_name: TestIgnoreInArrays +file_path: +func_name: +version: 0.1.0 +--- +{ + "users": [ + { + "email": "alice@example.com", + "id": 1, + "name": "Alice" + }, + { + "email": "bob@example.com", + "id": 2, + "name": "Bob" + } + ] +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_key_pattern.snap b/__snapshots__/test_ignore_key_pattern.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_key_pattern.snap @@ -0,0 +1,11 @@ +--- +title: Ignore Key Pattern +test_name: TestIgnoreKeyPattern +file_path: +func_name: +version: 0.1.0 +--- +{ + "email": "john@example.com", + "username": "john_doe" +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_key_value.snap b/__snapshots__/test_ignore_key_value.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_key_value.snap @@ -0,0 +1,11 @@ +--- +title: Ignore Password Field +test_name: TestIgnoreKeyValue +file_path: +func_name: +version: 0.1.0 +--- +{ + "email": "john@example.com", + "username": "john_doe" +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_keys.snap b/__snapshots__/test_ignore_keys.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_keys.snap @@ -0,0 +1,12 @@ +--- +title: Ignore Multiple Keys +test_name: TestIgnoreKeys +file_path: +func_name: +version: 0.1.0 +--- +{ + "email": "john@example.com", + "id": 1, + "name": "John Doe" +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_keys_matching.snap b/__snapshots__/test_ignore_keys_matching.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_keys_matching.snap @@ -0,0 +1,11 @@ +--- +title: Ignore Keys Matching Pattern +test_name: TestIgnoreKeysMatching +file_path: +func_name: +version: 0.1.0 +--- +{ + "product_id": 100, + "product_name": "Widget" +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_null_values.snap b/__snapshots__/test_ignore_null_values.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_null_values.snap @@ -0,0 +1,12 @@ +--- +title: Ignore Null Values +test_name: TestIgnoreNullValues +file_path: +func_name: +version: 0.1.0 +--- +{ + "age": 30, + "email": "john@example.com", + "name": "John Doe" +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_sensitive_keys.snap b/__snapshots__/test_ignore_sensitive_keys.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_sensitive_keys.snap @@ -0,0 +1,12 @@ +--- +title: Ignore Sensitive Keys +test_name: TestIgnoreSensitiveKeys +file_path: +func_name: +version: 0.1.0 +--- +{ + "email": "john@example.com", + "name": "John Doe", + "username": "john_doe" +} \ No newline at end of file diff --git a/__snapshots__/test_ignore_values.snap b/__snapshots__/test_ignore_values.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_ignore_values.snap @@ -0,0 +1,10 @@ +--- +title: Ignore Specific Values +test_name: TestIgnoreValues +file_path: +func_name: +version: 0.1.0 +--- +{ + "message": "Processing" +} \ No newline at end of file diff --git a/__snapshots__/test_multiple_scrubbers.snap b/__snapshots__/test_multiple_scrubbers.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_multiple_scrubbers.snap @@ -0,0 +1,14 @@ +--- +title: Multiple Scrubbers +test_name: TestMultipleScrubbers +file_path: +func_name: +version: 0.1.0 +--- +{ + "created_at": "", + "email": "", + "ip_address": "", + "name": "John Doe", + "user_id": "" +} \ No newline at end of file diff --git a/__snapshots__/test_nested_ignore_patterns.snap b/__snapshots__/test_nested_ignore_patterns.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_nested_ignore_patterns.snap @@ -0,0 +1,19 @@ +--- +title: Nested Ignore Patterns +test_name: TestNestedIgnorePatterns +file_path: +func_name: +version: 0.1.0 +--- +{ + "admin": {}, + "user": { + "email": "john@example.com", + "id": 1, + "name": "John Doe", + "profile": { + "bio": "Developer", + "website": "https://example.com" + } + } +} \ No newline at end of file diff --git a/__snapshots__/test_regex_scrubber.snap b/__snapshots__/test_regex_scrubber.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_regex_scrubber.snap @@ -0,0 +1,12 @@ +--- +title: Custom Regex Scrubber +test_name: TestRegexScrubber +file_path: +func_name: +version: 0.1.0 +--- +{ + "api_key": "", + "name": "Test User", + "secret_key": "" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_a_p_i_keys.snap b/__snapshots__/test_scrub_a_p_i_keys.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_a_p_i_keys.snap @@ -0,0 +1,13 @@ +--- +title: Scrubbed API Keys +test_name: TestScrubAPIKeys +file_path: +func_name: +version: 0.1.0 +--- +{ + "api_key_prod": "", + "name": "Test Config", + "stripe_key": "", + "test_key": "" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_dates.snap b/__snapshots__/test_scrub_dates.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_dates.snap @@ -0,0 +1,13 @@ +--- +title: Scrubbed Dates +test_name: TestScrubDates +file_path: +func_name: +version: 0.1.0 +--- +{ + "birth_date": "", + "hire_date": "", + "name": "John Doe", + "us_format": "" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_emails.snap b/__snapshots__/test_scrub_emails.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_emails.snap @@ -0,0 +1,12 @@ +--- +title: Scrubbed Emails +test_name: TestScrubEmails +file_path: +func_name: +version: 0.1.0 +--- +{ + "backup_email": "", + "email": "", + "name": "John Doe" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_i_p_addresses.snap b/__snapshots__/test_scrub_i_p_addresses.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_i_p_addresses.snap @@ -0,0 +1,12 @@ +--- +title: Scrubbed IPs +test_name: TestScrubIPAddresses +file_path: +func_name: +version: 0.1.0 +--- +{ + "client_ip": "", + "message": "Connection from ", + "server_ip": "" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_j_w_ts.snap b/__snapshots__/test_scrub_j_w_ts.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_j_w_ts.snap @@ -0,0 +1,11 @@ +--- +title: Scrubbed JWTs +test_name: TestScrubJWTs +file_path: +func_name: +version: 0.1.0 +--- +{ + "refresh_token": "", + "token": "" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_timestamps.snap b/__snapshots__/test_scrub_timestamps.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_timestamps.snap @@ -0,0 +1,13 @@ +--- +title: Scrubbed Timestamps +test_name: TestScrubTimestamps +file_path: +func_name: +version: 0.1.0 +--- +{ + "created_at": "", + "deleted_at": "", + "name": "Test Event", + "updated_at": "" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_u_u_i_ds.snap b/__snapshots__/test_scrub_u_u_i_ds.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_u_u_i_ds.snap @@ -0,0 +1,12 @@ +--- +title: Scrubbed UUIDs +test_name: TestScrubUUIDs +file_path: +func_name: +version: 0.1.0 +--- +{ + "name": "John Doe", + "session_id": "", + "user_id": "" +} \ No newline at end of file diff --git a/__snapshots__/test_scrub_with_snap_function.snap b/__snapshots__/test_scrub_with_snap_function.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_scrub_with_snap_function.snap @@ -0,0 +1,13 @@ +--- +title: Scrub With Snap +test_name: TestScrubWithSnapFunction +file_path: +func_name: +version: 0.1.0 +--- +map[string]interface{}{ + "created_at": "", + "email": "", + "name": "John Doe", + "user_id": "", +} diff --git a/__snapshots__/test_snap_json_array_of_objects.snap b/__snapshots__/test_snap_json_array_of_objects.snap --- a/__snapshots__/test_snap_json_array_of_objects.snap +++ b/__snapshots__/test_snap_json_array_of_objects.snap @@ -6,25 +6,25 @@ version: 0.1.0 --- [ - { - "id": 1, - "likes": 42, - "title": "First Post", - "type": "post", - "views": 150 - }, - { - "id": 2, - "likes": 75, - "title": "Second Post", - "type": "post", - "views": 280 - }, - { - "id": 3, - "likes": 120, - "title": "Third Post", - "type": "post", - "views": 450 - } -] \ No newline at end of file + { + "id": 1, + "type": "post", + "title": "First Post", + "views": 150, + "likes": 42 + }, + { + "id": 2, + "type": "post", + "title": "Second Post", + "views": 280, + "likes": 75 + }, + { + "id": 3, + "type": "post", + "title": "Third Post", + "views": 450, + "likes": 120 + } + ] \ No newline at end of file diff --git a/__snapshots__/test_snap_json_basic.snap b/__snapshots__/test_snap_json_basic.snap --- a/__snapshots__/test_snap_json_basic.snap +++ b/__snapshots__/test_snap_json_basic.snap @@ -6,8 +6,8 @@ version: 0.1.0 --- { - "age": 30, - "email": "john@example.com", - "name": "John Doe", - "verified": true -} \ No newline at end of file + "name": "John Doe", + "email": "john@example.com", + "age": 30, + "verified": true + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_compact_format.snap b/__snapshots__/test_snap_json_compact_format.snap --- a/__snapshots__/test_snap_json_compact_format.snap +++ b/__snapshots__/test_snap_json_compact_format.snap @@ -5,13 +5,4 @@ func_name: version: 0.1.0 --- -{ - "id": 1, - "in_stock": true, - "name": "Product", - "price": 99.99, - "tags": [ - "electronics", - "gadgets" - ] -} \ No newline at end of file +{"id":1,"name":"Product","price":99.99,"in_stock":true,"tags":["electronics","gadgets"]} \ No newline at end of file diff --git a/__snapshots__/test_snap_json_complex_a_p_i.snap b/__snapshots__/test_snap_json_complex_a_p_i.snap --- a/__snapshots__/test_snap_json_complex_a_p_i.snap +++ b/__snapshots__/test_snap_json_complex_a_p_i.snap @@ -6,38 +6,38 @@ version: 0.1.0 --- { - "code": 200, - "data": { - "pagination": { - "page": 1, - "per_page": 10, - "total": 3, - "total_pages": 1 - }, - "users": [ - { - "active": true, - "department": "Engineering", - "id": 1, - "name": "Alice", - "role": "admin" - }, - { - "active": true, - "department": "Sales", - "id": 2, - "name": "Bob", - "role": "user" - }, - { - "active": false, - "department": "Marketing", - "id": 3, - "name": "Charlie", - "role": "user" - } - ] - }, - "status": "success", - "timestamp": "2023-11-18T21:45:30Z" -} \ No newline at end of file + "status": "success", + "code": 200, + "data": { + "users": [ + { + "id": 1, + "name": "Alice", + "role": "admin", + "department": "Engineering", + "active": true + }, + { + "id": 2, + "name": "Bob", + "role": "user", + "department": "Sales", + "active": true + }, + { + "id": 3, + "name": "Charlie", + "role": "user", + "department": "Marketing", + "active": false + } + ], + "pagination": { + "page": 1, + "per_page": 10, + "total": 3, + "total_pages": 1 + } + }, + "timestamp": "2023-11-18T21:45:30Z" + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_empty_structures.snap b/__snapshots__/test_snap_json_empty_structures.snap --- a/__snapshots__/test_snap_json_empty_structures.snap +++ b/__snapshots__/test_snap_json_empty_structures.snap @@ -6,14 +6,14 @@ version: 0.1.0 --- { - "empty_array": [], - "empty_object": {}, - "empty_string": "", - "false_value": false, - "nested": { - "also_empty": {}, - "empty": [] - }, - "null_value": null, - "zero": 0 -} \ No newline at end of file + "empty_array": [], + "empty_object": {}, + "empty_string": "", + "zero": 0, + "false_value": false, + "null_value": null, + "nested": { + "empty": [], + "also_empty": {} + } + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_large_nested_structure.snap b/__snapshots__/test_snap_json_large_nested_structure.snap --- a/__snapshots__/test_snap_json_large_nested_structure.snap +++ b/__snapshots__/test_snap_json_large_nested_structure.snap @@ -6,99 +6,59 @@ version: 0.1.0 --- { - "organization": { - "departments": [ - { - "manager": "Alice", - "name": "Engineering", - "teams": [ - { - "lead": "John", - "members": [ - { - "id": 1, - "level": "senior", - "name": "John" - }, - { - "id": 2, - "level": "mid", - "name": "Jane" - } - ], - "name": "Backend", - "projects": [ - { - "id": "proj_1", - "name": "API Service", - "status": "active" - }, - { - "id": "proj_2", - "name": "Database Optimization", - "status": "planning" - } - ] - }, - { - "lead": "Bob", - "members": [ - { - "id": 3, - "level": "senior", - "name": "Bob" - }, - { - "id": 4, - "level": "junior", - "name": "Carol" - } - ], - "name": "Frontend", - "projects": [ - { - "id": "proj_3", - "name": "Web App", - "status": "active" - } - ] - } - ] - }, - { - "manager": "Charlie", - "name": "Sales", - "teams": [ - { - "lead": "Dave", - "members": [ - { - "id": 5, - "level": "senior", - "name": "Dave" - }, - { - "id": 6, - "level": "mid", - "name": "Eve" - } - ], - "name": "Enterprise", - "projects": [] - } - ] - } - ], - "id": "org_123", - "metadata": { - "employees": 150, - "founded": "2020", - "locations": [ - "USA", - "EU", - "APAC" - ] - }, - "name": "TechCorp" - } -} \ No newline at end of file + "organization": { + "name": "TechCorp", + "id": "org_123", + "departments": [ + { + "name": "Engineering", + "manager": "Alice", + "teams": [ + { + "name": "Backend", + "lead": "John", + "members": [ + {"id": 1, "name": "John", "level": "senior"}, + {"id": 2, "name": "Jane", "level": "mid"} + ], + "projects": [ + {"id": "proj_1", "name": "API Service", "status": "active"}, + {"id": "proj_2", "name": "Database Optimization", "status": "planning"} + ] + }, + { + "name": "Frontend", + "lead": "Bob", + "members": [ + {"id": 3, "name": "Bob", "level": "senior"}, + {"id": 4, "name": "Carol", "level": "junior"} + ], + "projects": [ + {"id": "proj_3", "name": "Web App", "status": "active"} + ] + } + ] + }, + { + "name": "Sales", + "manager": "Charlie", + "teams": [ + { + "name": "Enterprise", + "lead": "Dave", + "members": [ + {"id": 5, "name": "Dave", "level": "senior"}, + {"id": 6, "name": "Eve", "level": "mid"} + ], + "projects": [] + } + ] + } + ], + "metadata": { + "founded": "2020", + "employees": 150, + "locations": ["USA", "EU", "APAC"] + } + } + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_mixed_types.snap b/__snapshots__/test_snap_json_mixed_types.snap --- a/__snapshots__/test_snap_json_mixed_types.snap +++ b/__snapshots__/test_snap_json_mixed_types.snap @@ -6,37 +6,21 @@ version: 0.1.0 --- { - "complex": [ - { - "id": 1, - "type": "user" - }, - { - "id": 100, - "type": "post" - }, - [ - 1, - 2, - 3 - ], - "string", - null - ], - "mixed_array": [ - "string", - 123, - 45.67, - true, - false, - null, - { - "nested": "object" - }, - [ - 1, - 2, - 3 - ] - ] -} \ No newline at end of file + "mixed_array": [ + "string", + 123, + 45.67, + true, + false, + null, + {"nested": "object"}, + [1, 2, 3] + ], + "complex": [ + {"type": "user", "id": 1}, + {"type": "post", "id": 100}, + [1, 2, 3], + "string", + null + ] + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_real_world_example.snap b/__snapshots__/test_snap_json_real_world_example.snap --- a/__snapshots__/test_snap_json_real_world_example.snap +++ b/__snapshots__/test_snap_json_real_world_example.snap @@ -6,80 +6,76 @@ version: 0.1.0 --- { - "data": { - "product": { - "description": "High-quality wireless headphones with noise cancellation", - "id": "prod_12345", - "inventory": { - "available": 425, - "damaged": 25, - "reserved": 50, - "total": 500 - }, - "name": "Premium Wireless Headphones", - "price": { - "amount": 199.99, - "currency": "USD", - "discount": 10, - "final_price": 179.99 - }, - "ratings": { - "average": 4.5, - "breakdown": { - "1": 20, - "2": 30, - "3": 100, - "4": 350, - "5": 750 - }, - "count": 1250 - }, - "reviews": [ - { - "content": "Great sound quality and comfortable to wear.", - "created_at": "2023-11-15T10:30:00Z", - "helpful": 25, - "id": "rev_001", - "rating": 5, - "title": "Excellent product!", - "user": "john_doe" - }, - { - "content": "Works well, could be cheaper.", - "created_at": "2023-11-10T14:20:00Z", - "helpful": 12, - "id": "rev_002", - "rating": 4, - "title": "Good but pricey", - "user": "jane_smith" - } - ], - "sku": "PWH-001", - "specifications": { - "battery_life": "30 hours", - "colors": [ - "black", - "white", - "blue" - ], - "warranty_months": 24, - "weight": "250g" - } - }, - "related_products": [ - { - "id": "prod_12346", - "name": "Headphone Case", - "price": 29.99 - }, - { - "id": "prod_12347", - "name": "Audio Cable", - "price": 14.99 - } - ] - }, - "request_id": "req_abc123def456", - "success": true, - "timestamp": "2023-11-18T22:00:00Z" -} \ No newline at end of file + "success": true, + "data": { + "product": { + "id": "prod_12345", + "name": "Premium Wireless Headphones", + "sku": "PWH-001", + "description": "High-quality wireless headphones with noise cancellation", + "price": { + "amount": 199.99, + "currency": "USD", + "discount": 10, + "final_price": 179.99 + }, + "inventory": { + "total": 500, + "available": 425, + "reserved": 50, + "damaged": 25 + }, + "specifications": { + "battery_life": "30 hours", + "weight": "250g", + "colors": ["black", "white", "blue"], + "warranty_months": 24 + }, + "ratings": { + "average": 4.5, + "count": 1250, + "breakdown": { + "5": 750, + "4": 350, + "3": 100, + "2": 30, + "1": 20 + } + }, + "reviews": [ + { + "id": "rev_001", + "user": "john_doe", + "rating": 5, + "title": "Excellent product!", + "content": "Great sound quality and comfortable to wear.", + "helpful": 25, + "created_at": "2023-11-15T10:30:00Z" + }, + { + "id": "rev_002", + "user": "jane_smith", + "rating": 4, + "title": "Good but pricey", + "content": "Works well, could be cheaper.", + "helpful": 12, + "created_at": "2023-11-10T14:20:00Z" + } + ] + }, + "related_products": [ + { + "id": "prod_12346", + "name": "Headphone Case", + "price": 29.99 + }, + { + "id": "prod_12347", + "name": "Audio Cable", + "price": 14.99 + } + ] + }, + "request_id": "req_abc123def456", + "timestamp": "2023-11-18T22:00:00Z" + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_simple_array.snap b/__snapshots__/test_snap_json_simple_array.snap --- a/__snapshots__/test_snap_json_simple_array.snap +++ b/__snapshots__/test_snap_json_simple_array.snap @@ -6,8 +6,8 @@ version: 0.1.0 --- [ - "apple", - "banana", - "orange", - "grape" -] \ No newline at end of file + "apple", + "banana", + "orange", + "grape" + ] \ No newline at end of file diff --git a/__snapshots__/test_snap_json_with_nested_objects.snap b/__snapshots__/test_snap_json_with_nested_objects.snap --- a/__snapshots__/test_snap_json_with_nested_objects.snap +++ b/__snapshots__/test_snap_json_with_nested_objects.snap @@ -6,22 +6,18 @@ version: 0.1.0 --- { - "created_at": "2023-06-15T10:30:00Z", - "user": { - "id": 42, - "permissions": [ - "read", - "write", - "admin" - ], - "profile": { - "avatar": "https://example.com/avatar.jpg", - "settings": { - "language": "en", - "notifications": true, - "theme": "dark" - }, - "username": "jane_smith" - } - } -} \ No newline at end of file + "user": { + "id": 42, + "profile": { + "username": "jane_smith", + "avatar": "https://example.com/avatar.jpg", + "settings": { + "theme": "dark", + "notifications": true, + "language": "en" + } + }, + "permissions": ["read", "write", "admin"] + }, + "created_at": "2023-06-15T10:30:00Z" + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_with_nulls.snap b/__snapshots__/test_snap_json_with_nulls.snap --- a/__snapshots__/test_snap_json_with_nulls.snap +++ b/__snapshots__/test_snap_json_with_nulls.snap @@ -6,14 +6,14 @@ version: 0.1.0 --- { - "category": null, - "description": null, - "id": 1, - "metadata": { - "created": "2023-01-01", - "deleted": null, - "updated": null - }, - "name": "Item", - "tags": null -} \ No newline at end of file + "id": 1, + "name": "Item", + "description": null, + "category": null, + "tags": null, + "metadata": { + "created": "2023-01-01", + "updated": null, + "deleted": null + } + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_with_numbers.snap b/__snapshots__/test_snap_json_with_numbers.snap --- a/__snapshots__/test_snap_json_with_numbers.snap +++ b/__snapshots__/test_snap_json_with_numbers.snap @@ -6,30 +6,16 @@ version: 0.1.0 --- { - "financial": { - "expenses": 750000.75, - "profit_margin": 0.2499, - "revenue": 1000000.5 - }, - "floats": [ - 0, - 3.14, - -2.5, - 0.001, - 0.000123, - 56700000000 - ], - "integers": [ - 0, - 1, - -1, - 42, - -100, - 9999999 - ], - "measurements": { - "distance": 1000.25, - "temperature": -40.5, - "weight": 0.5 - } -} \ No newline at end of file + "integers": [0, 1, -1, 42, -100, 9999999], + "floats": [0.0, 3.14, -2.5, 0.001, 1.23e-4, 5.67e10], + "financial": { + "revenue": 1000000.50, + "expenses": 750000.75, + "profit_margin": 0.2499 + }, + "measurements": { + "temperature": -40.5, + "distance": 1000.25, + "weight": 0.5 + } + } \ No newline at end of file diff --git a/__snapshots__/test_snap_json_with_special_characters.snap b/__snapshots__/test_snap_json_with_special_characters.snap --- a/__snapshots__/test_snap_json_with_special_characters.snap +++ b/__snapshots__/test_snap_json_with_special_characters.snap @@ -6,11 +6,11 @@ version: 0.1.0 --- { - "escaped": "line1\nline2\ttab\rcarriage", - "html": "\u003cdiv class=\"container\"\u003eContent\u003c/div\u003e", - "paths": "C:\\Users\\name\\Documents\\file.txt", - "quotes": "He said \"hello\" and she said 'goodbye'", - "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", - "special": "!@#$%^\u0026*()_+-=[]{}|;:',.\u003c\u003e?/", - "unicode": "Hello 世界 🌍 مرحبا Привет" -} \ No newline at end of file + "special": "!@#$%^&*()_+-=[]{}|;:',.<>?/", + "escaped": "line1\nline2\ttab\rcarriage", + "quotes": "He said \"hello\" and she said 'goodbye'", + "unicode": "Hello 世界 🌍 مرحبا Привет", + "paths": "C:\\Users\\name\\Documents\\file.txt", + "html": "
Content
", + "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" + } \ No newline at end of file diff --git a/__snapshots__/test_unix_timestamp_scrubbing.snap b/__snapshots__/test_unix_timestamp_scrubbing.snap new file mode 100644 --- /dev/null +++ b/__snapshots__/test_unix_timestamp_scrubbing.snap @@ -0,0 +1,13 @@ +--- +title: Scrubbed Unix Timestamps +test_name: TestUnixTimestampScrubbing +file_path: +func_name: +version: 0.1.0 +--- +{ + "created": , + "deleted": , + "name": "Test Event", + "updated": +} \ No newline at end of file diff --git a/internal/transform/transform.go b/internal/transform/transform.go new file mode 100644 --- /dev/null +++ b/internal/transform/transform.go @@ -0,0 +1,131 @@ +package transform + +import ( + "encoding/json" + "fmt" +) + +// Config holds the transformation configuration. +type Config struct { + Scrubbers []Scrubber + Ignore []IgnorePattern +} + +// Scrubber transforms content before snapshotting. +type Scrubber interface { + Scrub(content string) string +} + +// IgnorePattern determines whether a key-value pair should be excluded. +type IgnorePattern interface { + ShouldIgnore(key, value string) bool +} + +// ApplyScrubbers applies all scrubbers to the content in order. +func ApplyScrubbers(content string, scrubbers []Scrubber) string { + result := content + for _, scrubber := range scrubbers { + result = scrubber.Scrub(result) + } + return result +} + +// TransformJSON applies scrubbers and ignore patterns to JSON data. +func TransformJSON(jsonStr string, config *Config) (string, error) { + if config == nil || (len(config.Scrubbers) == 0 && len(config.Ignore) == 0) { + return jsonStr, nil + } + + var data interface{} + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return "", fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + // Apply ignore patterns first (removes fields) + if len(config.Ignore) > 0 { + data = walkAndFilter(data, config.Ignore) + } + + // Marshal back to JSON + prettyJSON, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + + result := string(prettyJSON) + + // Apply scrubbers to the final string + result = ApplyScrubbers(result, config.Scrubbers) + + return result, nil +} + +// walkAndFilter recursively walks the data structure and filters out ignored fields. +func walkAndFilter(data interface{}, ignorePatterns []IgnorePattern) interface{} { + switch v := data.(type) { + case map[string]interface{}: + return filterMap(v, ignorePatterns) + case []interface{}: + return filterSlice(v, ignorePatterns) + default: + return data + } +} + +// filterMap filters a map, removing entries that match ignore patterns. +func filterMap(m map[string]interface{}, ignorePatterns []IgnorePattern) map[string]interface{} { + result := make(map[string]interface{}) + for key, value := range m { + // Convert value to string for comparison + valueStr := valueToString(value) + + // Check if this key-value pair should be ignored + shouldIgnore := false + for _, pattern := range ignorePatterns { + if pattern.ShouldIgnore(key, valueStr) { + shouldIgnore = true + break + } + } + + if !shouldIgnore { + // Recursively filter nested structures + result[key] = walkAndFilter(value, ignorePatterns) + } + } + return result +} + +// filterSlice filters a slice, recursively processing each element. +func filterSlice(s []interface{}, ignorePatterns []IgnorePattern) []interface{} { + result := make([]interface{}, len(s)) + for i, item := range s { + result[i] = walkAndFilter(item, ignorePatterns) + } + return result +} + +// valueToString converts various value types to string for comparison. +func valueToString(value interface{}) string { + switch v := value.(type) { + case string: + return v + case nil: + return "null" + case bool: + if v { + return "true" + } + return "false" + case float64: + return fmt.Sprintf("%v", v) + case int, int64: + return fmt.Sprintf("%d", v) + default: + // For complex types, marshal to JSON + if bytes, err := json.Marshal(v); err == nil { + return string(bytes) + } + return fmt.Sprintf("%v", v) + } +}