Skip to content

Conversation

@karngyan
Copy link
Member

Add cfgx diff command for comparing TOML files

Implements a new diff command that compares two TOML configuration files and highlights differences. This is useful for understanding changes between environments (dev vs prod) or base and override configurations.

Features:

  • Recursive comparison of nested tables
  • Three diff types: added (+), removed (-), and changed (~) values
  • --keys-only flag to show only changed keys
  • --format json for machine-readable output
  • Exit code 0 on successful comparison (differences are not errors)

Example usage:

cfgx diff config.dev.toml config.prod.toml
cfgx diff config.dev.toml config.prod.toml --keys-only
cfgx diff base.toml override.toml --format json

Includes example config files and updated documentation.

Copilot AI review requested due to automatic review settings October 28, 2025 18:53
@github-actions
Copy link

📊 Code Coverage Report

total:									(statements)			50.6%
Coverage by file
github.com/gomantics/cfgx/cfgx.go:88:					GenerateFromFile		78.9%
github.com/gomantics/cfgx/cfgx.go:180:					Generate			100.0%
github.com/gomantics/cfgx/cfgx.go:197:					GenerateWithOptions		62.5%
github.com/gomantics/cfgx/cmd/cfgx/common.go:20:			parseFileSize			0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:38:				init				0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:43:				runDiff				0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:77:				parseTomlFile			0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:104:				computeDiffs			0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:178:				deepEqual			0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:185:				outputText			0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:221:				formatValue			0.0%
github.com/gomantics/cfgx/cmd/cfgx/diff.go:242:				outputJSON			0.0%
github.com/gomantics/cfgx/cmd/cfgx/generate.go:60:			init				0.0%
github.com/gomantics/cfgx/cmd/cfgx/main.go:18:				main				0.0%
github.com/gomantics/cfgx/cmd/cfgx/main.go:32:				init				0.0%
github.com/gomantics/cfgx/cmd/cfgx/watch.go:168:			init				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:33:		Logging				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:37:		File				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:44:		Format				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:51:		Level				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:58:		Rotation			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:62:		Compress			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:71:		MaxAge				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:80:		MaxSize				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:89:		Name				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:96:		Version				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:103:		Enabled				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:112:		MaxEntries			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:121:		Outputs				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:128:		Redis				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:132:		Addr				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:139:		Db				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:148:		Password			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:155:		Ttl				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:164:		ConnMaxLifetime			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:173:		Dsn				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:180:		MaxIdleConns			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:189:		MaxOpenConns			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:198:		Pool				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:202:		Enabled				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:211:		MaxSize				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:220:		MinSize				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:229:		Methods				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:236:		Path				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:243:		RateLimit			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:252:		Enabled				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:261:		Name				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:268:		Priority			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:277:		Addr				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:284:		Cert				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:332:		Debug				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:341:		IdleTimeout			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:350:		MaxHeaderBytes			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:359:		ReadTimeout			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:368:		ShutdownTimeout			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:377:		Timeout				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:386:		WriteTimeout			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:395:		AllowedOrigins			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:402:		Features			0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:409:		Name				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:416:		Ports				0.0%
github.com/gomantics/cfgx/example/getter_config/config.go:423:		Weights				0.0%
github.com/gomantics/cfgx/internal/envoverride/envoverride.go:13:	Apply				50.0%
github.com/gomantics/cfgx/internal/envoverride/envoverride.go:40:	applyNested			88.2%
github.com/gomantics/cfgx/internal/envoverride/envoverride.go:79:	convertValue			93.3%
github.com/gomantics/cfgx/internal/envoverride/envoverride.go:111:	convertArray			88.9%
github.com/gomantics/cfgx/internal/generator/file_handler.go:11:	isFileReference			100.0%
github.com/gomantics/cfgx/internal/generator/file_handler.go:18:	loadFileContent			81.2%
github.com/gomantics/cfgx/internal/generator/generator.go:26:		WithPackageName			100.0%
github.com/gomantics/cfgx/internal/generator/generator.go:33:		WithEnvOverride			0.0%
github.com/gomantics/cfgx/internal/generator/generator.go:40:		WithInputDir			100.0%
github.com/gomantics/cfgx/internal/generator/generator.go:47:		WithMaxFileSize			100.0%
github.com/gomantics/cfgx/internal/generator/generator.go:54:		WithMode			100.0%
github.com/gomantics/cfgx/internal/generator/generator.go:61:		New				100.0%
github.com/gomantics/cfgx/internal/generator/generator.go:77:		stripSuffix			40.0%
github.com/gomantics/cfgx/internal/generator/generator.go:88:		writeGetterImports		100.0%
github.com/gomantics/cfgx/internal/generator/generator.go:109:		needsStrconvImport		100.0%
github.com/gomantics/cfgx/internal/generator/generator.go:119:		checkStrconvNeeded		69.2%
github.com/gomantics/cfgx/internal/generator/generator.go:148:		Generate			86.4%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:24:		generateStructsAndVars		63.8%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:128:		collectNestedStructs		57.1%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:166:		generateStruct			89.5%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:208:		generateStructInit		85.2%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:272:		writeArrayOfTablesInit		58.8%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:317:		writeArrayOfStructs		0.0%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:374:		generateStructsAndGetters	71.8%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:442:		collectNestedStructsForGetters	57.1%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:471:		generateGetterMethods		62.8%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:547:		generateGetterMethod		91.7%
github.com/gomantics/cfgx/internal/generator/struct_gen.go:609:		envVarName			100.0%
github.com/gomantics/cfgx/internal/generator/validation.go:10:		validateFileReferences		100.0%
github.com/gomantics/cfgx/internal/generator/validation.go:20:		validateFileReferencesValue	84.6%
github.com/gomantics/cfgx/internal/generator/validation.go:51:		needsTimeImport			100.0%
github.com/gomantics/cfgx/internal/generator/validation.go:60:		needsTimeImportValue		88.9%
github.com/gomantics/cfgx/internal/generator/validation.go:82:		isDurationString		100.0%
github.com/gomantics/cfgx/internal/generator/value_writer.go:17:	toGoType			94.1%
github.com/gomantics/cfgx/internal/generator/value_writer.go:61:	writeValue			100.0%
github.com/gomantics/cfgx/internal/generator/value_writer.go:66:	writeValueWithIndent		76.5%
github.com/gomantics/cfgx/internal/generator/value_writer.go:105:	writeByteArrayLiteral		88.2%
github.com/gomantics/cfgx/internal/generator/value_writer.go:141:	writeDurationLiteral		80.0%
github.com/gomantics/cfgx/internal/generator/value_writer.go:197:	writeArray			80.0%
github.com/gomantics/cfgx/internal/pkgutil/pkgutil.go:11:		InferName			63.6%
total:									(statements)			50.6%

@karngyan karngyan merged commit cd3aaf3 into main Oct 28, 2025
6 checks passed
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements a new diff command for cfgx that compares two TOML configuration files and highlights their differences. This feature helps developers understand configuration changes between different environments (dev vs prod) or between base and override configurations.

Key changes:

  • Added cfgx diff command with text and JSON output formats
  • Supports recursive comparison of nested TOML tables
  • Provides --keys-only and --format flags for customized output

Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
cmd/cfgx/diff.go New file implementing the diff command with comparison logic, output formatting, and CLI interface
cmd/cfgx/main.go Registers the new diff command with the root command
readme.md Adds documentation for the diff command including usage examples and output format
example/diff/config.dev.toml Example development configuration file for demonstrating diff functionality
example/diff/config.prod.toml Example production configuration file for demonstrating diff functionality
ROADMAP.md Removes diff from the roadmap since it's now implemented

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if isMap1 && isMap2 {
// Recursively compare nested maps
nestedDiffs := computeDiffs(map1, map2, fullKey)
diffs = append(diffs, nestedDiffs...)
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When one value is a map and the other is not (isMap1 != isMap2), the comparison logic falls through without reporting a difference. This results in a silent failure to detect type mismatches. Add an explicit check for this case before the deepEqual comparison.

Suggested change
diffs = append(diffs, nestedDiffs...)
diffs = append(diffs, nestedDiffs...)
} else if isMap1 != isMap2 {
// Type mismatch: one is a map, the other is not
diffs = append(diffs, Diff{
Key: fullKey,
Type: DiffChanged,
Value1: val1,
Value2: val2,
})

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +182
func deepEqual(v1, v2 any) bool {
// Use fmt.Sprintf to compare values as strings
// This handles most TOML types correctly
return fmt.Sprintf("%v", v1) == fmt.Sprintf("%v", v2)
}
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using fmt.Sprintf for deep equality comparison can produce false positives. For example, arrays and maps with different internal representations may produce the same string output. Consider using reflect.DeepEqual or implementing proper type-specific comparisons for arrays, maps, and primitive types.

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +148
diffs = append(diffs, Diff{
Key: fullKey,
Type: DiffAdded,
Value2: val2,
})
continue
}

// Key only in data1 (removed)
if exists1 && !exists2 {
diffs = append(diffs, Diff{
Key: fullKey,
Type: DiffRemoved,
Value1: val1,
})
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a key exists only in data2 and its value is a nested map, the entire map is stored in Value2 without recursing into it. This means added nested structures are not expanded in the diff output, making it difficult to see what was added in deeply nested configurations. Consider recursing into added/removed maps to provide detailed diffs.

Suggested change
diffs = append(diffs, Diff{
Key: fullKey,
Type: DiffAdded,
Value2: val2,
})
continue
}
// Key only in data1 (removed)
if exists1 && !exists2 {
diffs = append(diffs, Diff{
Key: fullKey,
Type: DiffRemoved,
Value1: val1,
})
// If the added value is a map, recurse into it for detailed diffs
if map2, isMap2 := val2.(map[string]any); isMap2 {
nestedDiffs := computeDiffs(nil, map2, fullKey)
diffs = append(diffs, nestedDiffs...)
} else {
diffs = append(diffs, Diff{
Key: fullKey,
Type: DiffAdded,
Value2: val2,
})
}
continue
}
// Key only in data1 (removed)
if exists1 && !exists2 {
// If the removed value is a map, recurse into it for detailed diffs
if map1, isMap1 := val1.(map[string]any); isMap1 {
nestedDiffs := computeDiffs(map1, nil, fullKey)
diffs = append(diffs, nestedDiffs...)
} else {
diffs = append(diffs, Diff{
Key: fullKey,
Type: DiffRemoved,
Value1: val1,
})
}

Copilot uses AI. Check for mistakes.
- Exits with code 0 on successful comparison (regardless of differences found)
- Exits with code 1 only on errors (file not found, invalid TOML, etc.)
- Recursively compares nested tables
- Shows added (+), removed (-), and changed (~) values
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that changed values are shown with '', but the actual text output in lines 199-202 of diff.go does not prefix changed values with '' unless --keys-only is used. The documentation should accurately reflect the actual behavior where changed values show the key without a prefix, followed by '-' and '+' lines for the old and new values.

Suggested change
- Shows added (+), removed (-), and changed (~) values
- Shows added (+), removed (-), and changed values (key shown, with '-' and '+' lines for old and new values)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants