From 34d1b5206e203726429023f0b934c3e4d44d079d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:34:01 +0000 Subject: [PATCH 1/4] Initial plan From 4d30ffc2486528fe2c03bb0a267bec418f53f993 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:40:46 +0000 Subject: [PATCH 2/4] Complete implementation of go-config-diff utility Co-authored-by: BaseMax <2658040+BaseMax@users.noreply.github.com> --- .gitignore | 32 +++++ README.md | 228 ++++++++++++++++++++++++++++++++++- examples/config1.ini | 16 +++ examples/config1.json | 26 ++++ examples/config1.toml | 27 +++++ examples/config1.yaml | 19 +++ examples/config2.ini | 17 +++ examples/config2.json | 28 +++++ examples/config2.toml | 31 +++++ examples/config2.yaml | 21 ++++ go.mod | 17 +++ go.sum | 25 ++++ pkg/ast/ast.go | 198 ++++++++++++++++++++++++++++++ pkg/ast/ast_test.go | 156 ++++++++++++++++++++++++ pkg/diff/diff.go | 246 ++++++++++++++++++++++++++++++++++++++ pkg/diff/diff_test.go | 195 ++++++++++++++++++++++++++++++ pkg/output/output.go | 212 ++++++++++++++++++++++++++++++++ pkg/parser/parser.go | 140 ++++++++++++++++++++++ pkg/parser/parser_test.go | 145 ++++++++++++++++++++++ 19 files changed, 1778 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 examples/config1.ini create mode 100644 examples/config1.json create mode 100644 examples/config1.toml create mode 100644 examples/config1.yaml create mode 100644 examples/config2.ini create mode 100644 examples/config2.json create mode 100644 examples/config2.toml create mode 100644 examples/config2.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/ast/ast.go create mode 100644 pkg/ast/ast_test.go create mode 100644 pkg/diff/diff.go create mode 100644 pkg/diff/diff_test.go create mode 100644 pkg/output/output.go create mode 100644 pkg/parser/parser.go create mode 100644 pkg/parser/parser_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cb76c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Build output +go-config-diff + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 0477c0f..95721a2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,228 @@ # go-config-diff -A semantic configuration comparison tool for infra and app configs. + +A semantic configuration comparison tool for infrastructure and application configs. Compare YAML, JSON, TOML, and INI files intelligently - detecting meaningful differences while ignoring formatting and ordering variations. + +## Features + +- 🎯 **Semantic Comparison**: Compares configuration semantics, not just text +- 📝 **Multiple Formats**: Supports YAML, JSON, TOML, and INI files +- 🎨 **Colored Output**: Beautiful, easy-to-read colored diffs in terminal +- 📊 **Machine-Readable**: JSON output for integration with other tools +- 🔄 **Order-Independent**: Ignores key ordering in maps/objects +- 🧩 **Nested Structures**: Deep comparison of nested configurations +- 📈 **Change Summary**: Quick overview of additions, removals, and modifications + +## Installation + +### From Source + +```bash +go install github.com/BaseMax/go-config-diff/cmd/go-config-diff@latest +``` + +### Build Locally + +```bash +git clone https://github.com/BaseMax/go-config-diff.git +cd go-config-diff +go build -o go-config-diff ./cmd/go-config-diff +``` + +## Usage + +### Basic Usage + +```bash +go-config-diff config1.yaml config2.yaml +``` + +### Output Formats + +#### Colored Output (default) +Displays changes with color-coded indicators: +- 🟢 Green `+` for additions +- 🔴 Red `-` for removals +- 🟡 Yellow `~` for modifications + +```bash +go-config-diff config1.yaml config2.yaml +``` + +#### Plain Text Output +For environments without color support: + +```bash +go-config-diff -format=plain config1.json config2.json +``` + +#### JSON Output +Machine-readable format for automation: + +```bash +go-config-diff -format=json config1.toml config2.toml +``` + +### Show Summary + +Display a summary of changes: + +```bash +go-config-diff -summary config1.ini config2.ini +``` + +### Version Information + +```bash +go-config-diff -version +``` + +## Examples + +### YAML Comparison + +```bash +$ go-config-diff examples/config1.yaml examples/config2.yaml +~ database.credentials.password + - "secret123" + + "newsecret456" +~ database.name + - "myapp" + + "myapp_prod" ++ features[3] + + "caching" ++ server.timeout + + 60 +``` + +### JSON Output Format + +```bash +$ go-config-diff -format=json examples/config1.json examples/config2.json +{ + "has_changes": true, + "changes": [ + { + "type": "modified", + "path": "database.name", + "old_value": "myapp", + "new_value": "myapp_prod" + }, + { + "type": "added", + "path": "server.timeout", + "new_value": 60 + } + ] +} +``` + +### With Summary + +```bash +$ go-config-diff -summary examples/config1.toml examples/config2.toml +Summary: 2 added, 5 modified + +~ database.name + - "myapp" + + "myapp_prod" ++ features[3] + + {"name":"caching"} +``` + +## Supported File Formats + +| Format | Extensions | Description | +|--------|-----------|-------------| +| YAML | `.yaml`, `.yml` | YAML Ain't Markup Language | +| JSON | `.json` | JavaScript Object Notation | +| TOML | `.toml` | Tom's Obvious, Minimal Language | +| INI | `.ini`, `.conf` | Initialization files | + +## How It Works + +1. **Parse**: Each configuration file is parsed using format-specific parsers +2. **Normalize**: Data is converted to a unified internal AST (Abstract Syntax Tree) +3. **Compare**: Semantic comparison detects actual differences +4. **Report**: Changes are formatted for human or machine consumption + +### Key Capabilities + +- **Semantic Comparison**: Compares actual values, not file formatting +- **Type Awareness**: Understands different data types (strings, numbers, booleans, arrays, maps) +- **Deep Traversal**: Compares nested structures at any depth +- **Path Tracking**: Shows exact location of changes using dot notation +- **Order Independence**: Map/object key ordering doesn't affect comparison + +## Exit Codes + +- `0`: No differences found +- `1`: Differences found or error occurred + +## Command Line Options + +``` +Usage: go-config-diff [options] + +Options: + -format string + Output format: colored, plain, json (default "colored") + -summary + Show summary of changes + -version + Show version information +``` + +## Use Cases + +- 🔧 **DevOps**: Compare configuration files across environments +- 🚀 **CI/CD**: Validate configuration changes in pipelines +- 🔍 **Auditing**: Review configuration differences for compliance +- 🐛 **Debugging**: Identify configuration drift between systems +- 📦 **Releases**: Verify configuration changes before deployment + +## Development + +### Running Tests + +```bash +go test ./... -v +``` + +### Project Structure + +``` +go-config-diff/ +├── cmd/ +│ └── go-config-diff/ # CLI application +│ └── main.go +├── pkg/ +│ ├── ast/ # Unified AST model +│ │ ├── ast.go +│ │ └── ast_test.go +│ ├── parser/ # Format parsers +│ │ ├── parser.go +│ │ └── parser_test.go +│ ├── diff/ # Comparison logic +│ │ ├── diff.go +│ │ └── diff_test.go +│ └── output/ # Output formatters +│ └── output.go +├── examples/ # Sample config files +└── README.md +``` + +## License + +This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Author + +**Max Base** + +## Repository + +https://github.com/BaseMax/go-config-diff diff --git a/examples/config1.ini b/examples/config1.ini new file mode 100644 index 0000000..63afcd8 --- /dev/null +++ b/examples/config1.ini @@ -0,0 +1,16 @@ +[server] +host = localhost +port = 8080 +ssl = true + +[database] +host = db.example.com +port = 5432 +name = myapp +username = admin +password = secret123 + +[settings] +timeout = 30 +retries = 3 +debug = false diff --git a/examples/config1.json b/examples/config1.json new file mode 100644 index 0000000..d542afb --- /dev/null +++ b/examples/config1.json @@ -0,0 +1,26 @@ +{ + "server": { + "host": "localhost", + "port": 8080, + "ssl": true + }, + "database": { + "host": "db.example.com", + "port": 5432, + "name": "myapp", + "credentials": { + "username": "admin", + "password": "secret123" + } + }, + "features": [ + "authentication", + "logging", + "monitoring" + ], + "settings": { + "timeout": 30, + "retries": 3, + "debug": false + } +} diff --git a/examples/config1.toml b/examples/config1.toml new file mode 100644 index 0000000..d9b9466 --- /dev/null +++ b/examples/config1.toml @@ -0,0 +1,27 @@ +[server] +host = "localhost" +port = 8080 +ssl = true + +[database] +host = "db.example.com" +port = 5432 +name = "myapp" + +[database.credentials] +username = "admin" +password = "secret123" + +[[features]] +name = "authentication" + +[[features]] +name = "logging" + +[[features]] +name = "monitoring" + +[settings] +timeout = 30 +retries = 3 +debug = false diff --git a/examples/config1.yaml b/examples/config1.yaml new file mode 100644 index 0000000..17468d4 --- /dev/null +++ b/examples/config1.yaml @@ -0,0 +1,19 @@ +server: + host: localhost + port: 8080 + ssl: true +database: + host: db.example.com + port: 5432 + name: myapp + credentials: + username: admin + password: secret123 +features: + - authentication + - logging + - monitoring +settings: + timeout: 30 + retries: 3 + debug: false diff --git a/examples/config2.ini b/examples/config2.ini new file mode 100644 index 0000000..9e960aa --- /dev/null +++ b/examples/config2.ini @@ -0,0 +1,17 @@ +[server] +host = localhost +port = 8080 +ssl = true +timeout = 60 + +[database] +host = db.example.com +port = 5432 +name = myapp_prod +username = admin +password = newsecret456 + +[settings] +timeout = 45 +retries = 5 +debug = true diff --git a/examples/config2.json b/examples/config2.json new file mode 100644 index 0000000..5c74a4f --- /dev/null +++ b/examples/config2.json @@ -0,0 +1,28 @@ +{ + "server": { + "host": "localhost", + "port": 8080, + "ssl": true, + "timeout": 60 + }, + "database": { + "host": "db.example.com", + "port": 5432, + "name": "myapp_prod", + "credentials": { + "username": "admin", + "password": "newsecret456" + } + }, + "features": [ + "authentication", + "logging", + "monitoring", + "caching" + ], + "settings": { + "timeout": 45, + "retries": 5, + "debug": true + } +} diff --git a/examples/config2.toml b/examples/config2.toml new file mode 100644 index 0000000..9937b15 --- /dev/null +++ b/examples/config2.toml @@ -0,0 +1,31 @@ +[server] +host = "localhost" +port = 8080 +ssl = true +timeout = 60 + +[database] +host = "db.example.com" +port = 5432 +name = "myapp_prod" + +[database.credentials] +username = "admin" +password = "newsecret456" + +[[features]] +name = "authentication" + +[[features]] +name = "logging" + +[[features]] +name = "monitoring" + +[[features]] +name = "caching" + +[settings] +timeout = 45 +retries = 5 +debug = true diff --git a/examples/config2.yaml b/examples/config2.yaml new file mode 100644 index 0000000..a976483 --- /dev/null +++ b/examples/config2.yaml @@ -0,0 +1,21 @@ +server: + host: localhost + port: 8080 + ssl: true + timeout: 60 +database: + host: db.example.com + port: 5432 + name: myapp_prod + credentials: + username: admin + password: newsecret456 +features: + - authentication + - logging + - monitoring + - caching +settings: + timeout: 45 + retries: 5 + debug: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6cf9df5 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/BaseMax/go-config-diff + +go 1.24.11 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/fatih/color v1.18.0 + gopkg.in/ini.v1 v1.67.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a5ea551 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/ast/ast.go b/pkg/ast/ast.go new file mode 100644 index 0000000..0295027 --- /dev/null +++ b/pkg/ast/ast.go @@ -0,0 +1,198 @@ +package ast + +import ( + "fmt" + "reflect" + "sort" +) + +// Node represents a unified configuration node +type Node struct { + Type NodeType + Value interface{} +} + +// NodeType defines the type of configuration node +type NodeType int + +const ( + NodeTypeMap NodeType = iota + NodeTypeArray + NodeTypeString + NodeTypeNumber + NodeTypeBool + NodeTypeNull +) + +// NewNode creates a new Node from an interface value +func NewNode(v interface{}) *Node { + if v == nil { + return &Node{Type: NodeTypeNull, Value: nil} + } + + val := reflect.ValueOf(v) + switch val.Kind() { + case reflect.Map: + m := make(map[string]*Node) + iter := val.MapRange() + for iter.Next() { + key := fmt.Sprintf("%v", iter.Key().Interface()) + m[key] = NewNode(iter.Value().Interface()) + } + return &Node{Type: NodeTypeMap, Value: m} + case reflect.Slice, reflect.Array: + arr := make([]*Node, val.Len()) + for i := 0; i < val.Len(); i++ { + arr[i] = NewNode(val.Index(i).Interface()) + } + return &Node{Type: NodeTypeArray, Value: arr} + case reflect.String: + return &Node{Type: NodeTypeString, Value: val.String()} + case reflect.Bool: + return &Node{Type: NodeTypeBool, Value: val.Bool()} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return &Node{Type: NodeTypeNumber, Value: float64(val.Int())} + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return &Node{Type: NodeTypeNumber, Value: float64(val.Uint())} + case reflect.Float32, reflect.Float64: + return &Node{Type: NodeTypeNumber, Value: val.Float()} + case reflect.Interface, reflect.Ptr: + if val.IsNil() { + return &Node{Type: NodeTypeNull, Value: nil} + } + return NewNode(val.Elem().Interface()) + default: + return &Node{Type: NodeTypeString, Value: fmt.Sprintf("%v", v)} + } +} + +// Normalize ensures consistent representation for comparison +func (n *Node) Normalize() { + if n == nil { + return + } + + switch n.Type { + case NodeTypeMap: + if m, ok := n.Value.(map[string]*Node); ok { + for _, v := range m { + v.Normalize() + } + } + case NodeTypeArray: + if arr, ok := n.Value.([]*Node); ok { + for _, v := range arr { + v.Normalize() + } + } + case NodeTypeNumber: + // Normalize numbers to float64 + if num, ok := n.Value.(float64); ok { + n.Value = num + } + } +} + +// GetMapKeys returns sorted keys of a map node +func (n *Node) GetMapKeys() []string { + if n.Type != NodeTypeMap { + return nil + } + m, ok := n.Value.(map[string]*Node) + if !ok { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// GetMapValue returns the value for a key in a map node +func (n *Node) GetMapValue(key string) (*Node, bool) { + if n.Type != NodeTypeMap { + return nil, false + } + m, ok := n.Value.(map[string]*Node) + if !ok { + return nil, false + } + val, exists := m[key] + return val, exists +} + +// GetArrayLength returns the length of an array node +func (n *Node) GetArrayLength() int { + if n.Type != NodeTypeArray { + return 0 + } + arr, ok := n.Value.([]*Node) + if !ok { + return 0 + } + return len(arr) +} + +// GetArrayValue returns the value at index in an array node +func (n *Node) GetArrayValue(index int) (*Node, bool) { + if n.Type != NodeTypeArray { + return nil, false + } + arr, ok := n.Value.([]*Node) + if !ok || index < 0 || index >= len(arr) { + return nil, false + } + return arr[index], true +} + +// Equals compares two nodes for semantic equality +func (n *Node) Equals(other *Node) bool { + if n == nil && other == nil { + return true + } + if n == nil || other == nil { + return false + } + if n.Type != other.Type { + return false + } + + switch n.Type { + case NodeTypeNull: + return true + case NodeTypeString: + return n.Value.(string) == other.Value.(string) + case NodeTypeBool: + return n.Value.(bool) == other.Value.(bool) + case NodeTypeNumber: + return n.Value.(float64) == other.Value.(float64) + case NodeTypeMap: + m1, ok1 := n.Value.(map[string]*Node) + m2, ok2 := other.Value.(map[string]*Node) + if !ok1 || !ok2 || len(m1) != len(m2) { + return false + } + for k, v1 := range m1 { + v2, exists := m2[k] + if !exists || !v1.Equals(v2) { + return false + } + } + return true + case NodeTypeArray: + arr1, ok1 := n.Value.([]*Node) + arr2, ok2 := other.Value.([]*Node) + if !ok1 || !ok2 || len(arr1) != len(arr2) { + return false + } + for i := range arr1 { + if !arr1[i].Equals(arr2[i]) { + return false + } + } + return true + } + return false +} diff --git a/pkg/ast/ast_test.go b/pkg/ast/ast_test.go new file mode 100644 index 0000000..0389351 --- /dev/null +++ b/pkg/ast/ast_test.go @@ -0,0 +1,156 @@ +package ast + +import ( + "testing" +) + +func TestNewNode(t *testing.T) { + tests := []struct { + name string + input interface{} + expected NodeType + }{ + {"nil", nil, NodeTypeNull}, + {"string", "hello", NodeTypeString}, + {"bool", true, NodeTypeBool}, + {"int", 42, NodeTypeNumber}, + {"float", 3.14, NodeTypeNumber}, + {"map", map[string]interface{}{"key": "value"}, NodeTypeMap}, + {"slice", []interface{}{"a", "b"}, NodeTypeArray}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node := NewNode(tt.input) + if node.Type != tt.expected { + t.Errorf("expected type %v, got %v", tt.expected, node.Type) + } + }) + } +} + +func TestNodeEquals(t *testing.T) { + tests := []struct { + name string + node1 *Node + node2 *Node + expected bool + }{ + { + "equal strings", + &Node{Type: NodeTypeString, Value: "hello"}, + &Node{Type: NodeTypeString, Value: "hello"}, + true, + }, + { + "different strings", + &Node{Type: NodeTypeString, Value: "hello"}, + &Node{Type: NodeTypeString, Value: "world"}, + false, + }, + { + "equal numbers", + &Node{Type: NodeTypeNumber, Value: 42.0}, + &Node{Type: NodeTypeNumber, Value: 42.0}, + true, + }, + { + "different types", + &Node{Type: NodeTypeString, Value: "42"}, + &Node{Type: NodeTypeNumber, Value: 42.0}, + false, + }, + { + "both nil", + nil, + nil, + true, + }, + { + "one nil", + &Node{Type: NodeTypeNull, Value: nil}, + nil, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.node1.Equals(tt.node2) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestNodeGetMapKeys(t *testing.T) { + data := map[string]interface{}{ + "c": "third", + "a": "first", + "b": "second", + } + node := NewNode(data) + keys := node.GetMapKeys() + + if len(keys) != 3 { + t.Errorf("expected 3 keys, got %d", len(keys)) + } + + // Keys should be sorted + expected := []string{"a", "b", "c"} + for i, key := range keys { + if key != expected[i] { + t.Errorf("expected key %s at position %d, got %s", expected[i], i, key) + } + } +} + +func TestNodeGetMapValue(t *testing.T) { + data := map[string]interface{}{ + "key1": "value1", + "key2": 42, + } + node := NewNode(data) + + val, exists := node.GetMapValue("key1") + if !exists { + t.Error("expected key1 to exist") + } + if val.Type != NodeTypeString { + t.Errorf("expected string type, got %v", val.Type) + } + + _, exists = node.GetMapValue("nonexistent") + if exists { + t.Error("expected nonexistent key to not exist") + } +} + +func TestNodeGetArrayLength(t *testing.T) { + data := []interface{}{"a", "b", "c"} + node := NewNode(data) + + length := node.GetArrayLength() + if length != 3 { + t.Errorf("expected length 3, got %d", length) + } +} + +func TestNodeGetArrayValue(t *testing.T) { + data := []interface{}{"a", "b", "c"} + node := NewNode(data) + + val, exists := node.GetArrayValue(1) + if !exists { + t.Error("expected index 1 to exist") + } + if val.Type != NodeTypeString || val.Value != "b" { + t.Errorf("expected 'b', got %v", val.Value) + } + + _, exists = node.GetArrayValue(10) + if exists { + t.Error("expected index 10 to not exist") + } +} diff --git a/pkg/diff/diff.go b/pkg/diff/diff.go new file mode 100644 index 0000000..f577c3d --- /dev/null +++ b/pkg/diff/diff.go @@ -0,0 +1,246 @@ +package diff + +import ( + "fmt" + + "github.com/BaseMax/go-config-diff/pkg/ast" +) + +// ChangeType represents the type of change +type ChangeType int + +const ( + ChangeTypeAdded ChangeType = iota + ChangeTypeRemoved + ChangeTypeModified + ChangeTypeEqual +) + +func (ct ChangeType) String() string { + switch ct { + case ChangeTypeAdded: + return "added" + case ChangeTypeRemoved: + return "removed" + case ChangeTypeModified: + return "modified" + case ChangeTypeEqual: + return "equal" + default: + return "unknown" + } +} + +// Change represents a single difference +type Change struct { + Type ChangeType + Path string + OldValue interface{} + NewValue interface{} +} + +// Diff contains all differences between two configurations +type Diff struct { + Changes []Change +} + +// HasChanges returns true if there are any differences +func (d *Diff) HasChanges() bool { + for _, c := range d.Changes { + if c.Type != ChangeTypeEqual { + return true + } + } + return false +} + +// Compare compares two AST nodes and returns differences +func Compare(oldNode, newNode *ast.Node) *Diff { + diff := &Diff{ + Changes: []Change{}, + } + compareNodes(diff, "", oldNode, newNode) + return diff +} + +func compareNodes(diff *Diff, path string, oldNode, newNode *ast.Node) { + // Both nil + if oldNode == nil && newNode == nil { + return + } + + // One is nil + if oldNode == nil { + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeAdded, + Path: path, + OldValue: nil, + NewValue: nodeToValue(newNode), + }) + return + } + if newNode == nil { + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeRemoved, + Path: path, + OldValue: nodeToValue(oldNode), + NewValue: nil, + }) + return + } + + // Different types + if oldNode.Type != newNode.Type { + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeModified, + Path: path, + OldValue: nodeToValue(oldNode), + NewValue: nodeToValue(newNode), + }) + return + } + + // Same type, compare values + switch oldNode.Type { + case ast.NodeTypeMap: + compareMap(diff, path, oldNode, newNode) + case ast.NodeTypeArray: + compareArray(diff, path, oldNode, newNode) + default: + if !oldNode.Equals(newNode) { + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeModified, + Path: path, + OldValue: nodeToValue(oldNode), + NewValue: nodeToValue(newNode), + }) + } + } +} + +func compareMap(diff *Diff, path string, oldNode, newNode *ast.Node) { + oldKeys := make(map[string]bool) + for _, key := range oldNode.GetMapKeys() { + oldKeys[key] = true + } + + newKeys := make(map[string]bool) + for _, key := range newNode.GetMapKeys() { + newKeys[key] = true + } + + // Check all keys + allKeys := make(map[string]bool) + for k := range oldKeys { + allKeys[k] = true + } + for k := range newKeys { + allKeys[k] = true + } + + for key := range allKeys { + keyPath := key + if path != "" { + keyPath = path + "." + key + } + + oldVal, oldExists := oldNode.GetMapValue(key) + newVal, newExists := newNode.GetMapValue(key) + + if oldExists && newExists { + compareNodes(diff, keyPath, oldVal, newVal) + } else if oldExists { + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeRemoved, + Path: keyPath, + OldValue: nodeToValue(oldVal), + NewValue: nil, + }) + } else { + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeAdded, + Path: keyPath, + OldValue: nil, + NewValue: nodeToValue(newVal), + }) + } + } +} + +func compareArray(diff *Diff, path string, oldNode, newNode *ast.Node) { + oldLen := oldNode.GetArrayLength() + newLen := newNode.GetArrayLength() + + // Compare up to the shorter length + minLen := oldLen + if newLen < minLen { + minLen = newLen + } + + for i := 0; i < minLen; i++ { + indexPath := fmt.Sprintf("%s[%d]", path, i) + oldVal, _ := oldNode.GetArrayValue(i) + newVal, _ := newNode.GetArrayValue(i) + compareNodes(diff, indexPath, oldVal, newVal) + } + + // Handle extra elements + if oldLen > newLen { + for i := newLen; i < oldLen; i++ { + indexPath := fmt.Sprintf("%s[%d]", path, i) + oldVal, _ := oldNode.GetArrayValue(i) + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeRemoved, + Path: indexPath, + OldValue: nodeToValue(oldVal), + NewValue: nil, + }) + } + } else if newLen > oldLen { + for i := oldLen; i < newLen; i++ { + indexPath := fmt.Sprintf("%s[%d]", path, i) + newVal, _ := newNode.GetArrayValue(i) + diff.Changes = append(diff.Changes, Change{ + Type: ChangeTypeAdded, + Path: indexPath, + OldValue: nil, + NewValue: nodeToValue(newVal), + }) + } + } +} + +func nodeToValue(node *ast.Node) interface{} { + if node == nil { + return nil + } + + switch node.Type { + case ast.NodeTypeNull: + return nil + case ast.NodeTypeString, ast.NodeTypeBool, ast.NodeTypeNumber: + return node.Value + case ast.NodeTypeMap: + m, ok := node.Value.(map[string]*ast.Node) + if !ok { + return node.Value + } + result := make(map[string]interface{}) + for k, v := range m { + result[k] = nodeToValue(v) + } + return result + case ast.NodeTypeArray: + arr, ok := node.Value.([]*ast.Node) + if !ok { + return node.Value + } + result := make([]interface{}, len(arr)) + for i, v := range arr { + result[i] = nodeToValue(v) + } + return result + default: + return node.Value + } +} diff --git a/pkg/diff/diff_test.go b/pkg/diff/diff_test.go new file mode 100644 index 0000000..3c8e17a --- /dev/null +++ b/pkg/diff/diff_test.go @@ -0,0 +1,195 @@ +package diff + +import ( + "testing" + + "github.com/BaseMax/go-config-diff/pkg/ast" +) + +func TestCompareEqual(t *testing.T) { + data := map[string]interface{}{ + "key": "value", + "num": 42, + } + node1 := ast.NewNode(data) + node2 := ast.NewNode(data) + + diff := Compare(node1, node2) + + if diff.HasChanges() { + t.Error("expected no changes for equal nodes") + } +} + +func TestCompareAdded(t *testing.T) { + data1 := map[string]interface{}{ + "key1": "value1", + } + data2 := map[string]interface{}{ + "key1": "value1", + "key2": "value2", + } + + node1 := ast.NewNode(data1) + node2 := ast.NewNode(data2) + + diff := Compare(node1, node2) + + if !diff.HasChanges() { + t.Error("expected changes") + } + + addedCount := 0 + for _, change := range diff.Changes { + if change.Type == ChangeTypeAdded { + addedCount++ + if change.Path != "key2" { + t.Errorf("expected path 'key2', got '%s'", change.Path) + } + } + } + + if addedCount != 1 { + t.Errorf("expected 1 added change, got %d", addedCount) + } +} + +func TestCompareRemoved(t *testing.T) { + data1 := map[string]interface{}{ + "key1": "value1", + "key2": "value2", + } + data2 := map[string]interface{}{ + "key1": "value1", + } + + node1 := ast.NewNode(data1) + node2 := ast.NewNode(data2) + + diff := Compare(node1, node2) + + if !diff.HasChanges() { + t.Error("expected changes") + } + + removedCount := 0 + for _, change := range diff.Changes { + if change.Type == ChangeTypeRemoved { + removedCount++ + if change.Path != "key2" { + t.Errorf("expected path 'key2', got '%s'", change.Path) + } + } + } + + if removedCount != 1 { + t.Errorf("expected 1 removed change, got %d", removedCount) + } +} + +func TestCompareModified(t *testing.T) { + data1 := map[string]interface{}{ + "key": "value1", + } + data2 := map[string]interface{}{ + "key": "value2", + } + + node1 := ast.NewNode(data1) + node2 := ast.NewNode(data2) + + diff := Compare(node1, node2) + + if !diff.HasChanges() { + t.Error("expected changes") + } + + modifiedCount := 0 + for _, change := range diff.Changes { + if change.Type == ChangeTypeModified { + modifiedCount++ + if change.Path != "key" { + t.Errorf("expected path 'key', got '%s'", change.Path) + } + if change.OldValue != "value1" { + t.Errorf("expected old value 'value1', got '%v'", change.OldValue) + } + if change.NewValue != "value2" { + t.Errorf("expected new value 'value2', got '%v'", change.NewValue) + } + } + } + + if modifiedCount != 1 { + t.Errorf("expected 1 modified change, got %d", modifiedCount) + } +} + +func TestCompareNested(t *testing.T) { + data1 := map[string]interface{}{ + "server": map[string]interface{}{ + "host": "localhost", + "port": 8080, + }, + } + data2 := map[string]interface{}{ + "server": map[string]interface{}{ + "host": "example.com", + "port": 8080, + }, + } + + node1 := ast.NewNode(data1) + node2 := ast.NewNode(data2) + + diff := Compare(node1, node2) + + if !diff.HasChanges() { + t.Error("expected changes") + } + + for _, change := range diff.Changes { + if change.Type == ChangeTypeModified { + if change.Path != "server.host" { + t.Errorf("expected path 'server.host', got '%s'", change.Path) + } + } + } +} + +func TestCompareArray(t *testing.T) { + data1 := map[string]interface{}{ + "items": []interface{}{"a", "b", "c"}, + } + data2 := map[string]interface{}{ + "items": []interface{}{"a", "x", "c", "d"}, + } + + node1 := ast.NewNode(data1) + node2 := ast.NewNode(data2) + + diff := Compare(node1, node2) + + if !diff.HasChanges() { + t.Error("expected changes") + } + + hasModified := false + hasAdded := false + + for _, change := range diff.Changes { + if change.Path == "items[1]" && change.Type == ChangeTypeModified { + hasModified = true + } + if change.Path == "items[3]" && change.Type == ChangeTypeAdded { + hasAdded = true + } + } + + if !hasModified { + t.Error("expected modified change at items[1]") + } + if !hasAdded { + t.Error("expected added change at items[3]") + } +} diff --git a/pkg/output/output.go b/pkg/output/output.go new file mode 100644 index 0000000..257d0b3 --- /dev/null +++ b/pkg/output/output.go @@ -0,0 +1,212 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/BaseMax/go-config-diff/pkg/diff" + "github.com/fatih/color" +) + +// Format represents the output format +type Format string + +const ( + FormatColored Format = "colored" + FormatJSON Format = "json" + FormatPlain Format = "plain" +) + +// Printer handles output formatting +type Printer struct { + Writer io.Writer + Format Format +} + +// NewPrinter creates a new output printer +func NewPrinter(w io.Writer, format Format) *Printer { + return &Printer{ + Writer: w, + Format: format, + } +} + +// Print outputs the diff in the specified format +func (p *Printer) Print(d *diff.Diff) error { + switch p.Format { + case FormatColored: + return p.printColored(d) + case FormatJSON: + return p.printJSON(d) + case FormatPlain: + return p.printPlain(d) + default: + return fmt.Errorf("unsupported output format: %s", p.Format) + } +} + +func (p *Printer) printColored(d *diff.Diff) error { + if !d.HasChanges() { + fmt.Fprintln(p.Writer, color.GreenString("No differences found.")) + return nil + } + + green := color.New(color.FgGreen).SprintFunc() + red := color.New(color.FgRed).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + + for _, change := range d.Changes { + if change.Type == diff.ChangeTypeEqual { + continue + } + + path := cyan(change.Path) + + switch change.Type { + case diff.ChangeTypeAdded: + fmt.Fprintf(p.Writer, "%s %s\n", green("+"), path) + fmt.Fprintf(p.Writer, " %s %s\n", green("+"), green(formatValue(change.NewValue))) + case diff.ChangeTypeRemoved: + fmt.Fprintf(p.Writer, "%s %s\n", red("-"), path) + fmt.Fprintf(p.Writer, " %s %s\n", red("-"), red(formatValue(change.OldValue))) + case diff.ChangeTypeModified: + fmt.Fprintf(p.Writer, "%s %s\n", yellow("~"), path) + fmt.Fprintf(p.Writer, " %s %s\n", red("-"), red(formatValue(change.OldValue))) + fmt.Fprintf(p.Writer, " %s %s\n", green("+"), green(formatValue(change.NewValue))) + } + } + + return nil +} + +func (p *Printer) printPlain(d *diff.Diff) error { + if !d.HasChanges() { + fmt.Fprintln(p.Writer, "No differences found.") + return nil + } + + for _, change := range d.Changes { + if change.Type == diff.ChangeTypeEqual { + continue + } + + switch change.Type { + case diff.ChangeTypeAdded: + fmt.Fprintf(p.Writer, "+ %s\n", change.Path) + fmt.Fprintf(p.Writer, " + %s\n", formatValue(change.NewValue)) + case diff.ChangeTypeRemoved: + fmt.Fprintf(p.Writer, "- %s\n", change.Path) + fmt.Fprintf(p.Writer, " - %s\n", formatValue(change.OldValue)) + case diff.ChangeTypeModified: + fmt.Fprintf(p.Writer, "~ %s\n", change.Path) + fmt.Fprintf(p.Writer, " - %s\n", formatValue(change.OldValue)) + fmt.Fprintf(p.Writer, " + %s\n", formatValue(change.NewValue)) + } + } + + return nil +} + +func (p *Printer) printJSON(d *diff.Diff) error { + type JSONChange struct { + Type string `json:"type"` + Path string `json:"path"` + OldValue interface{} `json:"old_value,omitempty"` + NewValue interface{} `json:"new_value,omitempty"` + } + + type JSONOutput struct { + HasChanges bool `json:"has_changes"` + Changes []JSONChange `json:"changes"` + } + + output := JSONOutput{ + HasChanges: d.HasChanges(), + Changes: make([]JSONChange, 0), + } + + for _, change := range d.Changes { + if change.Type == diff.ChangeTypeEqual { + continue + } + + jc := JSONChange{ + Type: change.Type.String(), + Path: change.Path, + OldValue: change.OldValue, + NewValue: change.NewValue, + } + output.Changes = append(output.Changes, jc) + } + + encoder := json.NewEncoder(p.Writer) + encoder.SetIndent("", " ") + return encoder.Encode(output) +} + +func formatValue(v interface{}) string { + if v == nil { + return "null" + } + + switch val := v.(type) { + case string: + return fmt.Sprintf("%q", val) + case map[string]interface{}: + // Format as compact JSON + data, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(data) + case []interface{}: + // Format as compact JSON + data, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(data) + default: + return fmt.Sprintf("%v", val) + } +} + +// PrintSummary prints a summary of changes +func (p *Printer) PrintSummary(d *diff.Diff) error { + added := 0 + removed := 0 + modified := 0 + + for _, change := range d.Changes { + switch change.Type { + case diff.ChangeTypeAdded: + added++ + case diff.ChangeTypeRemoved: + removed++ + case diff.ChangeTypeModified: + modified++ + } + } + + var parts []string + if added > 0 { + parts = append(parts, fmt.Sprintf("%d added", added)) + } + if removed > 0 { + parts = append(parts, fmt.Sprintf("%d removed", removed)) + } + if modified > 0 { + parts = append(parts, fmt.Sprintf("%d modified", modified)) + } + + if len(parts) == 0 { + fmt.Fprintln(p.Writer, "Summary: No changes") + } else { + fmt.Fprintf(p.Writer, "Summary: %s\n", strings.Join(parts, ", ")) + } + + return nil +} diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go new file mode 100644 index 0000000..513771c --- /dev/null +++ b/pkg/parser/parser.go @@ -0,0 +1,140 @@ +package parser + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/BaseMax/go-config-diff/pkg/ast" + "github.com/BurntSushi/toml" + "gopkg.in/ini.v1" + "gopkg.in/yaml.v3" +) + +// Format represents the configuration file format +type Format string + +const ( + FormatYAML Format = "yaml" + FormatJSON Format = "json" + FormatTOML Format = "toml" + FormatINI Format = "ini" +) + +// DetectFormat detects the format from file extension +func DetectFormat(filename string) Format { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".yaml", ".yml": + return FormatYAML + case ".json": + return FormatJSON + case ".toml": + return FormatTOML + case ".ini", ".conf": + return FormatINI + default: + return "" + } +} + +// Parse parses a configuration file into an AST node +func Parse(filename string) (*ast.Node, error) { + format := DetectFormat(filename) + if format == "" { + return nil, fmt.Errorf("unsupported file format: %s", filename) + } + + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + return ParseReader(file, format) +} + +// ParseReader parses configuration data from a reader +func ParseReader(r io.Reader, format Format) (*ast.Node, error) { + var data interface{} + var err error + + switch format { + case FormatYAML: + data, err = parseYAML(r) + case FormatJSON: + data, err = parseJSON(r) + case FormatTOML: + data, err = parseTOML(r) + case FormatINI: + data, err = parseINI(r) + default: + return nil, fmt.Errorf("unsupported format: %s", format) + } + + if err != nil { + return nil, err + } + + node := ast.NewNode(data) + node.Normalize() + return node, nil +} + +func parseYAML(r io.Reader) (interface{}, error) { + var data interface{} + decoder := yaml.NewDecoder(r) + if err := decoder.Decode(&data); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + return data, nil +} + +func parseJSON(r io.Reader) (interface{}, error) { + var data interface{} + decoder := json.NewDecoder(r) + if err := decoder.Decode(&data); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + return data, nil +} + +func parseTOML(r io.Reader) (interface{}, error) { + var data interface{} + if _, err := toml.NewDecoder(r).Decode(&data); err != nil { + return nil, fmt.Errorf("failed to parse TOML: %w", err) + } + return data, nil +} + +func parseINI(r io.Reader) (interface{}, error) { + cfg, err := ini.Load(r) + if err != nil { + return nil, fmt.Errorf("failed to parse INI: %w", err) + } + + // Convert INI to map structure + result := make(map[string]interface{}) + + for _, section := range cfg.Sections() { + sectionName := section.Name() + if sectionName == ini.DefaultSection { + // Put default section keys at root level + for _, key := range section.Keys() { + result[key.Name()] = key.Value() + } + } else { + // Create nested map for named sections + sectionMap := make(map[string]interface{}) + for _, key := range section.Keys() { + sectionMap[key.Name()] = key.Value() + } + result[sectionName] = sectionMap + } + } + + return result, nil +} diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go new file mode 100644 index 0000000..eacb918 --- /dev/null +++ b/pkg/parser/parser_test.go @@ -0,0 +1,145 @@ +package parser + +import ( + "strings" + "testing" + + "github.com/BaseMax/go-config-diff/pkg/ast" +) + +func TestDetectFormat(t *testing.T) { + tests := []struct { + filename string + expected Format + }{ + {"config.yaml", FormatYAML}, + {"config.yml", FormatYAML}, + {"config.json", FormatJSON}, + {"config.toml", FormatTOML}, + {"config.ini", FormatINI}, + {"config.conf", FormatINI}, + {"config.txt", ""}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + result := DetectFormat(tt.filename) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestParseYAML(t *testing.T) { + yamlData := ` +server: + host: localhost + port: 8080 +enabled: true +` + node, err := ParseReader(strings.NewReader(yamlData), FormatYAML) + if err != nil { + t.Fatalf("failed to parse YAML: %v", err) + } + + if node.Type != ast.NodeTypeMap { + t.Errorf("expected map type, got %v", node.Type) + } + + server, exists := node.GetMapValue("server") + if !exists { + t.Error("expected 'server' key to exist") + } + + if server.Type != ast.NodeTypeMap { + t.Errorf("expected map type for server, got %v", server.Type) + } +} + +func TestParseJSON(t *testing.T) { + jsonData := `{ + "server": { + "host": "localhost", + "port": 8080 + }, + "enabled": true + }` + node, err := ParseReader(strings.NewReader(jsonData), FormatJSON) + if err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if node.Type != ast.NodeTypeMap { + t.Errorf("expected map type, got %v", node.Type) + } + + server, exists := node.GetMapValue("server") + if !exists { + t.Error("expected 'server' key to exist") + } + + if server.Type != ast.NodeTypeMap { + t.Errorf("expected map type for server, got %v", server.Type) + } +} + +func TestParseTOML(t *testing.T) { + tomlData := ` +[server] +host = "localhost" +port = 8080 +enabled = true +` + node, err := ParseReader(strings.NewReader(tomlData), FormatTOML) + if err != nil { + t.Fatalf("failed to parse TOML: %v", err) + } + + if node.Type != ast.NodeTypeMap { + t.Errorf("expected map type, got %v", node.Type) + } + + server, exists := node.GetMapValue("server") + if !exists { + t.Error("expected 'server' key to exist") + } + + if server.Type != ast.NodeTypeMap { + t.Errorf("expected map type for server, got %v", server.Type) + } +} + +func TestParseINI(t *testing.T) { + iniData := ` +[server] +host = localhost +port = 8080 +enabled = true +` + node, err := ParseReader(strings.NewReader(iniData), FormatINI) + if err != nil { + t.Fatalf("failed to parse INI: %v", err) + } + + if node.Type != ast.NodeTypeMap { + t.Errorf("expected map type, got %v", node.Type) + } + + server, exists := node.GetMapValue("server") + if !exists { + t.Error("expected 'server' key to exist") + } + + if server.Type != ast.NodeTypeMap { + t.Errorf("expected map type for server, got %v", server.Type) + } +} + +func TestParseInvalidFormat(t *testing.T) { + data := "some data" + _, err := ParseReader(strings.NewReader(data), Format("invalid")) + if err == nil { + t.Error("expected error for invalid format") + } +} From cac508c3e704a037458b82882ea4526502968c3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:44:16 +0000 Subject: [PATCH 3/4] Add Makefile for easier development workflow Co-authored-by: BaseMax <2658040+BaseMax@users.noreply.github.com> --- Makefile | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e5f2df --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.PHONY: build test install clean run help + +# Binary name +BINARY_NAME=go-config-diff + +# Build the binary +build: + @echo "Building $(BINARY_NAME)..." + @go build -o $(BINARY_NAME) ./cmd/go-config-diff + +# Run tests +test: + @echo "Running tests..." + @go test ./... -v + +# Install the binary +install: + @echo "Installing $(BINARY_NAME)..." + @go install ./cmd/go-config-diff + +# Clean build artifacts +clean: + @echo "Cleaning..." + @rm -f $(BINARY_NAME) + @go clean + +# Run the application with example +run: build + @echo "Running example..." + @./$(BINARY_NAME) examples/config1.yaml examples/config2.yaml || true + +# Show help +help: + @echo "Available targets:" + @echo " build - Build the binary" + @echo " test - Run tests" + @echo " install - Install the binary to GOPATH/bin" + @echo " clean - Clean build artifacts" + @echo " run - Build and run with example files" + @echo " help - Show this help message" From 6a5f216d7c1bf7d9f0cfb988b80ccef070989a18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:45:48 +0000 Subject: [PATCH 4/4] Add quick reference guide and contributing guidelines Co-authored-by: BaseMax <2658040+BaseMax@users.noreply.github.com> --- CONTRIBUTING.md | 166 ++++++++++++++++++++++++++++++++++++++++++++++++ QUICKREF.md | 127 ++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 QUICKREF.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..27516c7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,166 @@ +# Contributing to go-config-diff + +Thank you for your interest in contributing to go-config-diff! This document provides guidelines and instructions for contributing. + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/go-config-diff.git` +3. Create a feature branch: `git checkout -b feature/your-feature-name` +4. Make your changes +5. Run tests: `make test` +6. Commit your changes: `git commit -am 'Add some feature'` +7. Push to the branch: `git push origin feature/your-feature-name` +8. Create a Pull Request + +## Development Setup + +### Prerequisites + +- Go 1.24 or later +- Make (optional, for using Makefile targets) + +### Building + +```bash +make build +``` + +### Running Tests + +```bash +make test +``` + +### Running the Tool + +```bash +make run +``` + +## Code Style + +- Follow standard Go conventions +- Use `gofmt` to format your code +- Write meaningful commit messages +- Add comments for complex logic +- Keep functions focused and small + +## Testing + +- Write tests for new features +- Ensure all tests pass before submitting PR +- Add test cases for edge cases +- Update existing tests if behavior changes + +### Test Structure + +``` +pkg/ + ast/ + ast.go + ast_test.go # Tests for AST functionality + parser/ + parser.go + parser_test.go # Tests for parsers + diff/ + diff.go + diff_test.go # Tests for diff logic +``` + +## Adding a New Format + +To add support for a new configuration format: + +1. Add the format constant to `pkg/parser/parser.go`: + ```go + const FormatXML Format = "xml" + ``` + +2. Update `DetectFormat()` to recognize the file extension + +3. Implement a parser function: + ```go + func parseXML(r io.Reader) (interface{}, error) { + // Parse XML and return as interface{} + } + ``` + +4. Add the case to `ParseReader()` switch statement + +5. Add tests in `pkg/parser/parser_test.go` + +6. Add example files in `examples/` directory + +7. Update documentation + +## Pull Request Guidelines + +- **Title**: Use a clear, descriptive title +- **Description**: Explain what changes you made and why +- **Tests**: Include tests for new functionality +- **Documentation**: Update README or other docs if needed +- **One feature per PR**: Keep PRs focused on a single feature or fix + +## Reporting Issues + +When reporting issues, please include: + +- Go version: `go version` +- Operating system and version +- Clear description of the problem +- Steps to reproduce +- Expected vs actual behavior +- Error messages or logs +- Sample configuration files (if applicable) + +## Feature Requests + +We welcome feature requests! Please: + +- Check if the feature already exists +- Search existing issues for similar requests +- Provide a clear use case +- Explain why the feature would be valuable + +## Code Review Process + +1. All PRs require review before merging +2. Address review comments +3. Keep the discussion constructive and professional +4. PRs may be updated by maintainers to fix minor issues + +## Project Structure + +``` +go-config-diff/ +├── cmd/ +│ └── go-config-diff/ # CLI entry point +├── pkg/ +│ ├── ast/ # Abstract Syntax Tree +│ ├── parser/ # Format parsers +│ ├── diff/ # Comparison logic +│ └── output/ # Output formatters +├── examples/ # Example config files +├── Makefile # Build automation +└── README.md # Documentation +``` + +## Areas for Contribution + +- **New format support**: Add XML, HOCON, or other formats +- **Output formats**: Add more output options (HTML, Markdown, etc.) +- **Performance**: Optimize comparison for large files +- **Documentation**: Improve examples and guides +- **Tests**: Add more test coverage +- **Bug fixes**: Fix reported issues + +## Questions? + +Feel free to open an issue with your question or reach out to the maintainers. + +## License + +By contributing, you agree that your contributions will be licensed under the GPL-3.0 License. + +Thank you for contributing to go-config-diff! 🎉 diff --git a/QUICKREF.md b/QUICKREF.md new file mode 100644 index 0000000..9c110b6 --- /dev/null +++ b/QUICKREF.md @@ -0,0 +1,127 @@ +# Quick Reference Guide + +## Installation + +```bash +go install github.com/BaseMax/go-config-diff/cmd/go-config-diff@latest +``` + +## Basic Commands + +```bash +# Compare two config files +go-config-diff config1.yaml config2.yaml + +# Use different output format +go-config-diff -format=json config1.json config2.json +go-config-diff -format=plain config1.toml config2.toml + +# Show summary of changes +go-config-diff -summary config1.ini config2.ini + +# Show version +go-config-diff -version + +# Show help +go-config-diff -help +``` + +## Output Symbols + +- `+` Green: Added field or value +- `-` Red: Removed field or value +- `~` Yellow: Modified field or value + +## Exit Codes + +- `0`: No differences found +- `1`: Differences found or error occurred + +## Supported Formats + +- YAML (`.yaml`, `.yml`) +- JSON (`.json`) +- TOML (`.toml`) +- INI (`.ini`, `.conf`) + +## Use Cases + +### CI/CD Pipeline +```bash +# Exit with error if configs differ +go-config-diff prod-config.yaml staging-config.yaml +``` + +### Generate Machine-Readable Report +```bash +# Output JSON for further processing +go-config-diff -format=json old.json new.json > diff-report.json +``` + +### Quick Visual Check +```bash +# Colored output with summary +go-config-diff -summary app-v1.yaml app-v2.yaml +``` + +## Features + +✅ Semantic comparison (not line-by-line) +✅ Ignores key ordering in objects/maps +✅ Deep nested structure comparison +✅ Type-aware comparison +✅ Multiple output formats +✅ Colored terminal output +✅ Path tracking for changes +✅ Summary statistics + +## Common Patterns + +### Compare across formats +```bash +# Even different formats are compared semantically +go-config-diff config.yaml config.json +``` + +### Integration with Git +```bash +# Compare config between branches +git show main:config.yaml > /tmp/old.yaml +git show feature:config.yaml > /tmp/new.yaml +go-config-diff /tmp/old.yaml /tmp/new.yaml +``` + +### Automation +```bash +# Use in scripts +if go-config-diff base.yaml custom.yaml; then + echo "Configs are identical" +else + echo "Configs differ" +fi +``` + +## Building from Source + +```bash +git clone https://github.com/BaseMax/go-config-diff.git +cd go-config-diff +make build +./go-config-diff -version +``` + +## Development + +```bash +# Run tests +make test + +# Build binary +make build + +# Run with examples +make run + +# Clean artifacts +make clean +```