diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..93833f2 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,5 @@ +name: CodeQL config + +paths-ignore: + - mocks + - testdata diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..76b614e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,59 @@ +# Development Guidelines + +This document contains the critical information about working with the project codebase. +Follows these guidelines precisely to ensure consistency and maintainability of the code. + +## Stack + +- Language: Go (Go 1.26) +- Framework: Go standard library +- Testing: Go's built-in testing package +- Dependency Management: Go modules +- Version Control: Git +- Documentation: go doc +- Code Review: Pull requests on GitHub +- CI/CD: GitHub Actions + +## Project Structure + +Since this is a library built in native Go, the files are organized following the +standard Go single-package layout in the repository root. + +- Library files are located in the root directory. +- `*_examples_test.go` files contain executable Go examples rendered by pkg.go.dev. +- .github/ contains GitHub-specific files such as workflows for CI/CD. +- .gitignore specifies files and directories to be ignored by Git. +- .vscode/ contains Visual Studio Code configuration files. +- LICENSE is the license file for the project. +- README.md provides an overview of the project, installation instructions, usage examples, and other relevant information. +- go.mod declares the module and Go toolchain version. +- go.sum should not exist unless external dependencies are intentionally introduced. +- \*.go files contain the main source code of the library. +- \*\_test.go files contain the test cases for the library. + +## Code Style + +- Follow Go's idiomatic style defined in + - + - + - + - +- Use meaningful names for variables, functions, and packages. +- Keep functions small and focused on a single task. +- Use comments to explain complex logic or decisions. +- Prefer `for b.Loop()` over `for i := 0; i < b.N; i++` in benchmarks (Go 1.24+). +- don't use `interface{}` instead use `any` for better readability. + +## Post-Change Checklist + +Use standard Go commands after making changes: + +```bash +go fix ./... +go fmt ./... +go vet ./... +go test -race -coverprofile=/tmp/comparator-coverage.txt -covermode=atomic ./... +go build ./... +``` + +Do not add external module dependencies without explicit approval; this project is intentionally standard-library-only. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7fbaf5f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a032ef7 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,16 @@ +changelog: + categories: + - title: Breaking Changes + labels: + - Semver-Major + - breaking-change + - title: New Features + labels: + - Semver-Minor + - enhancement + - title: Security + labels: + - security + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..796c048 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: CodeQL Advanced + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "10 12 * * 3" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:${{ matrix.language }} \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..f435404 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,81 @@ +name: Main + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Push Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Push:** ${{ github.event.head_commit.message }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Author:** ${{ github.event.head_commit.author.name }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** ${{ github.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Tools and versions + run: | + echo "## Tools and versions" >> "$GITHUB_STEP_SUMMARY" + echo "**Ubuntu Version:** $(lsb_release -ds)" >> "$GITHUB_STEP_SUMMARY" + echo "**Bash Version:** $(bash --version | head -n 1 | awk '{print $4}')" >> "$GITHUB_STEP_SUMMARY" + echo "**Git Version:** $(git --version | awk '{print $3}')" >> "$GITHUB_STEP_SUMMARY" + echo "**Go Version:** $(go version | awk '{print $3}')" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + echo "## Format check" >> "$GITHUB_STEP_SUMMARY" + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + echo "The files above need gofmt." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + echo "All Go files are gofmt-formatted." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Vet + run: | + echo "## Vet" >> "$GITHUB_STEP_SUMMARY" + go vet ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Test + run: | + echo "## Test report" >> "$GITHUB_STEP_SUMMARY" + go test -race -coverprofile=coverage.txt -covermode=atomic ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Test coverage + run: | + echo "## Test coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + go tool cover -func=coverage.txt | tee -a "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> "$GITHUB_STEP_SUMMARY" + + - name: Build + run: | + echo "## Build" >> "$GITHUB_STEP_SUMMARY" + go build ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "Build completed successfully." >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..ac7b2b1 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,58 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Pull Request Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Pull Request:** ${{ github.event.pull_request.title }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Author:** ${{ github.event.pull_request.user.login }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** ${{ github.event.pull_request.head.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Base:** ${{ github.event.pull_request.base.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Commits:** ${{ github.event.pull_request.commits }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Changed Files:** ${{ github.event.pull_request.changed_files }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Additions:** ${{ github.event.pull_request.additions }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Deletions:** ${{ github.event.pull_request.deletions }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Test coverage + run: go tool cover -func=coverage.txt + + - name: Build + run: go build ./... \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..360fa3c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + actions: write + contents: write + id-token: write + packages: write + pull-requests: read + security-events: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Release Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Actor:** ${{ github.triggering_actor }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Commit ID:** ${{ github.sha }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Tag:** ${{ github.ref_name }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Test coverage + run: | + echo "## Test coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + go tool cover -func=coverage.txt | tee -a "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + - name: Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + make_latest: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4ab336 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.txt +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..e4af670 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,21 @@ +version: "2" +linters: + enable: + - errcheck + - ineffassign + - staticcheck + - unused + + settings: + errcheck: + check-type-assertions: false + check-blank: false + disable-default-exclusions: true + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - fmt.Print + - fmt.Printf + - fmt.Println + - fmt.Fprint + - fmt.Fprintf diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..11d909a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "cSpell.words": [ + "betteralign", + "Equatable", + "Errorf", + "gofmt", + "nolint", + "slashdevops" + ], + "chat.tools.terminal.autoApprove": { + "gofmt": true, + "go test": true, + "go vet": true, + "go build": true, + "actionlint": true + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 60fc0d2..8b162fc 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,81 @@ -# Comparator Package +# ๐Ÿ”ฌ comparator + +[![main branch](https://github.com/slashdevops/comparator/actions/workflows/main.yml/badge.svg)](https://github.com/slashdevops/comparator/actions/workflows/main.yml) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/comparator?style=plastic) +[![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/comparator.svg)](https://pkg.go.dev/github.com/slashdevops/comparator) +[![Go Report Card](https://goreportcard.com/badge/github.com/slashdevops/comparator)](https://goreportcard.com/report/github.com/slashdevops/comparator) +[![license](https://img.shields.io/github/license/slashdevops/comparator.svg)](https://github.com/slashdevops/comparator/blob/main/LICENSE) +[![Release](https://github.com/slashdevops/comparator/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/comparator/actions/workflows/release.yml) + +`comparator` is a small, dependency-free Go package for **deep comparison** and +**rich diffing** of arbitrary Go values โ€” primitives, structs, slices, maps, +pointers, and complex nested structures. It answers two questions: *are these two +values equal?* and *how exactly do they differ?* โ€” with output ready for +terminals, web UIs, APIs, and version control. + +Only the Go standard library is used. There are no third-party dependencies. + +## โœจ Features + +- ๐Ÿ” **Deep recursive comparison** with cycle detection +- ๐Ÿ” **Difference detection** โ€” detailed, path-addressed reports of what changed +- ๐ŸŽจ **Multiple output formats** โ€” text (with ANSI color), JSON, Markdown, HTML +- ๐Ÿ“ **Unified diff** โ€” Unix `diff`-style output +- ๐Ÿฉน **JSON Patch** โ€” RFC 6902 patch documents +- ๐ŸŒณ **Visual tree diff** โ€” hierarchical representation for viewers +- ๐Ÿง  **Custom comparators** โ€” register domain-specific equality per type +- ๐Ÿ“Š **Statistics & suggestions** โ€” metrics and actionable hints +- โš™๏ธ **Configurable behavior** โ€” float precision, slice-order insensitivity, + field ignoring, depth limits, NaN handling, and more +- ๐Ÿšซ **Zero third-party dependencies** โ€” standard library only +- ๐Ÿ“„ **Apache-2.0 licensed** + +## ๐Ÿ“ฆ Installation -[![Go Reference](https://pkg.go.dev/badge/github.com/aizon-shared/ds-utils/pkg/comparator.svg)](https://pkg.go.dev/github.com/aizon-shared/ds-utils/pkg/comparator) - -The `comparator` package provides advanced comparison and diffing capabilities for Go data structures. It offers a comprehensive solution for deep equality checking and difference detection across any Go values, including primitives, structs, slices, maps, pointers, and complex nested structures. - -## Features - -### Core Capabilities - -- **Deep Recursive Comparison** - Handles complex nested structures with cycle detection -- **Difference Detection** - Detailed reports of what differs between two values -- **Multiple Output Formats** - Text, JSON, Markdown, HTML, and Unified Diff -- **JSON Patch Generation** - RFC 6902 compliant patch documents -- **Visual Tree Diff** - Hierarchical representation of differences -- **Custom Comparators** - Register custom comparison logic for specific types -- **Statistics & Suggestions** - Detailed metrics and actionable recommendations - -### Configuration Options +```bash +go get github.com/slashdevops/comparator +``` -- **Float Precision** - Configurable tolerance for floating-point comparisons -- **Slice Order** - Option to ignore element ordering (treat as sets) -- **Field Ignoring** - Skip specific struct fields by name -- **Depth Limiting** - Prevent stack overflow with deeply nested structures -- **Unexported Fields** - Option to include/exclude private fields -- **Empty Values** - Treat nil and empty as equal -- **NaN Handling** - Option to consider NaN values as equal -- **Colorized Output** - ANSI color-coded terminal output for better readability -- **Include Equal Values** - Show both differences and matches in reports +### ๐Ÿ”„ Update -## Installation +Update to the latest available version: ```bash -go get github.com/aizon-shared/ds-utils/pkg/comparator +go get -u github.com/slashdevops/comparator ``` -## Update +## ๐Ÿงฐ Requirements -```bash -go get -u github.com/aizon-shared/ds-utils/pkg/comparator -``` +- Go **1.26** or newer +- No external Go modules -## Quick Start +## ๐Ÿš€ Quick Start -### Simple Equality Check +### Simple equality check ```go -import "github.com/aizon-shared/ds-utils/pkg/comparator" +package main -comp := comparator.New() -if comp.Equal(obj1, obj2) { - fmt.Println("Objects are equal") -} +import ( + "fmt" + + "github.com/slashdevops/comparator" +) + +func main() { + comp := comparator.New() + if comp.Equal(obj1, obj2) { + fmt.Println("objects are equal") + } -// Or use the convenience function -if comparator.Equal(obj1, obj2) { - fmt.Println("Objects are equal") + // Or use the package-level convenience function: + if comparator.Equal(obj1, obj2) { + fmt.Println("objects are equal") + } } ``` -### Comparison with Options +### Comparison with options ```go comp := comparator.NewWithOptions( @@ -68,11 +85,11 @@ comp := comparator.NewWithOptions( ) if comp.Equal(user1, user2) { - fmt.Println("Users are equal (ignoring ID and timestamps)") + fmt.Println("users are equal (ignoring ID and timestamps)") } ``` -### Detailed Difference Analysis +### Detailed difference analysis ```go diffComp := comparator.NewDiffComparer( @@ -82,102 +99,16 @@ diffComp := comparator.NewDiffComparer( result := diffComp.CompareWithDiff(expected, actual) if !result.Equal { - fmt.Printf("Found %d differences\n", len(result.Differences)) + fmt.Printf("found %d differences\n", len(result.Differences)) fmt.Println(result.Summary) for _, diff := range result.Differences { fmt.Printf("[%s] %s: %s\n", diff.Severity, diff.Path, diff.Message) - for _, suggestion := range diff.Suggestions { - fmt.Printf(" โ†’ %s\n", suggestion) - } } } ``` -## Advanced Features - -### Colorized Terminal Output - -Enable ANSI color-coded output for better readability in terminals: - -```go -comp := comparator.NewDiffComparer( - comparator.WithColorize(true), - comparator.WithOutputFormat("text"), -) - -result := comp.CompareWithDiff(config1, config2) -formatted, _ := comp.FormatDiff(result, "text") -fmt.Println(formatted) // Displays with colors - -// Colors used: -// - Cyan: Paths and section headers -// - Green: Expected values and equal fields -// - Red: Actual values and errors -// - Yellow: Warnings -// - Bold: Section headers -``` - -**Use Cases:** - -- Terminal-based tools and CLI applications -- Development and debugging sessions -- Interactive diff viewers -- CI/CD pipeline outputs - -### Include Equal Values - -Show both differences and matches in comparison reports: - -```go -comp := comparator.NewDiffComparer( - comparator.WithIncludeEqual(true), -) - -result := comp.CompareWithDiff(user1, user2) - -// result.Differences now contains both: -// - Differences (severity: "error") -// - Equal values (severity: "info", type: "equal") - -for _, diff := range result.Differences { - if diff.Detail.Type == "equal" { - fmt.Printf("โœ“ %s: values match\n", diff.Path) - } else { - fmt.Printf("โœ— %s: %s\n", diff.Path, diff.Message) - } -} -``` - -**Use Cases:** - -- Comprehensive audit trails -- Configuration validation reports -- Debugging comparison logic -- Understanding what hasn't changed between versions - -### Combined Features - -```go -comp := comparator.NewDiffComparer( - comparator.WithColorize(true), - comparator.WithIncludeEqual(true), - comparator.WithOutputFormat("text"), -) - -result := comp.CompareWithDiff(oldConfig, newConfig) -formatted, _ := comp.FormatDiff(result, "text") - -// Output shows: -// - Equal fields in green with "info" severity -// - Different fields in red with "error" severity -// - All paths highlighted in cyan -fmt.Println(formatted) -``` - -### JSON Patch Generation - -Generate RFC 6902 JSON Patch documents: +### JSON Patch (RFC 6902) ```go patch, err := comparator.GetJSONPatch(oldDoc, newDoc) @@ -185,200 +116,113 @@ if err != nil { log.Fatal(err) } -patchJSON, _ := json.MarshalIndent(patch, "", " ") -fmt.Println(string(patchJSON)) -// Output: -// [ -// {"op": "replace", "path": "/name", "value": "John"}, -// {"op": "add", "path": "/email", "value": "john@example.com"} -// ] +out, _ := json.MarshalIndent(patch, "", " ") +fmt.Println(string(out)) ``` -Supported operations: - -- `add` - Add a new value at a path -- `remove` - Remove the value at a path -- `replace` - Replace the value at a path -- `move` - Move a value from one path to another -- `copy` - Copy a value from one path to another -- `test` - Test that a value at a path equals a specified value - -### Unified Diff Format - -Generate Unix diff-style output: - -```go -diffComp := comparator.NewDiffComparer() -unifiedDiff, err := diffComp.GetUnifiedDiff(oldConfig, newConfig) -if err != nil { - log.Fatal(err) -} +## ๐Ÿ“– Documentation + +Full documentation lives in the [`docs/`](docs/) folder, and the API reference is +on [pkg.go.dev](https://pkg.go.dev/github.com/slashdevops/comparator). + +| Guide | What it covers | +| ----- | -------------- | +| ๐Ÿš€ [Getting Started](docs/getting-started.md) | Installation, first comparison, core types. | +| โš™๏ธ [Configuration & Options](docs/configuration.md) | Every option, its default, and when to use it. | +| ๐Ÿ” [Diffing](docs/diffing.md) | `DiffResult`, `Difference`, diff modes. | +| ๐ŸŽจ [Output Formats](docs/output-formats.md) | Text/color, JSON, Markdown, HTML, unified, visual. | +| ๐Ÿฉน [JSON Patch](docs/json-patch.md) | Generating RFC 6902 patch documents. | +| ๐Ÿง  [Custom Comparators](docs/custom-comparators.md) | Domain-specific equality logic. | +| โšก [Performance](docs/performance.md) | Cost model, benchmarks, tuning. | +| โ“ [FAQ](docs/faq.md) | Gotchas, thread-safety, common questions. | + +## ๐ŸŽ›๏ธ Configuration Options + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `WithFloatPrecision(float64)` | `1e-9` | Tolerance for float equality. | +| `IgnoreSliceOrder()` | `false` | Treat slices as sets (ignore order). | +| `WithMaxDepth(int)` | `0` (unlimited) | Limit recursion depth. | +| `IgnoreUnexported()` | `false` | Skip unexported struct fields. | +| `EquateEmpty()` | `true` | Treat nil and empty containers as equal. | +| `EquateNaNs()` | `false` | Treat NaN values as equal. | +| `IgnoreStructFields(...string)` | none | Skip specific struct fields by name. | +| `WithTimeLayout(string)` | `time.RFC3339Nano` | Layout for rendering `time.Time`. | +| `WithCustomComparator[T](func(T, T) bool)` | none | Register custom equality for a type. | +| `WithDiffMode(DiffMode)` | `DiffModeSimple` | Diff detail level. | +| `WithMaxDiffs(int)` | `1000` | Cap the number of differences collected. | +| `WithOutputFormat(string)` | `"text"` | `text`, `json`, `markdown`, or `html`. | +| `WithColorize(bool)` | `false` | ANSI colors in text output. | +| `WithIncludeEqual(bool)` | `false` | Include equal values in reports. | + +See [Configuration & Options](docs/configuration.md) for details. + +## ๐Ÿงต Thread Safety + +Comparator instances are **not** thread-safe. Create separate instances for +concurrent use, or synchronize access with a mutex. The package-level helpers +build a fresh instance per call and are safe to call concurrently. + +## ๐Ÿงช Testing + +Run the test suite: -fmt.Println(unifiedDiff.Header) -for _, chunk := range unifiedDiff.Chunks { - fmt.Println(chunk.Context) - for _, change := range chunk.Changes { - symbol := " " - if change.Type == "add" { - symbol = "+" - } else if change.Type == "remove" { - symbol = "-" - } - fmt.Printf("%s%s\n", symbol, change.Content) - } -} +```bash +go test ./... ``` -### Visual Tree Diff - -Generate hierarchical tree representation: - -```go -comp := comparator.NewDiffComparer() -visualDiff, err := comp.GetVisualDiff(obj1, obj2) -if err != nil { - log.Fatal(err) -} +Run the runnable documentation examples: -printNode(visualDiff.Root, 0) - -func printNode(node *comparator.VisualNode, indent int) { - prefix := strings.Repeat(" ", indent) - symbol := "" - switch node.Status { - case "added": symbol = "+ " - case "removed": symbol = "- " - case "different": symbol = "~ " - default: symbol = " " - } - fmt.Printf("%s%s%s: %s\n", prefix, symbol, node.Path, node.Value) - for _, child := range node.Children { - printNode(child, indent+1) - } -} +```bash +go test -run Example ./... ``` -### Custom Comparators +Run benchmarks: -Register custom comparison logic for specific types: - -```go -type User struct { - ID int - Name string - Age int -} - -comp := comparator.NewWithOptions( - comparator.WithCustomComparator(func(a, b User) bool { - return a.ID == b.ID // Compare users by ID only - }), -) - -if comp.Equal(user1, user2) { - fmt.Println("Users have the same ID") -} +```bash +go test -run '^$' -bench . ./... ``` -## Configuration Options Reference - -### Comparison Behavior - -- `WithFloatPrecision(float64)`: Set precision threshold for float comparisons. Default: `1e-9`. -- `IgnoreSliceOrder()`: Treat slices as sets and ignore element order. Default: `false`. -- `WithMaxDepth(int)`: Limit recursion depth where `0` means unlimited. Default: `0`. -- `IgnoreUnexported()`: Skip unexported struct fields. Default: `false`. -- `EquateEmpty()`: Treat nil and empty values as equal. Default: `true`. -- `EquateNaNs()`: Treat all NaN values as equal. Default: `false`. -- `IgnoreStructFields(...string)`: Skip specific struct fields by name. Default: none. - -### Diff Output - -- `WithDiffMode(DiffMode)`: Set diff reporting mode. Default: `DiffModeSimple`. -- `WithMaxDiffs(int)`: Limit the number of differences collected. Default: `1000`. -- `WithOutputFormat(string)`: Set output format to `text`, `json`, `markdown`, or `html`. Default: `"text"`. -- `WithColorize(bool)`: Enable ANSI color codes in text output. Default: `false`. -- `WithIncludeEqual(bool)`: Include equal values in diff reports. Default: `false`. - -### Other - -- `WithTimeLayout(string)`: Set the time format used for display. Default: `time.RFC3339Nano`. -- `WithCustomComparator[T](func(T, T) bool)`: Register a custom comparator for type `T`. Default: none. - -## Diff Modes - -- **`DiffModeSimple`** - Basic difference reporting -- **`DiffModeFull`** - Comprehensive details with nested differences -- **`DiffModeUnified`** - Unix diff-style output -- **`DiffModeJSONPatch`** - RFC 6902 JSON Patch operations -- **`DiffModeVisual`** - Tree-based visual representation - -## Output Formats - -- **`text`** - Plain text with optional ANSI colors -- **`json`** - JSON representation of diff result -- **`markdown`** - Markdown-formatted report -- **`html`** - HTML with styling - -## Performance Notes - -The package already includes benchmarks for primitive values, structs, deep nesting, large slices, large maps, and diff generation. - -- plain equality checks are cheaper than full diff generation -- large slices are among the more expensive cases, especially when additional diff detail is required -- reusing comparator instances is preferable when the same configuration is used repeatedly -- options such as `IgnoreSliceOrder`, `WithIncludeEqual`, and rich output formatting can materially increase work and output size - -You can rerun the package benchmarks with: +Run the same local quality checks used by CI: ```bash -go test ./pkg/comparator -run '^$' -bench . +go fmt ./... +go vet ./... +go test -race -coverprofile=/tmp/comparator-coverage.txt -covermode=atomic ./... +go build ./... ``` -## Supported Types - -The comparator handles all Go types: - -- **Primitives**: `bool`, `int*`, `uint*`, `float*`, `complex*`, `string` -- **Composite**: arrays, slices, maps, structs -- **Reference**: pointers, interfaces, channels, functions -- **Special**: `time.Time` (uses native Equal method) - -## Performance Considerations - -1. **Reuse Comparator Instances** - For repeated comparisons with the same configuration -2. **Limit Depth** - Use `WithMaxDepth` for deeply nested structures -3. **Limit Diffs** - Use `WithMaxDiffs` to control memory usage -4. **Skip Expensive Fields** - Use `IgnoreStructFields` for expensive comparisons -5. **Avoid IgnoreSliceOrder** - For large slices when possible (use pre-sorted slices) - -## Thread Safety - -Comparator instances are **not thread-safe**. Create separate instances for concurrent use, or synchronize access with a mutex. - -## Error Handling - -Most methods return errors for future compatibility. Current implementations rarely return errors, but always check error returns for forward compatibility. - -## Examples - -See [comparator_examples_test.go](comparator_examples_test.go) for comprehensive examples including: +## ๐Ÿ—‚๏ธ Project Structure + +```text +. +|-- .github/ GitHub Actions, CodeQL, Dependabot, release metadata +|-- .golangci.yaml Optional local golangci-lint configuration +|-- docs/ Extensive usage documentation +|-- doc.go Package documentation rendered by pkg.go.dev +|-- comparator.go Public comparison and diffing API +|-- comparator_test.go Unit tests and benchmarks +|-- comparator_examples_test.go Executable examples +|-- comparator_api_examples_test.go Executable examples +|-- comparator_options_examples_test.go Executable examples +|-- go.mod Module definition with no external requirements +|-- LICENSE Apache License 2.0 +|-- README.md Project overview and usage guide +`-- SECURITY.md Vulnerability reporting policy +``` -- String list comparisons -- JSON structure diffing -- Configuration validation -- Colorized output -- Including equal values -- Performance testing with large structures +## ๐Ÿ” Security -## License +Security scanning is handled by GitHub CodeQL. See [SECURITY.md](SECURITY.md) for +the vulnerability reporting policy. -This package is part of the ds-utils project. +## ๐Ÿ“„ License -## Contributing +`comparator` is licensed under the [Apache License 2.0](LICENSE). -Contributions are welcome! Please ensure all tests pass and add appropriate test coverage for new features. +## ๐Ÿค Contributing -```bash -make test -``` +Issues and pull requests are welcome at +[github.com/slashdevops/comparator](https://github.com/slashdevops/comparator). +Please keep changes small, idiomatic, tested, documented, and dependency-free +unless there is a clear reason to expand the project scope. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..471cf79 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +This project uses GitHub CodeQL to scan for security vulnerabilities. + +[![CodeQL Advanced](https://github.com/slashdevops/comparator/actions/workflows/codeql.yml/badge.svg)](https://github.com/slashdevops/comparator/actions/workflows/codeql.yml) + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| 0.0.x | Yes | + +## Reporting a Vulnerability + +Please report vulnerabilities through GitHub issues or the repository security advisory flow when available. Avoid posting sensitive exploit details publicly before maintainers have had time to respond. diff --git a/comparator_options_examples_test.go b/comparator_options_examples_test.go new file mode 100644 index 0000000..334d0b5 --- /dev/null +++ b/comparator_options_examples_test.go @@ -0,0 +1,231 @@ +package comparator + +import ( + "fmt" + "math" + "time" +) + +// ExampleEqual demonstrates the package-level Equal convenience function for a +// quick deep comparison without constructing a Comparator. +func ExampleEqual() { + a := map[string]int{"cpu": 2, "mem": 8} + b := map[string]int{"cpu": 2, "mem": 8} + + fmt.Println(Equal(a, b)) + fmt.Println(Equal(a, map[string]int{"cpu": 4, "mem": 8})) + // Output: + // true + // false +} + +// ExampleDeepEqual demonstrates DeepEqual, which behaves like Equal but keeps a +// name that is familiar to users of reflect.DeepEqual. +func ExampleDeepEqual() { + type point struct{ X, Y int } + + fmt.Println(DeepEqual(point{1, 2}, point{1, 2})) + fmt.Println(DeepEqual(point{1, 2}, point{1, 3})) + // Output: + // true + // false +} + +// ExampleEqual_options shows how options can be passed to the package-level +// helpers to relax the comparison. +func ExampleEqual_options() { + left := []int{3, 1, 2} + right := []int{1, 2, 3} + + fmt.Println(Equal(left, right)) + fmt.Println(Equal(left, right, IgnoreSliceOrder())) + // Output: + // false + // true +} + +// ExampleWithFloatPrecision shows how to treat floating-point values that are +// close enough as equal. +func ExampleWithFloatPrecision() { + comp := NewWithOptions(WithFloatPrecision(1e-3)) + + fmt.Println(comp.Equal(0.1+0.2, 0.3)) + // Output: + // true +} + +// ExampleIgnoreStructFields skips volatile fields such as IDs and timestamps so +// that two otherwise-identical records compare as equal. +func ExampleIgnoreStructFields() { + type user struct { + ID int + Name string + UpdatedAt string + } + + comp := NewWithOptions(IgnoreStructFields("ID", "UpdatedAt")) + + u1 := user{ID: 1, Name: "Alice", UpdatedAt: "2026-01-01"} + u2 := user{ID: 2, Name: "Alice", UpdatedAt: "2026-07-23"} + + fmt.Println(comp.Equal(u1, u2)) + // Output: + // true +} + +// ExampleIgnoreUnexported ignores unexported struct fields during comparison. +func ExampleIgnoreUnexported() { + type credential struct { + Name string + token string // unexported + } + + comp := NewWithOptions(IgnoreUnexported()) + + c1 := credential{Name: "svc", token: "aaa"} + c2 := credential{Name: "svc", token: "bbb"} + + fmt.Println(comp.Equal(c1, c2)) + // Output: + // true +} + +// ExampleEquateEmpty treats nil and empty containers as equal. +func ExampleEquateEmpty() { + comp := NewWithOptions(EquateEmpty()) + + var nilSlice []string + emptySlice := []string{} + + fmt.Println(comp.Equal(nilSlice, emptySlice)) + // Output: + // true +} + +// ExampleEquateNaNs treats NaN values as equal, which the IEEE-754 standard does +// not do by default. +func ExampleEquateNaNs() { + comp := NewWithOptions(EquateNaNs()) + + nan := math.NaN() + fmt.Println(comp.Equal(nan, nan)) + // Output: + // true +} + +// ExampleWithCustomComparator registers domain-specific comparison logic for a +// concrete type. +func ExampleWithCustomComparator() { + type account struct { + ID int + Name string + } + + // Two accounts are considered equal when they share the same ID. + comp := NewWithOptions(WithCustomComparator(func(a, b account) bool { + return a.ID == b.ID + })) + + a1 := account{ID: 7, Name: "prod"} + a2 := account{ID: 7, Name: "production"} + + fmt.Println(comp.Equal(a1, a2)) + // Output: + // true +} + +// ExampleWithTimeLayout controls how time.Time values are rendered in diff +// output, while equality still uses the native time comparison. +func ExampleWithTimeLayout() { + comp := NewWithOptions(WithTimeLayout(time.RFC3339)) + + t1 := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) + + fmt.Println(comp.Equal(t1, t2)) + // Output: + // true +} + +// ExampleGetJSONPatch generates an RFC 6902 JSON Patch describing how to turn +// the first document into the second. +func ExampleGetJSONPatch() { + type profile struct { + Name string `json:"name"` + } + + patch, err := GetJSONPatch(profile{Name: "Jon"}, profile{Name: "John"}) + if err != nil { + fmt.Println("error:", err) + return + } + + fmt.Println(len(patch)) + fmt.Println(patch[0].Op) + fmt.Println(patch[0].Path) + // Output: + // 1 + // replace + // /Name +} + +// ExampleDiffComparer_GetUnifiedDiff produces a Unix diff-style report. +func ExampleDiffComparer_GetUnifiedDiff() { + type release struct { + Version string + } + + comp := NewDiffComparer() + diff, err := comp.GetUnifiedDiff(release{Version: "1.0.0"}, release{Version: "1.1.0"}) + if err != nil { + fmt.Println("error:", err) + return + } + + fmt.Println(len(diff.Chunks) > 0) + // Output: + // true +} + +// ExampleDiffComparer_GetVisualDiff builds a hierarchical tree of the +// differences between two values. +func ExampleDiffComparer_GetVisualDiff() { + type server struct { + Host string + Port int + } + + comp := NewDiffComparer() + visual, err := comp.GetVisualDiff( + server{Host: "localhost", Port: 8080}, + server{Host: "localhost", Port: 9090}, + ) + if err != nil { + fmt.Println("error:", err) + return + } + + fmt.Println(visual.Root != nil) + // Output: + // true +} + +// ExampleDiffComparer_FormatDiff renders a diff result as Markdown. +func ExampleDiffComparer_FormatDiff() { + type plan struct { + Tier string + } + + comp := NewDiffComparer(WithOutputFormat("markdown")) + result := comp.CompareWithDiff(plan{Tier: "free"}, plan{Tier: "pro"}) + + formatted, err := comp.FormatDiff(result, "markdown") + if err != nil { + fmt.Println("error:", err) + return + } + + fmt.Println(len(formatted) > 0) + // Output: + // true +} diff --git a/comparator_test.go b/comparator_test.go index 611eeee..3b53f28 100644 --- a/comparator_test.go +++ b/comparator_test.go @@ -6,12 +6,8 @@ import ( "strings" "testing" "time" - - "github.com/aizon-shared/ds-utils/pkg/testutils" ) -var testCredGen = testutils.NewTestCredentialGenerator("comparator-tests") - // ==================== Basic Equality Tests ==================== func TestEqual_Primitives(t *testing.T) { @@ -363,8 +359,7 @@ func BenchmarkEqual_Primitives(b *testing.B) { a := 42 c := 42 - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { comp.Equal(a, c) } } @@ -379,8 +374,7 @@ func BenchmarkEqual_Structs(b *testing.B) { p1 := Person{Name: "Alice", Age: 30} p2 := Person{Name: "Alice", Age: 30} - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { comp.Equal(p1, p2) } } @@ -395,8 +389,7 @@ func BenchmarkCompareWithDiff(b *testing.B) { cfg1 := Config{Host: "localhost", Port: 8080} cfg2 := Config{Host: "localhost", Port: 9090} - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { comp.CompareWithDiff(cfg1, cfg2) } } @@ -622,8 +615,10 @@ func TestIgnoreUnexported(t *testing.T) { comp := NewWithOptions(IgnoreUnexported()) - u1 := User{Name: "Alice", password: testCredGen.Secret(1)} - u2 := User{Name: "Alice", password: testCredGen.Secret(2)} + // Distinct, clearly-marked fake values so this stays stdlib-only and does + // not trip secret scanning. + u1 := User{Name: "Alice", password: "test-secret-value-1"} + u2 := User{Name: "Alice", password: "test-secret-value-2"} if !comp.Equal(u1, u2) { t.Error("Expected structs to be equal when ignoring unexported fields") @@ -1169,8 +1164,7 @@ func BenchmarkEqual_DeepNested(b *testing.B) { l1 := Level1{L2: Level2{L3: Level3{L4: Level4{Value: 42}}}} l2 := Level1{L2: Level2{L3: Level3{L4: Level4{Value: 42}}}} - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { comp.Equal(l1, l2) } } @@ -1184,8 +1178,7 @@ func BenchmarkEqual_LargeSlice(b *testing.B) { s2[i] = i } - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { comp.Equal(s1, s2) } } @@ -2016,8 +2009,7 @@ func BenchmarkEqual_LargeMap(b *testing.B) { m2[key] = i } - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { comp.Equal(m1, m2) } } diff --git a/docs.go b/doc.go similarity index 100% rename from docs.go rename to doc.go diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..455dbaf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# ๐Ÿ“š Comparator Documentation + +Welcome to the full documentation for the `comparator` package โ€” a dependency-free +Go library for deep comparison and rich diffing of arbitrary Go values. + +Everything here is built on the Go standard library only. If you are just getting +started, read the guides in order; otherwise jump straight to the topic you need. + +## ๐Ÿ—‚๏ธ Table of Contents + +| Guide | What it covers | +| ----- | -------------- | +| [Getting Started](getting-started.md) | Installation, your first comparison, and the core types. | +| [Configuration & Options](configuration.md) | Every functional option, its default, and when to use it. | +| [Diffing](diffing.md) | `DiffResult`, `Difference`, diff modes, and how to read a diff. | +| [Output Formats](output-formats.md) | Text (with color), JSON, Markdown, HTML, unified diff, and visual tree. | +| [JSON Patch](json-patch.md) | Generating RFC 6902 patch documents. | +| [Custom Comparators](custom-comparators.md) | Registering domain-specific equality logic. | +| [Performance](performance.md) | Benchmarks, cost model, and tuning tips. | +| [FAQ](faq.md) | Common questions, gotchas, and thread-safety. | + +## โšก Quick Links + +- Package reference: [pkg.go.dev/github.com/slashdevops/comparator](https://pkg.go.dev/github.com/slashdevops/comparator) +- Runnable examples: [`comparator_examples_test.go`](../comparator_examples_test.go), + [`comparator_api_examples_test.go`](../comparator_api_examples_test.go), and + [`comparator_options_examples_test.go`](../comparator_options_examples_test.go) +- Source: [`comparator.go`](../comparator.go) + +## ๐Ÿงญ Mental Model + +The package exposes two layers: + +1. **Equality** โ€” the [`Comparator`](getting-started.md#the-comparator-interface) + interface answers a single question: *are these two values equal under this + configuration?* +2. **Diffing** โ€” the [`DiffComparer`](diffing.md) interface answers a richer + question: *how exactly do these two values differ, and how do I present that?* + +Both are configured with the same set of [functional options](configuration.md), +so you learn the options once and reuse them everywhere. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..12fff12 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,85 @@ +# โš™๏ธ Configuration & Options + +All behavior is configured through **functional options** of type +`comparator.Option`. The same options work with every constructor and every +package-level helper. + +```go +comp := comparator.NewWithOptions( + comparator.WithFloatPrecision(1e-6), + comparator.IgnoreSliceOrder(), + comparator.IgnoreStructFields("ID", "CreatedAt", "UpdatedAt"), +) +``` + +## ๐Ÿ—๏ธ Constructors + +| Constructor | Returns | Notes | +| ----------- | ------- | ----- | +| `New()` | `Comparator` | Default configuration. | +| `NewWithOptions(opts ...Option)` | `Comparator` | Equality with custom options. | +| `NewDiffComparer(opts ...Option)` | `DiffComparer` | Full diffing + formatting. | + +> โ™ป๏ธ **Reuse instances.** Constructing a comparator is cheap, but reusing one +> across many comparisons with the same configuration avoids repeated setup. + +## ๐Ÿ”ง Comparison Behavior + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `WithFloatPrecision(float64)` | `1e-9` | Two floats are equal when their absolute difference is `<=` this value. | +| `IgnoreSliceOrder()` | `false` | Treat slices/arrays as sets; element order is ignored. | +| `WithMaxDepth(int)` | `0` (unlimited) | Stop recursing beyond this depth; guards against very deep structures. | +| `IgnoreUnexported()` | `false` | Skip unexported struct fields entirely. | +| `EquateEmpty()` | `true` | Treat `nil` and empty containers (slice/map) as equal. | +| `EquateNaNs()` | `false` | Treat `NaN == NaN` as `true` (IEEE-754 says `false`). | +| `IgnoreStructFields(...string)` | none | Skip struct fields by name, at any nesting level. | +| `WithTimeLayout(string)` | `time.RFC3339Nano` | Layout used to render `time.Time` in diff output. Equality still uses `time.Time.Equal`. | + +> โ„น๏ธ `EquateEmpty` defaults to **true** in this package. Pass it explicitly if +> you want to document the intent; there is currently no dedicated option to turn +> it back off, so rely on strict typing when nil-vs-empty must differ. + +## ๐Ÿงพ Diff Output + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `WithDiffMode(DiffMode)` | `DiffModeSimple` | Level of detail in a diff report (see below). | +| `WithMaxDiffs(int)` | `1000` | Maximum number of differences collected before stopping. | +| `WithOutputFormat(string)` | `"text"` | `"text"`, `"json"`, `"markdown"`, or `"html"`. | +| `WithColorize(bool)` | `false` | ANSI color codes in `text` output. | +| `WithIncludeEqual(bool)` | `false` | Also report values that are equal (severity `info`). | + +### Diff modes + +| Mode | Meaning | +| ---- | ------- | +| `DiffModeSimple` | Basic difference reporting (default). | +| `DiffModeFull` | Comprehensive detail with nested differences and statistics. | +| `DiffModeUnified` | Unix `diff`-style output. | +| `DiffModeJSONPatch` | RFC 6902 JSON Patch operations. | +| `DiffModeVisual` | Tree-based visual representation. | + +## ๐Ÿงช Custom Logic + +| Option | Description | +| ------ | ----------- | +| `WithCustomComparator[T](func(a, b T) bool)` | Register domain-specific equality for a concrete type `T`. See [Custom Comparators](custom-comparators.md). | + +## ๐ŸŽ›๏ธ Putting It Together + +```go +comp := comparator.NewDiffComparer( + comparator.WithOutputFormat("markdown"), + comparator.WithMaxDiffs(100), + comparator.WithIncludeEqual(true), + comparator.IgnoreStructFields("ID", "UpdatedAt"), + comparator.WithFloatPrecision(1e-6), +) + +result := comp.CompareWithDiff(expected, actual) +report, _ := comp.FormatDiff(result, "markdown") +fmt.Println(report) +``` + +Continue with [Diffing](diffing.md) to learn how to read a `DiffResult`. diff --git a/docs/custom-comparators.md b/docs/custom-comparators.md new file mode 100644 index 0000000..eef9bbb --- /dev/null +++ b/docs/custom-comparators.md @@ -0,0 +1,66 @@ +# ๐Ÿง  Custom Comparators + +Sometimes structural equality is not what you want. Two records may be +"the same" when they share an identifier, even if other fields differ. Register +a **custom comparator** to encode that rule for a specific type. + +## ๐Ÿ”ง Registering + +`WithCustomComparator[T]` is a generic option. Provide a function that decides +equality for values of type `T`: + +```go +type Account struct { + ID int + Name string +} + +comp := comparator.NewWithOptions( + comparator.WithCustomComparator(func(a, b Account) bool { + return a.ID == b.ID // equal when IDs match + }), +) + +a1 := Account{ID: 7, Name: "prod"} +a2 := Account{ID: 7, Name: "production"} + +fmt.Println(comp.Equal(a1, a2)) // true +``` + +## ๐ŸŽฏ How It Works + +- The comparator is keyed by the concrete type `T`. Whenever two values of that + exact type are compared โ€” at the root or nested inside a larger structure โ€” + your function is used instead of the default structural comparison. +- Register comparators for as many distinct types as you need by passing multiple + `WithCustomComparator` options. + +## ๐Ÿ’ก Common Use Cases + +| Scenario | Rule | +| -------- | ---- | +| Entities with surrogate keys | Compare by `ID` only. | +| Values with derived/cached fields | Ignore the cache; compare source fields. | +| Domain-specific equivalence | e.g. case-insensitive names, normalized URLs. | +| Third-party types | Compare by the fields that actually matter. | + +## โš–๏ธ Custom Comparators vs. `IgnoreStructFields` + +Both let you loosen equality, but they differ in intent: + +- Use **`IgnoreStructFields`** when a few fields (IDs, timestamps) should simply + be skipped everywhere. +- Use **`WithCustomComparator`** when equality for a type is a genuine domain + decision that you want to express as code. + +## ๐Ÿงต A Note on Diffs + +Custom comparators answer *equal or not*. When you need a detailed diff and a +custom comparator reports two values as equal, they will not appear as a +difference. Combine custom comparators with `WithIncludeEqual(true)` if you want +those matches surfaced in reports. + +## โžก๏ธ Next Steps + +- Review all options in [Configuration & Options](configuration.md). +- Tune large comparisons in [Performance](performance.md). diff --git a/docs/diffing.md b/docs/diffing.md new file mode 100644 index 0000000..4ff13c3 --- /dev/null +++ b/docs/diffing.md @@ -0,0 +1,103 @@ +# ๐Ÿ” Diffing + +Equality tells you *whether* two values differ. Diffing tells you *how*. Use a +`DiffComparer` (from `NewDiffComparer`) or the package-level `CompareWithDiff`. + +```go +result := comparator.CompareWithDiff(expected, actual) +if !result.Equal { + fmt.Println(result.Summary) + for _, diff := range result.Differences { + fmt.Printf("[%s] %s: %s\n", diff.Severity, diff.Path, diff.Message) + } +} +``` + +## ๐Ÿงพ The `DiffResult` + +```go +type DiffResult struct { + Summary string // human-readable summary + Differences []Difference // all differences (empty when Equal) + PathStats PathStats // traversal statistics + Equal bool // deep-equality verdict +} +``` + +### `PathStats` + +Useful for understanding scope and cost of a comparison: + +| Field | Meaning | +| ----- | ------- | +| `TotalNodes` | Nodes encountered during traversal. | +| `ComparedNodes` | Nodes actually compared. | +| `DifferentNodes` | Nodes found different. | +| `IgnoredNodes` | Nodes skipped (e.g. via `IgnoreStructFields`). | + +## ๐Ÿงฉ The `Difference` + +```go +type Difference struct { + Path string // dot-notation location, e.g. "User.Address.City" + Message string // human-readable description + Severity string // "error", "warning", or "info" + Detail DifferenceDetail // granular, type-specific detail + Suggestions []string // optional remediation hints + Level int // depth in the structure (0 = root) +} +``` + +### `DifferenceDetail.Type` + +The `Detail.Type` field classifies what kind of difference was found: + +| Type | Meaning | +| ---- | ------- | +| `value_different` | Same type, different value. | +| `type_mismatch` | The two values have different types. | +| `missing` | Present on one side only. | +| `extra_element` | Extra slice/array element. | +| `length_mismatch` | Slices/arrays of different length. | +| `missing_key` / `extra_key` | Map key present on one side only. | +| `nil_mismatch` | One side is nil, the other is not. | +| `equal` | Emitted only with `WithIncludeEqual(true)`. | + +`Detail` also carries `ExpectedType`, `ActualType`, `ExpectedValue`, +`ActualValue`, and any nested `Children`. + +> ๐Ÿงญ **Convention:** the *first* argument is treated as **expected** and the +> *second* as **actual**. Keep the order consistent +> (`CompareWithDiff(expected, actual)`) so the report reads correctly. + +## โž• Including Equal Values + +By default only differences are reported. Enable `WithIncludeEqual(true)` to also +emit matched values (with `Severity: "info"` and `Detail.Type: "equal"`) โ€” handy +for audit trails and validation reports. + +```go +comp := comparator.NewDiffComparer(comparator.WithIncludeEqual(true)) +result := comp.CompareWithDiff(user1, user2) + +for _, d := range result.Differences { + if d.Detail.Type == "equal" { + fmt.Printf("โœ“ %s\n", d.Path) + } else { + fmt.Printf("โœ— %s: %s\n", d.Path, d.Message) + } +} +``` + +## ๐ŸŽš๏ธ Bounding Work + +- `WithMaxDiffs(n)` caps how many differences are collected (default `1000`). +- `WithMaxDepth(n)` caps recursion depth (default `0`, unlimited). + +Both are important safety valves for very large or deeply nested inputs โ€” see +[Performance](performance.md). + +## โžก๏ธ Next Steps + +- Render results in different formats: [Output Formats](output-formats.md). +- Produce machine-applicable patches: [JSON Patch](json-patch.md). diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..5aed1b9 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,72 @@ +# โ“ FAQ + +## Is it thread-safe? + +**No.** Comparator instances are **not** safe for concurrent use. Create a +separate instance per goroutine, or protect a shared instance with a mutex. The +package-level helpers (`Equal`, `CompareWithDiff`, โ€ฆ) build a fresh instance on +each call, so they are safe to call concurrently. + +## Does it have any dependencies? + +No. `comparator` depends only on the Go standard library. There is no `go.sum` +because there are no external modules. + +## What Go version do I need? + +Go **1.26** or newer. + +## How does it compare to `reflect.DeepEqual`? + +`reflect.DeepEqual` gives you a single boolean with no configuration and no +report. `comparator` adds configurable behavior (float precision, slice-order +insensitivity, field ignoring, custom comparators, NaN handling) and produces +detailed, presentable diffs. Use `reflect.DeepEqual` for the simplest checks and +`comparator` when you need control or a report. `DeepEqual` in this package is a +familiar-named alias for `Equal`. + +## How are `time.Time` values compared? + +With the native `time.Time.Equal` method, so two instants that represent the same +moment in different locations compare as equal. `WithTimeLayout` only affects how +times are *rendered* in diff output. + +## Why do JSON Patch paths use Go field names, not JSON tags? + +Paths are derived from the Go structure (e.g. field `Name` โ†’ `/Name`). If you +need JSON-tag names, marshal your inputs to `map[string]any` via `encoding/json` +before comparing, or rewrite the paths after generation. See +[JSON Patch](json-patch.md). + +## Are `nil` and empty slices/maps equal? + +By default, **yes** โ€” `EquateEmpty` defaults to `true`. If nil-vs-empty must be a +difference for your use case, model it with distinct types or values so the +comparison is unambiguous. + +## Is `NaN` equal to `NaN`? + +By default, **no**, matching IEEE-754. Enable `EquateNaNs()` to treat all NaN +values as equal. + +## Do the comparison methods return errors? + +Most methods return an `error` for forward compatibility, though current +implementations rarely produce one. Always check the error for future-proofing. + +## How do I limit work on huge inputs? + +Use `WithMaxDepth` to bound recursion and `WithMaxDiffs` to bound the number of +collected differences. See [Performance](performance.md). + +## Where are the runnable examples? + +- [`comparator_examples_test.go`](../comparator_examples_test.go) +- [`comparator_api_examples_test.go`](../comparator_api_examples_test.go) +- [`comparator_options_examples_test.go`](../comparator_options_examples_test.go) + +Run them with: + +```bash +go test -run Example ./... +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..3a762d5 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,102 @@ +# ๐Ÿš€ Getting Started + +## ๐Ÿ“ฆ Installation + +```bash +go get github.com/slashdevops/comparator +``` + +Update to the latest available version: + +```bash +go get -u github.com/slashdevops/comparator +``` + +`comparator` requires **Go 1.26 or newer** and has **zero third-party +dependencies** โ€” only the Go standard library. + +## ๐Ÿ‘‹ Your First Comparison + +```go +package main + +import ( + "fmt" + + "github.com/slashdevops/comparator" +) + +func main() { + a := map[string]int{"cpu": 2, "mem": 8} + b := map[string]int{"cpu": 2, "mem": 8} + + if comparator.Equal(a, b) { + fmt.Println("values are equal") + } +} +``` + +`comparator.Equal` is a package-level convenience function. It builds a +comparator, runs a single comparison, and returns a `bool`. For repeated +comparisons with the same configuration, create a comparator once (see below). + +## ๐Ÿงฑ Core Types + +### The `Comparator` interface + +Returned by [`New`](configuration.md) and +[`NewWithOptions`](configuration.md). Use it for equality checks: + +```go +comp := comparator.New() + +comp.Equal(a, b) // bool +comp.Diff(a, b) // ([]Difference, error) +comp.CompareWithDiff(a, b) // *DiffResult +``` + +### The `DiffComparer` interface + +Returned by [`NewDiffComparer`](diffing.md). It extends `Comparator` with the +richer diff and formatting operations: + +```go +comp := comparator.NewDiffComparer() + +comp.CompareWithDiff(a, b) // *DiffResult +comp.GetUnifiedDiff(a, b) // (*UnifiedDiff, error) +comp.GetJSONPatch(a, b) // ([]JSONPatchOperation, error) +comp.GetVisualDiff(a, b) // (*VisualDiff, error) +comp.FormatDiff(result, format) // (string, error) +``` + +## ๐Ÿงฉ Package-level Convenience Functions + +When you only need a single call, skip the constructor entirely: + +| Function | Returns | Use for | +| -------- | ------- | ------- | +| `Equal(a, b, opts...)` | `bool` | One-off equality check. | +| `DeepEqual(a, b, opts...)` | `bool` | Same as `Equal`; familiar name for `reflect.DeepEqual` users. | +| `CompareWithDiff(a, b, opts...)` | `*DiffResult` | One-off detailed diff. | +| `GetJSONPatch(a, b, opts...)` | `([]JSONPatchOperation, error)` | One-off RFC 6902 patch. | + +All of them accept the same [functional options](configuration.md) as the +constructors. + +## โœ… Supported Types + +The comparator handles every Go type: + +- **Primitives**: `bool`, `int*`, `uint*`, `float*`, `complex*`, `string` +- **Composite**: arrays, slices, maps, structs +- **Reference**: pointers, interfaces, channels, functions +- **Special**: `time.Time` (compared with its native `Equal` method) + +Recursive structures are handled safely via cycle detection. + +## โžก๏ธ Next Steps + +- Tune behavior with [Configuration & Options](configuration.md). +- Understand diff output in [Diffing](diffing.md). +- Present diffs with [Output Formats](output-formats.md). diff --git a/docs/json-patch.md b/docs/json-patch.md new file mode 100644 index 0000000..b14a3ff --- /dev/null +++ b/docs/json-patch.md @@ -0,0 +1,69 @@ +# ๐Ÿฉน JSON Patch (RFC 6902) + +`comparator` can express the difference between two values as an +[RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON Patch โ€” a list of +operations that transforms the first value into the second. This is ideal for +sending partial updates over the wire or persisting change sets. + +## ๐Ÿš€ Quick Start + +```go +patch, err := comparator.GetJSONPatch(oldDoc, newDoc) +if err != nil { + log.Fatal(err) +} + +out, _ := json.MarshalIndent(patch, "", " ") +fmt.Println(string(out)) +``` + +Example output: + +```json +[ + { "op": "replace", "path": "/name", "value": "John" }, + { "op": "add", "path": "/email", "value": "john@example.com" } +] +``` + +## ๐Ÿงฉ The `JSONPatchOperation` + +```go +type JSONPatchOperation struct { + Op string // "add", "remove", "replace", "move", "copy", "test" + Path string // JSON Pointer (RFC 6901) to the target + Value any // value for add/replace/test (omitted otherwise) + From string // source path for move/copy +} +``` + +| Operation | Purpose | +| --------- | ------- | +| `add` | Add a value at a path. | +| `remove` | Remove the value at a path. | +| `replace` | Replace the value at a path. | +| `move` | Move a value from `From` to `Path`. | +| `copy` | Copy a value from `From` to `Path`. | +| `test` | Assert the value at a path. | + +## ๐Ÿงญ Path Format + +Paths are [JSON Pointers](https://datatracker.ietf.org/doc/html/rfc6901). Struct +fields appear by their **Go field name** (e.g. a field `Name` produces `/Name`), +and nested paths are slash-separated (e.g. `/Server/Port`). + +> ๐Ÿ”Ž If you need the JSON-tag name instead of the Go field name in the pointer, +> transform the paths after generation, or marshal your inputs to +> `map[string]any` (via `encoding/json`) before comparing. + +## ๐Ÿ› ๏ธ Applying a Patch + +This package **generates** patches; it does not apply them. To apply a patch, +marshal it to JSON and use any RFC 6902-compliant applier, or interpret the +operations yourself. Because the type has standard JSON tags, it round-trips +cleanly through `encoding/json`. + +## โžก๏ธ Next Steps + +- Explore other renderings in [Output Formats](output-formats.md). +- Register domain equality in [Custom Comparators](custom-comparators.md). diff --git a/docs/output-formats.md b/docs/output-formats.md new file mode 100644 index 0000000..44545a0 --- /dev/null +++ b/docs/output-formats.md @@ -0,0 +1,124 @@ +# ๐ŸŽจ Output Formats + +A `DiffResult` can be rendered in several formats, and the package also offers +two specialized diff structures (unified and visual). Pick the one that matches +your surface โ€” terminal, web UI, API, or version control. + +## ๐Ÿ–จ๏ธ `FormatDiff` + +`FormatDiff` turns a `*DiffResult` into a string in one of four formats: + +```go +comp := comparator.NewDiffComparer(comparator.WithOutputFormat("markdown")) +result := comp.CompareWithDiff(expected, actual) + +report, err := comp.FormatDiff(result, "markdown") +if err != nil { + log.Fatal(err) +} +fmt.Println(report) +``` + +| Format | Best for | +| ------ | -------- | +| `"text"` | Terminals, logs, CLI tools (optionally colorized). | +| `"json"` | APIs, storage, further programmatic processing. | +| `"markdown"` | PRs, issues, documentation, chat. | +| `"html"` | Web dashboards and diff viewers. | + +You can set a default format with `WithOutputFormat(...)` and still override it +per call by passing the format argument to `FormatDiff`. + +## ๐ŸŒˆ Colorized Text + +Enable ANSI colors for terminal output: + +```go +comp := comparator.NewDiffComparer( + comparator.WithColorize(true), + comparator.WithOutputFormat("text"), +) +result := comp.CompareWithDiff(config1, config2) +out, _ := comp.FormatDiff(result, "text") +fmt.Println(out) +``` + +Color legend: + +- ๐ŸŸฆ **Cyan** โ€” paths and section headers +- ๐ŸŸฉ **Green** โ€” expected values and equal fields +- ๐ŸŸฅ **Red** โ€” actual values and errors +- ๐ŸŸจ **Yellow** โ€” warnings + +> ๐Ÿ’ก Disable color when writing to a file or a non-TTY (e.g. CI logs) to avoid +> escape sequences in stored output. + +## ๐Ÿ“ Unified Diff + +`GetUnifiedDiff` returns a Unix `diff`-style structure: + +```go +comp := comparator.NewDiffComparer() +ud, err := comp.GetUnifiedDiff(oldConfig, newConfig) +if err != nil { + log.Fatal(err) +} + +fmt.Println(ud.Header) +for _, chunk := range ud.Chunks { + fmt.Println(chunk.Context) + for _, ch := range chunk.Changes { + symbol := " " + switch ch.Type { + case "add": + symbol = "+" + case "remove": + symbol = "-" + } + fmt.Printf("%s%s\n", symbol, ch.Content) + } +} +``` + +Shape: + +- `UnifiedDiff{ Header string, Chunks []Chunk }` +- `Chunk{ Context string, Changes []Change }` +- `Change{ Type string /* add|remove|context */, Content string, Line int }` + +## ๐ŸŒณ Visual Tree + +`GetVisualDiff` returns a tree suitable for building graphical viewers: + +```go +comp := comparator.NewDiffComparer() +vd, err := comp.GetVisualDiff(obj1, obj2) +if err != nil { + log.Fatal(err) +} +printNode(vd.Root, 0) + +func printNode(n *comparator.VisualNode, indent int) { + prefix := strings.Repeat(" ", indent) + symbol := " " + switch n.Status { + case "added": + symbol = "+ " + case "removed": + symbol = "- " + case "different": + symbol = "~ " + } + fmt.Printf("%s%s%s: %s\n", prefix, symbol, n.Path, n.Value) + for _, child := range n.Children { + printNode(child, indent+1) + } +} +``` + +Each `VisualNode` has a `Path`, `Value`, `Status` (`same`, `different`, +`added`, `removed`), and `Children`. + +## โžก๏ธ Next Steps + +- Generate machine-applicable patches: [JSON Patch](json-patch.md). diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..ef23f37 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,61 @@ +# โšก Performance + +`comparator` uses reflection to walk arbitrary values. That flexibility has a +cost, but the cost is predictable and controllable. This guide explains the cost +model and how to tune it. + +## ๐Ÿงฎ Cost Model + +Work scales with the **number of nodes** traversed: + +- **Plain equality** (`Equal`) is the cheapest path โ€” it stops at the first + difference and collects no report. +- **Diff generation** (`CompareWithDiff`, `GetUnifiedDiff`, `GetJSONPatch`, + `GetVisualDiff`) is more expensive because it visits nodes to build a report. +- **Large slices and maps** dominate cost, especially with diff detail enabled. +- **`IgnoreSliceOrder`** turns slice comparison into set-style matching, which is + materially more expensive than positional comparison โ€” avoid it on large, + already-ordered slices. +- **Rich output** (`WithIncludeEqual`, colorized/markdown/html formatting) + increases both work and output size. + +## ๐ŸŽ›๏ธ Tuning Levers + +| Lever | Effect | +| ----- | ------ | +| Reuse a comparator instance | Avoids repeated setup for same-config comparisons. | +| `WithMaxDepth(n)` | Bounds recursion for deeply nested structures. | +| `WithMaxDiffs(n)` | Caps memory when many differences exist. | +| `IgnoreStructFields(...)` | Skips expensive or irrelevant fields entirely. | +| Prefer `Equal` over diffing | Use the cheap path when you only need a verdict. | +| Pre-sort instead of `IgnoreSliceOrder` | Positional comparison is much cheaper. | + +## ๐Ÿ Benchmarks + +The test suite includes benchmarks for primitives, structs, deep nesting, large +slices, large maps, and diff generation. Run them with: + +```bash +go test -run '^$' -bench . ./... +``` + +Add memory allocation stats: + +```bash +go test -run '^$' -bench . -benchmem ./... +``` + +Benchmarks use the Go 1.24+ `for b.Loop()` form, so timing excludes per-benchmark +setup automatically. + +## ๐Ÿงฉ Practical Guidance + +1. **Reuse comparators** for repeated comparisons with the same configuration. +2. **Limit depth** with `WithMaxDepth` for untrusted or unbounded input. +3. **Limit diffs** with `WithMaxDiffs` to keep reports (and memory) bounded. +4. **Skip expensive fields** with `IgnoreStructFields`. +5. **Avoid `IgnoreSliceOrder`** on large slices; sort once and compare positionally. + +## โžก๏ธ Next Steps + +- Common questions and gotchas: [FAQ](faq.md).