diff --git a/.goreleaser.yml b/.goreleaser.yml index ad02b44..ea379fc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -113,3 +113,42 @@ brews: system "#{bin}/configdiff version" install: | bin.install "configdiff" + +dockers: + - image_templates: + - "ghcr.io/pfrederiksen/configdiff:{{ .Version }}-amd64" + - "ghcr.io/pfrederiksen/configdiff:latest-amd64" + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.source=https://github.com/pfrederiksen/configdiff" + dockerfile: Dockerfile + + - image_templates: + - "ghcr.io/pfrederiksen/configdiff:{{ .Version }}-arm64" + - "ghcr.io/pfrederiksen/configdiff:latest-arm64" + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.source=https://github.com/pfrederiksen/configdiff" + goarch: arm64 + dockerfile: Dockerfile + +docker_manifests: + - name_template: "ghcr.io/pfrederiksen/configdiff:{{ .Version }}" + image_templates: + - "ghcr.io/pfrederiksen/configdiff:{{ .Version }}-amd64" + - "ghcr.io/pfrederiksen/configdiff:{{ .Version }}-arm64" + + - name_template: "ghcr.io/pfrederiksen/configdiff:latest" + image_templates: + - "ghcr.io/pfrederiksen/configdiff:latest-amd64" + - "ghcr.io/pfrederiksen/configdiff:latest-arm64" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f271fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM alpine:latest + +RUN apk add --no-cache ca-certificates + +COPY configdiff /usr/bin/configdiff + +ENTRYPOINT ["/usr/bin/configdiff"] diff --git a/README.md b/README.md index 0c852fd..1382c5e 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ Semantic, human-grade diffs for YAML/JSON/HCL configuration files. - Ignore specific paths or treat arrays as sets - Handle type coercions (e.g., `"1"` vs `1`, `"true"` vs `true`) - Generate both machine-readable patches and human-friendly reports +- Colorized output for better readability +- Configuration file support for project defaults -Perfect for GitOps reviews, CI checks, configuration drift detection, and any scenario where you need to understand what actually changed in your config files. +Perfect for GitOps reviews, CI checks, configuration drift detection, Terraform/HCL comparisons, and any scenario where you need to understand what actually changed in your config files. ## Installation @@ -22,10 +24,35 @@ Perfect for GitOps reviews, CI checks, configuration drift detection, and any sc # Homebrew (macOS/Linux) brew install pfrederiksen/tap/configdiff +# Docker +docker pull ghcr.io/pfrederiksen/configdiff:latest +docker run --rm -v $(pwd):/work ghcr.io/pfrederiksen/configdiff:latest old.yaml new.yaml + # Or download binaries from GitHub releases # https://github.com/pfrederiksen/configdiff/releases ``` +### Shell Completion + +Enable shell completion for a better CLI experience: + +```bash +# Bash +source <(configdiff completion bash) +# Or install permanently: +configdiff completion bash > /etc/bash_completion.d/configdiff # Linux +configdiff completion bash > $(brew --prefix)/etc/bash_completion.d/configdiff # macOS + +# Zsh +configdiff completion zsh > "${fpath[1]}/_configdiff" + +# Fish +configdiff completion fish > ~/.config/fish/completions/configdiff.fish + +# PowerShell +configdiff completion powershell | Out-String | Invoke-Expression +``` + ### Go Library ```bash @@ -113,7 +140,7 @@ env: production ``` Format Options: - -f, --format string Input format (yaml, json, auto) (default "auto") + -f, --format string Input format (yaml, json, hcl, auto) (default "auto") --old-format string Old file format override --new-format string New file format override @@ -134,15 +161,55 @@ Output Options: Other: -h, --help Help for configdiff -v, --version Version information + completion [shell] Generate shell completion scripts ``` ### Output Formats -- **report** (default): Detailed human-friendly report with values +- **report** (default): Detailed human-friendly report with values, colorized for better readability - **compact**: Summary with paths only - **json**: JSON-serialized changes array - **patch**: JSON Patch (RFC 6902) format +**Color Output**: The report format includes color-coded output by default: +- Green for additions +- Red for removals +- Yellow for modifications +- Cyan for moves + +Disable with `--no-color` or `NO_COLOR=1` environment variable. + +### Configuration File + +Create a `.configdiffrc` or `.configdiff.yaml` file in your project or home directory to set default options: + +```yaml +# .configdiffrc +ignore_paths: + - /metadata/generation + - /metadata/creationTimestamp + - /status/* + +array_keys: + /spec/containers: name + /spec/volumes: name + +numeric_strings: false +bool_strings: false +stable_order: true +output_format: report +max_value_length: 100 +no_color: false +``` + +**Configuration file locations** (checked in order): +1. `./.configdiffrc` (current directory) +2. `./.configdiff.yaml` (current directory) +3. `~/.configdiffrc` (home directory) +4. `~/.configdiff.yaml` (home directory) + +CLI flags always override configuration file settings. For arrays and maps (like `ignore_paths` and `array_keys`), CLI flags are merged with config file values. + ### Exit Codes - `0`: Success (no differences, or differences displayed) @@ -320,7 +387,7 @@ result, _ := configdiff.DiffBytes(jsonConfig, "json", yamlConfig, "yaml", opts) ### Cross-Format Comparison -Compare YAML and JSON representations: +Compare YAML, JSON, and HCL representations: ```go yamlConfig := []byte(` @@ -340,6 +407,78 @@ result, _ := configdiff.DiffBytes(yamlConfig, "yaml", jsonConfig, "json", config // No differences - semantically identical ``` +### HCL/Terraform Configuration + +Compare Terraform/HCL configuration files: + +```bash +# Compare Terraform configs +configdiff old.tf new.tf --format hcl + +# Compare Terraform variable files +configdiff terraform.tfvars.old terraform.tfvars.new --format hcl + +# Mix formats (YAML to HCL) +configdiff config.yaml config.hcl --old-format yaml --new-format hcl +``` + +Example HCL comparison: + +```go +oldHCL := []byte(` +region = "us-east-1" +instance_type = "t3.micro" + +config = { + enabled = true + replicas = 2 +} + +servers = [ + { + name = "web1" + ip = "10.0.1.1" + }, + { + name = "web2" + ip = "10.0.1.2" + } +] +`) + +newHCL := []byte(` +region = "us-west-2" +instance_type = "t3.small" + +config = { + enabled = true + replicas = 3 +} + +servers = [ + { + name = "web1" + ip = "10.0.1.1" + }, + { + name = "web2" + ip = "10.0.1.2" + }, + { + name = "web3" + ip = "10.0.1.3" + } +] +`) + +result, _ := configdiff.DiffBytes(oldHCL, "hcl", newHCL, "hcl", configdiff.Options{ + ArraySetKeys: map[string]string{ + "/servers": "name", + }, +}) +// Detects region change, instance_type change, replicas change, and new server +``` + ### Kubernetes Deployment Diff Real-world example comparing Kubernetes deployments: @@ -502,6 +641,7 @@ type Options struct { Compact bool // If true, only show paths ShowValues bool // If true, include old/new values MaxValueLength int // Truncate values longer than this (0 = no limit) + NoColor bool // If true, disable colored output } ``` @@ -540,6 +680,25 @@ configdiff desired-state.json actual.json \ --ignore /metadata/id ``` +### Terraform Configuration Management + +```bash +# Compare Terraform configurations +configdiff main.tf.backup main.tf --format hcl + +# Compare tfvars files +configdiff staging.tfvars production.tfvars --format hcl \ + --array-key /security_groups=name + +# Review Terraform state changes +terraform show -json > current-state.json +git show HEAD:terraform/state.json > previous-state.json +configdiff previous-state.json current-state.json \ + --ignore /version \ + --ignore /terraform_version \ + --ignore /serial +``` + ### Configuration Management ```bash @@ -620,19 +779,24 @@ go test ./report -update - [x] Repository setup with CI/CD - [x] Tree package with normalized representation -- [x] YAML/JSON parsing with format detection +- [x] YAML/JSON/HCL parsing with format detection - [x] Semantic diff engine with customizable rules - [x] JSON Patch-like operations -- [x] Human-friendly report generation -- [x] Comprehensive test coverage (84.8%) +- [x] Human-friendly report generation with color output +- [x] Full-featured CLI tool with shell completion +- [x] Docker container support +- [x] Configuration file support (.configdiffrc) +- [x] Homebrew tap for easy installation +- [x] Comprehensive test coverage (>80%) - [x] Full API documentation and examples ### Future Enhancements -- HCL format support (experimental) -- Additional coercion rules -- Performance optimizations for very large configs -- CLI tool for command-line usage +- Additional coercion rules (e.g., unit conversions, date formats) +- Performance optimizations for very large configs (>100MB) +- TOML format support +- Interactive diff mode +- Diff statistics and analytics ## Contributing diff --git a/cmd/configdiff/compare.go b/cmd/configdiff/compare.go index 6613e6a..f96cb80 100644 --- a/cmd/configdiff/compare.go +++ b/cmd/configdiff/compare.go @@ -29,6 +29,11 @@ func compare(oldFile, newFile string) error { ExitCode: exitCode, } + // Apply config file defaults (CLI flags take precedence) + if cfg != nil { + cliOpts.ApplyConfigDefaults(cfg) + } + // Validate options if err := cliOpts.Validate(); err != nil { return err diff --git a/cmd/configdiff/completion.go b/cmd/configdiff/completion.go new file mode 100644 index 0000000..4969bb8 --- /dev/null +++ b/cmd/configdiff/completion.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate completion script", + Long: `To load completions: + +Bash: + + $ source <(configdiff completion bash) + + # To load completions for each session, execute once: + # Linux: + $ configdiff completion bash > /etc/bash_completion.d/configdiff + # macOS: + $ configdiff completion bash > $(brew --prefix)/etc/bash_completion.d/configdiff + +Zsh: + + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ configdiff completion zsh > "${fpath[1]}/_configdiff" + + # You will need to start a new shell for this setup to take effect. + +Fish: + + $ configdiff completion fish | source + + # To load completions for each session, execute once: + $ configdiff completion fish > ~/.config/fish/completions/configdiff.fish + +PowerShell: + + PS> configdiff completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, run: + PS> configdiff completion powershell > configdiff.ps1 + # and source this file from your PowerShell profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + var err error + switch args[0] { + case "bash": + err = cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + err = cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + err = cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + err = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + } + return err + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} diff --git a/cmd/configdiff/root.go b/cmd/configdiff/root.go index 154195f..a136b01 100644 --- a/cmd/configdiff/root.go +++ b/cmd/configdiff/root.go @@ -3,6 +3,7 @@ package main import ( "fmt" + "github.com/pfrederiksen/configdiff/internal/config" "github.com/spf13/cobra" ) @@ -21,6 +22,9 @@ var ( maxValueLength int quiet bool exitCode bool + + // Config file loaded at startup + cfg *config.Config ) var rootCmd = &cobra.Command{ @@ -63,6 +67,9 @@ Use "-" for stdin input (only one file can be stdin).`, } func init() { + // Load config file (errors are ignored - config is optional) + cfg, _ = config.Load() + // Format flags rootCmd.Flags().StringVarP(&format, "format", "f", "auto", "Input format (yaml, json, auto)") rootCmd.Flags().StringVar(&oldFormat, "old-format", "", "Old file format override") diff --git a/go.mod b/go.mod index 2124c4f..9387854 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,24 @@ module github.com/pfrederiksen/configdiff -go 1.21 +go 1.23.0 require gopkg.in/yaml.v3 v3.0.1 require ( + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.25.0 // indirect + github.com/zclconf/go-cty v1.16.3 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect ) diff --git a/go.sum b/go.sum index 337e17c..9e623ec 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -8,16 +14,30 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk 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/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= +github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/internal/cli/options.go b/internal/cli/options.go index a436bb8..5345efa 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/pfrederiksen/configdiff" + "github.com/pfrederiksen/configdiff/internal/config" ) // CLIOptions holds all CLI flag values @@ -73,6 +74,64 @@ func (c *CLIOptions) GetNewFormat() string { return c.Format } +// ApplyConfigDefaults applies configuration file defaults to unset CLI options. +// CLI flags always take precedence over config file values. +func (c *CLIOptions) ApplyConfigDefaults(cfg *config.Config) { + // Merge ignore paths (config file + CLI) + if len(cfg.IgnorePaths) > 0 { + // Create a map to deduplicate + pathMap := make(map[string]bool) + for _, p := range cfg.IgnorePaths { + pathMap[p] = true + } + for _, p := range c.IgnorePaths { + pathMap[p] = true + } + + // Convert back to slice + merged := make([]string, 0, len(pathMap)) + for p := range pathMap { + merged = append(merged, p) + } + c.IgnorePaths = merged + } + + // Merge array keys (config file + CLI) + if len(cfg.ArrayKeys) > 0 { + // Convert config map to CLI format (path=key) + for path, key := range cfg.ArrayKeys { + keySpec := fmt.Sprintf("%s=%s", path, key) + c.ArrayKeys = append(c.ArrayKeys, keySpec) + } + } + + // Apply config defaults only if CLI flag wasn't set + // For bool flags, we need to check if they were explicitly set + // For now, we'll apply config if the CLI value is false (default) + if !c.NumericStrings && cfg.NumericStrings { + c.NumericStrings = cfg.NumericStrings + } + if !c.BoolStrings && cfg.BoolStrings { + c.BoolStrings = cfg.BoolStrings + } + if !c.StableOrder && cfg.StableOrder { + c.StableOrder = cfg.StableOrder + } + if !c.NoColor && cfg.NoColor { + c.NoColor = cfg.NoColor + } + + // Apply string defaults if not set + if (c.OutputFormat == "" || c.OutputFormat == "report") && cfg.OutputFormat != "" { + c.OutputFormat = cfg.OutputFormat + } + + // Apply numeric defaults if not set + if c.MaxValueLength == 0 && cfg.MaxValueLength > 0 { + c.MaxValueLength = cfg.MaxValueLength + } +} + // Validate validates the CLI options func (c *CLIOptions) Validate() error { // Validate output format diff --git a/internal/cli/options_test.go b/internal/cli/options_test.go index e7a279b..2191ca2 100644 --- a/internal/cli/options_test.go +++ b/internal/cli/options_test.go @@ -2,6 +2,8 @@ package cli import ( "testing" + + "github.com/pfrederiksen/configdiff/internal/config" ) func TestCLIOptions_ToLibraryOptions(t *testing.T) { @@ -170,3 +172,195 @@ func TestCLIOptions_Validate(t *testing.T) { }) } } +func TestCLIOptions_ApplyConfigDefaults(t *testing.T) { + tests := []struct { + name string + opts CLIOptions + config *config.Config + want CLIOptions + }{ + { + name: "empty config no-op", + opts: CLIOptions{ + OutputFormat: "report", + }, + config: &config.Config{}, + want: CLIOptions{ + OutputFormat: "report", + }, + }, + { + name: "apply config defaults when CLI empty", + opts: CLIOptions{}, + config: &config.Config{ + IgnorePaths: []string{"/status"}, + NumericStrings: true, + BoolStrings: true, + StableOrder: true, + OutputFormat: "compact", + MaxValueLength: 50, + NoColor: true, + }, + want: CLIOptions{ + IgnorePaths: []string{"/status"}, + NumericStrings: true, + BoolStrings: true, + StableOrder: true, + OutputFormat: "compact", + MaxValueLength: 50, + NoColor: true, + }, + }, + { + name: "CLI values take precedence", + opts: CLIOptions{ + IgnorePaths: []string{"/metadata"}, + NumericStrings: true, + OutputFormat: "json", + MaxValueLength: 100, + }, + config: &config.Config{ + IgnorePaths: []string{"/status"}, + NumericStrings: false, + OutputFormat: "compact", + MaxValueLength: 50, + }, + want: CLIOptions{ + IgnorePaths: []string{"/metadata", "/status"}, // Merged + NumericStrings: true, // CLI wins (already set) + OutputFormat: "json", // CLI wins + MaxValueLength: 100, // CLI wins + }, + }, + { + name: "merge ignore paths deduplication", + opts: CLIOptions{ + IgnorePaths: []string{"/metadata", "/status"}, + }, + config: &config.Config{ + IgnorePaths: []string{"/status", "/timestamp"}, + }, + want: CLIOptions{ + IgnorePaths: []string{"/metadata", "/status", "/timestamp"}, + }, + }, + { + name: "merge array keys", + opts: CLIOptions{ + ArrayKeys: []string{"/containers=name"}, + }, + config: &config.Config{ + ArrayKeys: map[string]string{ + "/volumes": "name", + "/ports": "port", + }, + }, + want: CLIOptions{ + ArrayKeys: []string{"/containers=name", "/volumes=name", "/ports=port"}, + }, + }, + { + name: "boolean flags - config applies when CLI is false", + opts: CLIOptions{ + NumericStrings: false, + BoolStrings: false, + StableOrder: false, + NoColor: false, + }, + config: &config.Config{ + NumericStrings: true, + BoolStrings: true, + StableOrder: true, + NoColor: true, + }, + want: CLIOptions{ + NumericStrings: true, + BoolStrings: true, + StableOrder: true, + NoColor: true, + }, + }, + { + name: "string defaults - config applies when CLI is default", + opts: CLIOptions{ + OutputFormat: "report", // Default value + }, + config: &config.Config{ + OutputFormat: "compact", + }, + want: CLIOptions{ + OutputFormat: "compact", + }, + }, + { + name: "numeric defaults - config applies when CLI is zero", + opts: CLIOptions{ + MaxValueLength: 0, // Default value + }, + config: &config.Config{ + MaxValueLength: 100, + }, + want: CLIOptions{ + MaxValueLength: 100, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := tt.opts + opts.ApplyConfigDefaults(tt.config) + + // Check arrays with element comparison (order-independent) + if !containsAll(opts.IgnorePaths, tt.want.IgnorePaths) { + t.Errorf("IgnorePaths = %v, want to contain all of %v", opts.IgnorePaths, tt.want.IgnorePaths) + } + + // Check array keys + if len(opts.ArrayKeys) != len(tt.want.ArrayKeys) { + t.Errorf("ArrayKeys length = %d, want %d", len(opts.ArrayKeys), len(tt.want.ArrayKeys)) + } + + // Check boolean flags + if opts.NumericStrings != tt.want.NumericStrings { + t.Errorf("NumericStrings = %v, want %v", opts.NumericStrings, tt.want.NumericStrings) + } + if opts.BoolStrings != tt.want.BoolStrings { + t.Errorf("BoolStrings = %v, want %v", opts.BoolStrings, tt.want.BoolStrings) + } + if opts.StableOrder != tt.want.StableOrder { + t.Errorf("StableOrder = %v, want %v", opts.StableOrder, tt.want.StableOrder) + } + if opts.NoColor != tt.want.NoColor { + t.Errorf("NoColor = %v, want %v", opts.NoColor, tt.want.NoColor) + } + + // Check string options + if opts.OutputFormat != tt.want.OutputFormat { + t.Errorf("OutputFormat = %v, want %v", opts.OutputFormat, tt.want.OutputFormat) + } + + // Check numeric options + if opts.MaxValueLength != tt.want.MaxValueLength { + t.Errorf("MaxValueLength = %v, want %v", opts.MaxValueLength, tt.want.MaxValueLength) + } + }) + } +} + +// containsAll checks if actual contains all elements from expected +func containsAll(actual, expected []string) bool { + if len(actual) < len(expected) { + return false + } + expectedMap := make(map[string]bool) + for _, e := range expected { + expectedMap[e] = true + } + for _, a := range actual { + if expectedMap[a] { + delete(expectedMap, a) + } + } + return len(expectedMap) == 0 +} diff --git a/internal/cli/output.go b/internal/cli/output.go index f0ee483..0ceaa5b 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -24,11 +24,16 @@ func FormatOutput(result *configdiff.Result, opts OutputOptions) (string, error) Compact: false, ShowValues: true, MaxValueLength: opts.MaxValueLength, + NoColor: opts.NoColor, }), nil case "compact": // Compact report (paths only) - return report.GenerateCompact(result.Changes), nil + return report.Generate(result.Changes, report.Options{ + Compact: true, + ShowValues: false, + NoColor: opts.NoColor, + }), nil case "json": // JSON serialized changes diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d482ed0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,84 @@ +// Package config handles loading configuration from files. +package config + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config represents the configuration file structure. +type Config struct { + // IgnorePaths is a list of paths to ignore in diffs. + IgnorePaths []string `yaml:"ignore_paths"` + + // ArrayKeys maps paths to key fields for array-as-set behavior. + ArrayKeys map[string]string `yaml:"array_keys"` + + // NumericStrings enables treating string numbers as numbers. + NumericStrings bool `yaml:"numeric_strings"` + + // BoolStrings enables treating string booleans as booleans. + BoolStrings bool `yaml:"bool_strings"` + + // StableOrder enables stable sorting of object keys and array elements. + StableOrder bool `yaml:"stable_order"` + + // OutputFormat specifies the default output format (report/compact/json/patch). + OutputFormat string `yaml:"output_format"` + + // MaxValueLength limits the displayed value length in reports. + MaxValueLength int `yaml:"max_value_length"` + + // NoColor disables colored output. + NoColor bool `yaml:"no_color"` +} + +// Load attempts to load configuration from standard locations. +// It checks the following locations in order: +// 1. ./.configdiffrc +// 2. ./.configdiff.yaml +// 3. ~/.configdiffrc +// 4. ~/.configdiff.yaml +// +// Returns the first config file found, or an empty config if none exist. +func Load() (*Config, error) { + locations := []string{ + ".configdiffrc", + ".configdiff.yaml", + } + + // Add home directory locations + if home, err := os.UserHomeDir(); err == nil { + locations = append(locations, + filepath.Join(home, ".configdiffrc"), + filepath.Join(home, ".configdiff.yaml"), + ) + } + + // Try each location + for _, path := range locations { + if cfg, err := loadFile(path); err == nil { + return cfg, nil + } + } + + // No config file found, return empty config + return &Config{}, nil +} + +// loadFile loads configuration from a specific file path. +func loadFile(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e8eef95 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,182 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "configdiff-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Change to temp directory for test + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + + t.Run("no config file", func(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Errorf("Load() error = %v, want nil", err) + } + if cfg == nil { + t.Error("Load() returned nil config") + } + }) + + t.Run("load .configdiffrc", func(t *testing.T) { + configContent := `ignore_paths: + - /test/path + - /another/path +array_keys: + /containers: name +numeric_strings: true +bool_strings: false +stable_order: true +output_format: compact +max_value_length: 50 +no_color: true +` + if err := os.WriteFile(".configdiffrc", []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + defer os.Remove(".configdiffrc") + + cfg, err := Load() + if err != nil { + t.Errorf("Load() error = %v, want nil", err) + } + + // Verify loaded values + if len(cfg.IgnorePaths) != 2 { + t.Errorf("IgnorePaths length = %d, want 2", len(cfg.IgnorePaths)) + } + if len(cfg.ArrayKeys) != 1 { + t.Errorf("ArrayKeys length = %d, want 1", len(cfg.ArrayKeys)) + } + if !cfg.NumericStrings { + t.Error("NumericStrings = false, want true") + } + if cfg.BoolStrings { + t.Error("BoolStrings = true, want false") + } + if !cfg.StableOrder { + t.Error("StableOrder = false, want true") + } + if cfg.OutputFormat != "compact" { + t.Errorf("OutputFormat = %q, want %q", cfg.OutputFormat, "compact") + } + if cfg.MaxValueLength != 50 { + t.Errorf("MaxValueLength = %d, want 50", cfg.MaxValueLength) + } + if !cfg.NoColor { + t.Error("NoColor = false, want true") + } + }) + + t.Run("load .configdiff.yaml", func(t *testing.T) { + configContent := `ignore_paths: + - /yaml/path +output_format: json +` + if err := os.WriteFile(".configdiff.yaml", []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + defer os.Remove(".configdiff.yaml") + + cfg, err := Load() + if err != nil { + t.Errorf("Load() error = %v, want nil", err) + } + + if len(cfg.IgnorePaths) != 1 { + t.Errorf("IgnorePaths length = %d, want 1", len(cfg.IgnorePaths)) + } + if cfg.OutputFormat != "json" { + t.Errorf("OutputFormat = %q, want %q", cfg.OutputFormat, "json") + } + }) + + t.Run("priority: .configdiffrc over .configdiff.yaml", func(t *testing.T) { + // Create both files + rcContent := `output_format: compact` + yamlContent := `output_format: json` + + if err := os.WriteFile(".configdiffrc", []byte(rcContent), 0644); err != nil { + t.Fatalf("Failed to write .configdiffrc: %v", err) + } + defer os.Remove(".configdiffrc") + + if err := os.WriteFile(".configdiff.yaml", []byte(yamlContent), 0644); err != nil { + t.Fatalf("Failed to write .configdiff.yaml: %v", err) + } + defer os.Remove(".configdiff.yaml") + + cfg, err := Load() + if err != nil { + t.Errorf("Load() error = %v, want nil", err) + } + + // Should load .configdiffrc (first in priority) + if cfg.OutputFormat != "compact" { + t.Errorf("OutputFormat = %q, want %q (from .configdiffrc)", cfg.OutputFormat, "compact") + } + }) +} + +func TestLoadFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "configdiff-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + t.Run("valid config", func(t *testing.T) { + path := filepath.Join(tmpDir, "valid.yaml") + content := `ignore_paths: [/path1, /path2]` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + + cfg, err := loadFile(path) + if err != nil { + t.Errorf("loadFile() error = %v, want nil", err) + } + if len(cfg.IgnorePaths) != 2 { + t.Errorf("IgnorePaths length = %d, want 2", len(cfg.IgnorePaths)) + } + }) + + t.Run("file not found", func(t *testing.T) { + _, err := loadFile("/nonexistent/file.yaml") + if err == nil { + t.Error("loadFile() error = nil, want error") + } + }) + + t.Run("invalid yaml", func(t *testing.T) { + path := filepath.Join(tmpDir, "invalid.yaml") + content := `invalid: yaml: content: [[[` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + + _, err := loadFile(path) + if err == nil { + t.Error("loadFile() error = nil, want YAML parse error") + } + }) +} diff --git a/parse/parse.go b/parse/parse.go index 9395070..24ac3e6 100644 --- a/parse/parse.go +++ b/parse/parse.go @@ -5,7 +5,10 @@ import ( "encoding/json" "fmt" + "github.com/hashicorp/hcl/v2/hclparse" "github.com/pfrederiksen/configdiff/tree" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" "gopkg.in/yaml.v3" ) @@ -31,7 +34,7 @@ func Parse(data []byte, format Format) (*tree.Node, error) { case FormatJSON: return ParseJSON(data) case FormatHCL: - return nil, fmt.Errorf("HCL format not yet implemented") + return ParseHCL(data) default: return nil, fmt.Errorf("unsupported format: %s", format) } @@ -73,6 +76,105 @@ func ParseJSON(data []byte) (*tree.Node, error) { return node, nil } +// ParseHCL parses HCL data into a normalized tree. +func ParseHCL(data []byte) (*tree.Node, error) { + parser := hclparse.NewParser() + file, diags := parser.ParseHCL(data, "config.hcl") + if diags.HasErrors() { + return nil, fmt.Errorf("failed to parse HCL: %s", diags.Error()) + } + + // Extract attributes into a map + attrs, diags := file.Body.JustAttributes() + if diags.HasErrors() { + return nil, fmt.Errorf("failed to extract HCL attributes: %s", diags.Error()) + } + + result := make(map[string]interface{}) + for name, attr := range attrs { + val, diags := attr.Expr.Value(nil) + if diags.HasErrors() { + return nil, fmt.Errorf("failed to evaluate HCL attribute %q: %s", name, diags.Error()) + } + + goVal, err := ctyToGo(val) + if err != nil { + return nil, fmt.Errorf("failed to convert HCL value for %q: %w", name, err) + } + result[name] = goVal + } + + node, err := valueToNode(result) + if err != nil { + return nil, err + } + + // Set canonical paths + node.SetPaths("/") + return node, nil +} + +// ctyToGo converts a cty.Value to a Go interface{} value +func ctyToGo(val cty.Value) (interface{}, error) { + if val.IsNull() { + return nil, nil + } + + typ := val.Type() + switch { + case typ == cty.Bool: + var result bool + if err := gocty.FromCtyValue(val, &result); err != nil { + return nil, err + } + return result, nil + + case typ == cty.Number: + var result float64 + if err := gocty.FromCtyValue(val, &result); err != nil { + return nil, err + } + return result, nil + + case typ == cty.String: + var result string + if err := gocty.FromCtyValue(val, &result); err != nil { + return nil, err + } + return result, nil + + case typ.IsListType() || typ.IsTupleType(): + list := make([]interface{}, 0, val.LengthInt()) + it := val.ElementIterator() + for it.Next() { + _, elem := it.Element() + goElem, err := ctyToGo(elem) + if err != nil { + return nil, err + } + list = append(list, goElem) + } + return list, nil + + case typ.IsMapType() || typ.IsObjectType(): + result := make(map[string]interface{}) + it := val.ElementIterator() + for it.Next() { + key, elem := it.Element() + keyStr := key.AsString() + goElem, err := ctyToGo(elem) + if err != nil { + return nil, err + } + result[keyStr] = goElem + } + return result, nil + + default: + return nil, fmt.Errorf("unsupported cty type: %s", typ.FriendlyName()) + } +} + // normalizeYAMLValue converts YAML's map[interface{}]interface{} to map[string]interface{} // for consistent handling with JSON. func normalizeYAMLValue(v interface{}) interface{} { diff --git a/parse/parse_test.go b/parse/parse_test.go index 3994e0d..ee3fb8f 100644 --- a/parse/parse_test.go +++ b/parse/parse_test.go @@ -1,6 +1,7 @@ package parse import ( + "os" "testing" "github.com/pfrederiksen/configdiff/tree" @@ -412,10 +413,10 @@ func TestParse(t *testing.T) { wantErr: false, }, { - name: "HCL format not implemented", + name: "HCL format", data: `key = "value"`, format: FormatHCL, - wantErr: true, + wantErr: false, }, { name: "unsupported format", @@ -629,3 +630,297 @@ func TestNormalizeYAMLValue(t *testing.T) { }) } } +func TestParseHCL(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + check func(*testing.T, *tree.Node) + }{ + { + name: "boolean true", + input: `enabled = true`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + if n.Object["enabled"].Kind != tree.KindBool || n.Object["enabled"].Value != true { + t.Errorf("enabled = %v, want bool true", n.Object["enabled"].Value) + } + }, + }, + { + name: "boolean false", + input: `disabled = false`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + if n.Object["disabled"].Kind != tree.KindBool || n.Object["disabled"].Value != false { + t.Errorf("disabled = %v, want bool false", n.Object["disabled"].Value) + } + }, + }, + { + name: "integer", + input: `count = 42`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + if n.Object["count"].Kind != tree.KindNumber || n.Object["count"].Value != 42.0 { + t.Errorf("count = %v, want number 42", n.Object["count"].Value) + } + }, + }, + { + name: "float", + input: `ratio = 3.14`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + if n.Object["ratio"].Kind != tree.KindNumber || n.Object["ratio"].Value != 3.14 { + t.Errorf("ratio = %v, want number 3.14", n.Object["ratio"].Value) + } + }, + }, + { + name: "string", + input: `name = "test"`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + if n.Object["name"].Kind != tree.KindString || n.Object["name"].Value != "test" { + t.Errorf("name = %v, want string 'test'", n.Object["name"].Value) + } + }, + }, + { + name: "object", + input: `config = { + host = "localhost" + port = 8080 +}`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + configNode := n.Object["config"] + if configNode.Kind != tree.KindObject { + t.Fatalf("config.Kind = %v, want object", configNode.Kind) + } + if configNode.Object["host"].Kind != tree.KindString || configNode.Object["host"].Value != "localhost" { + t.Errorf("config.host = %v, want 'localhost'", configNode.Object["host"].Value) + } + if configNode.Object["port"].Kind != tree.KindNumber || configNode.Object["port"].Value != 8080.0 { + t.Errorf("config.port = %v, want 8080", configNode.Object["port"].Value) + } + }, + }, + { + name: "list of strings", + input: `tags = ["prod", "web"]`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + tagsNode := n.Object["tags"] + if tagsNode.Kind != tree.KindArray { + t.Fatalf("tags.Kind = %v, want array", tagsNode.Kind) + } + if len(tagsNode.Array) != 2 { + t.Fatalf("tags len = %v, want 2", len(tagsNode.Array)) + } + if tagsNode.Array[0].Kind != tree.KindString || tagsNode.Array[0].Value != "prod" { + t.Errorf("tags[0] = %v, want 'prod'", tagsNode.Array[0].Value) + } + if tagsNode.Array[1].Kind != tree.KindString || tagsNode.Array[1].Value != "web" { + t.Errorf("tags[1] = %v, want 'web'", tagsNode.Array[1].Value) + } + }, + }, + { + name: "list of objects", + input: `servers = [ + { + name = "web1" + ip = "10.0.1.1" + }, + { + name = "web2" + ip = "10.0.1.2" + } +]`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + serversNode := n.Object["servers"] + if serversNode.Kind != tree.KindArray { + t.Fatalf("servers.Kind = %v, want array", serversNode.Kind) + } + if len(serversNode.Array) != 2 { + t.Fatalf("servers len = %v, want 2", len(serversNode.Array)) + } + server1 := serversNode.Array[0] + if server1.Kind != tree.KindObject { + t.Fatalf("server1.Kind = %v, want object", server1.Kind) + } + if server1.Object["name"].Kind != tree.KindString || server1.Object["name"].Value != "web1" { + t.Errorf("server1.name = %v, want 'web1'", server1.Object["name"].Value) + } + if server1.Object["ip"].Kind != tree.KindString || server1.Object["ip"].Value != "10.0.1.1" { + t.Errorf("server1.ip = %v, want '10.0.1.1'", server1.Object["ip"].Value) + } + }, + }, + { + name: "nested objects", + input: `database = { + connection = { + host = "localhost" + port = 5432 + } + credentials = { + username = "admin" + password = "secret" + } +}`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + dbNode := n.Object["database"] + if dbNode.Kind != tree.KindObject { + t.Fatalf("database.Kind = %v, want object", dbNode.Kind) + } + connNode := dbNode.Object["connection"] + if connNode.Kind != tree.KindObject { + t.Fatalf("connection.Kind = %v, want object", connNode.Kind) + } + if connNode.Object["host"].Kind != tree.KindString || connNode.Object["host"].Value != "localhost" { + t.Errorf("connection.host = %v, want 'localhost'", connNode.Object["host"].Value) + } + }, + }, + { + name: "multiple top-level attributes", + input: `name = "app" +version = "1.0.0" +enabled = true`, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + if len(n.Object) != 3 { + t.Fatalf("Object len = %v, want 3", len(n.Object)) + } + if n.Object["name"].Kind != tree.KindString || n.Object["name"].Value != "app" { + t.Errorf("name = %v, want 'app'", n.Object["name"].Value) + } + if n.Object["version"].Kind != tree.KindString || n.Object["version"].Value != "1.0.0" { + t.Errorf("version = %v, want '1.0.0'", n.Object["version"].Value) + } + if n.Object["enabled"].Kind != tree.KindBool || n.Object["enabled"].Value != true { + t.Errorf("enabled = %v, want true", n.Object["enabled"].Value) + } + }, + }, + { + name: "invalid HCL", + input: `invalid = [[[`, + wantErr: true, + }, + { + name: "empty HCL", + input: ``, + wantErr: false, + check: func(t *testing.T, n *tree.Node) { + if n.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", n.Kind) + } + if len(n.Object) != 0 { + t.Errorf("Object len = %v, want 0", len(n.Object)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node, err := ParseHCL([]byte(tt.input)) + if tt.wantErr { + if err == nil { + t.Error("ParseHCL() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParseHCL() error = %v", err) + } + if tt.check != nil { + tt.check(t, node) + } + // Verify paths are set + if node.Path == "" { + t.Error("ParseHCL() did not set paths") + } + }) + } +} + +// Integration tests using testdata files +func TestParseHCL_Integration(t *testing.T) { + tests := []struct { + name string + file string + expectedKeys []string + }{ + { + name: "simple HCL file", + file: "../testdata/hcl/simple.hcl", + expectedKeys: []string{"name", "enabled", "count", "ratio", "tags"}, + }, + { + name: "complex HCL file", + file: "../testdata/hcl/complex.hcl", + expectedKeys: []string{"name", "version", "config", "servers", "metadata"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := os.ReadFile(tt.file) + if err != nil { + t.Fatalf("Failed to read file %s: %v", tt.file, err) + } + + node, err := ParseHCL(data) + if err != nil { + t.Fatalf("ParseHCL() error = %v", err) + } + + if node.Kind != tree.KindObject { + t.Fatalf("Kind = %v, want object", node.Kind) + } + + for _, key := range tt.expectedKeys { + if _, ok := node.Object[key]; !ok { + t.Errorf("Expected key %q not found in parsed HCL", key) + } + } + }) + } +} diff --git a/report/report.go b/report/report.go index 811aa9b..c487dab 100644 --- a/report/report.go +++ b/report/report.go @@ -3,8 +3,10 @@ package report import ( "fmt" + "os" "strings" + "github.com/fatih/color" "github.com/pfrederiksen/configdiff/diff" "github.com/pfrederiksen/configdiff/tree" ) @@ -23,6 +25,9 @@ type Options struct { // ContextLines shows N lines of context around changes (not implemented yet). ContextLines int + + // NoColor disables colored output. + NoColor bool } // DefaultOptions returns sensible defaults for report generation. @@ -41,11 +46,20 @@ func Generate(changes []diff.Change, opts Options) string { return "No changes detected.\n" } + // Save original color.NoColor value to restore later + originalNoColor := color.NoColor + defer func() { color.NoColor = originalNoColor }() + + // Disable color if requested or if NO_COLOR env var is set + if opts.NoColor || os.Getenv("NO_COLOR") != "" { + color.NoColor = true + } + var b strings.Builder // Write summary summary := summarizeChanges(changes) - b.WriteString(formatSummary(summary)) + b.WriteString(formatSummary(summary, opts)) if !opts.Compact { b.WriteString("\n") @@ -94,20 +108,25 @@ func summarizeChanges(changes []diff.Change) Summary { } // formatSummary creates a summary header. -func formatSummary(s Summary) string { +func formatSummary(s Summary, opts Options) string { parts := make([]string, 0, 4) + green := color.New(color.FgGreen).SprintFunc() + red := color.New(color.FgRed).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + if s.Added > 0 { - parts = append(parts, fmt.Sprintf("+%d added", s.Added)) + parts = append(parts, green(fmt.Sprintf("+%d added", s.Added))) } if s.Removed > 0 { - parts = append(parts, fmt.Sprintf("-%d removed", s.Removed)) + parts = append(parts, red(fmt.Sprintf("-%d removed", s.Removed))) } if s.Modified > 0 { - parts = append(parts, fmt.Sprintf("~%d modified", s.Modified)) + parts = append(parts, yellow(fmt.Sprintf("~%d modified", s.Modified))) } if s.Moved > 0 { - parts = append(parts, fmt.Sprintf("↔%d moved", s.Moved)) + parts = append(parts, cyan(fmt.Sprintf("↔%d moved", s.Moved))) } summary := strings.Join(parts, ", ") @@ -118,25 +137,45 @@ func formatSummary(s Summary) string { func formatChange(change diff.Change, opts Options) string { var b strings.Builder - // Change type symbol and path + // Color functions + green := color.New(color.FgGreen).SprintFunc() + red := color.New(color.FgRed).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + + // Change type symbol and path with color symbol := getChangeSymbol(change.Type) - b.WriteString(fmt.Sprintf(" %s %s", symbol, change.Path)) + var coloredSymbol string + switch change.Type { + case diff.ChangeTypeAdd: + coloredSymbol = green(symbol) + case diff.ChangeTypeRemove: + coloredSymbol = red(symbol) + case diff.ChangeTypeModify: + coloredSymbol = yellow(symbol) + case diff.ChangeTypeMove: + coloredSymbol = cyan(symbol) + default: + coloredSymbol = symbol + } + + b.WriteString(fmt.Sprintf(" %s %s", coloredSymbol, change.Path)) // Add values if requested if opts.ShowValues { switch change.Type { case diff.ChangeTypeAdd: val := formatValue(change.NewValue, opts.MaxValueLength) - b.WriteString(fmt.Sprintf(" = %s", val)) + b.WriteString(fmt.Sprintf(" = %s", green(val))) case diff.ChangeTypeRemove: val := formatValue(change.OldValue, opts.MaxValueLength) - b.WriteString(fmt.Sprintf(" (was: %s)", val)) + b.WriteString(fmt.Sprintf(" (was: %s)", red(val))) case diff.ChangeTypeModify: oldVal := formatValue(change.OldValue, opts.MaxValueLength) newVal := formatValue(change.NewValue, opts.MaxValueLength) - b.WriteString(fmt.Sprintf(": %s → %s", oldVal, newVal)) + b.WriteString(fmt.Sprintf(": %s → %s", red(oldVal), green(newVal))) } } diff --git a/report/report_test.go b/report/report_test.go index 2007bdf..0a2a877 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -300,7 +300,8 @@ func TestFormatSummary(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := formatSummary(tt.summary) + opts := Options{NoColor: true} // Disable color in tests + got := formatSummary(tt.summary, opts) if got != tt.want { t.Errorf("formatSummary() = %q, want %q", got, tt.want) } diff --git a/testdata/config/deployment1.yaml b/testdata/config/deployment1.yaml new file mode 100644 index 0000000..8c81548 --- /dev/null +++ b/testdata/config/deployment1.yaml @@ -0,0 +1,13 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myapp + generation: 1 + creationTimestamp: "2024-01-01T00:00:00Z" +spec: + replicas: 2 + containers: + - name: web + image: nginx:1.19 + - name: sidecar + image: helper:1.0 diff --git a/testdata/config/deployment2.yaml b/testdata/config/deployment2.yaml new file mode 100644 index 0000000..f755c34 --- /dev/null +++ b/testdata/config/deployment2.yaml @@ -0,0 +1,13 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myapp + generation: 5 + creationTimestamp: "2024-01-15T00:00:00Z" +spec: + replicas: 3 + containers: + - name: web + image: nginx:1.20 + - name: sidecar + image: helper:1.0 diff --git a/testdata/hcl/complex.hcl b/testdata/hcl/complex.hcl new file mode 100644 index 0000000..71de171 --- /dev/null +++ b/testdata/hcl/complex.hcl @@ -0,0 +1,24 @@ +name = "app" +version = "1.0.0" + +config = { + host = "localhost" + port = 8080 + ssl = false +} + +servers = [ + { + name = "server1" + ip = "192.168.1.1" + }, + { + name = "server2" + ip = "192.168.1.2" + } +] + +metadata = { + tags = ["prod", "web"] + owner = "team" +} diff --git a/testdata/hcl/complex_modified.hcl b/testdata/hcl/complex_modified.hcl new file mode 100644 index 0000000..f74ef5b --- /dev/null +++ b/testdata/hcl/complex_modified.hcl @@ -0,0 +1,29 @@ +name = "app" +version = "1.1.0" + +config = { + host = "example.com" + port = 8443 + ssl = true +} + +servers = [ + { + name = "server1" + ip = "192.168.1.1" + }, + { + name = "server2" + ip = "192.168.1.2" + }, + { + name = "server3" + ip = "192.168.1.3" + } +] + +metadata = { + tags = ["prod", "web", "https"] + owner = "team" + region = "us-east-1" +} diff --git a/testdata/hcl/simple.hcl b/testdata/hcl/simple.hcl new file mode 100644 index 0000000..19a6a31 --- /dev/null +++ b/testdata/hcl/simple.hcl @@ -0,0 +1,5 @@ +name = "example" +enabled = true +count = 42 +ratio = 3.14 +tags = ["tag1", "tag2"] diff --git a/testdata/hcl/simple_modified.hcl b/testdata/hcl/simple_modified.hcl new file mode 100644 index 0000000..366fdbb --- /dev/null +++ b/testdata/hcl/simple_modified.hcl @@ -0,0 +1,6 @@ +name = "example-updated" +enabled = true +count = 45 +ratio = 3.14159 +tags = ["tag1", "tag2", "tag3"] +new_field = "added"