diff --git a/README.md b/README.md index 9811288..f3c20ff 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,43 @@ For TLS-only clusters (typically port `9440`), set `CLICKHOUSE_SECURE=true` — or pass `-secure` on the CLI, or `?secure=true` on the diff URL form. See **[TLS / secure connections](#tls--secure-connections)** below. +## CLI comparison defaults + +`diff`, `drift`, `plan`, and dump-mode `web` read comparison defaults from up +to two HCL files, in this order: + +1. `$HOME/.hclexp.config` — user-wide defaults. +2. `/.hclexp.config` — project defaults. + +Only those exact locations are inspected; parent directories of the working +directory are not searched. If both paths identify the same file, it is read +once. Each file can set a `global` default and override it per subcommand: + +```hcl +global { + ignore_column_order = true +} + +plan { + ignore_column_order = false +} + +web { + ignore_column_order = true +} +``` + +The supported command blocks are `diff`, `drift`, `plan`, and `web`. +Precedence, from lowest to highest, is the built-in default, home `global`, +home command block, working-directory `global`, working-directory command +block, and an explicit CLI flag. Both directions are explicit: for example, +`-ignore-column-order=false` overrides a configured `true`. + +Malformed files, unknown settings, and unknown command blocks stop the command +with the config path in the error. When stderr is a terminal, hclexp prints one +`hclexp: loaded config ` line per file read. The status goes to stderr; +JSON and redirected stdout remain machine-clean. + ## Introspect a live database ```bash diff --git a/cmd/hclexp/cli_config.go b/cmd/hclexp/cli_config.go new file mode 100644 index 0000000..f0d45e4 --- /dev/null +++ b/cmd/hclexp/cli_config.go @@ -0,0 +1,148 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclparse" + "golang.org/x/term" +) + +const hclexpConfigFilename = ".hclexp.config" + +// hclexpConfigFile separates defaults shared by comparison commands from +// command-specific overrides. Pointer values distinguish an explicit false +// from an omitted setting that should inherit from the preceding layer. +type hclexpConfigFile struct { + Global *hclexpConfigSection `hcl:"global,block"` + Diff *hclexpConfigSection `hcl:"diff,block"` + Drift *hclexpConfigSection `hcl:"drift,block"` + Plan *hclexpConfigSection `hcl:"plan,block"` + Web *hclexpConfigSection `hcl:"web,block"` +} + +type hclexpConfigSection struct { + IgnoreColumnOrder *bool `hcl:"ignore_column_order,optional"` +} + +type hclexpConfigResolution struct { + IgnoreColumnOrder *bool + Files []string +} + +// configuredIgnoreColumnOrder applies, from lowest to highest precedence: +// built-in flag default, $HOME config, working-directory config, explicit CLI +// flag. Inside each file the subcommand section overrides the global section. +func configuredIgnoreColumnOrder(fs *flag.FlagSet, command string, flagValue bool) (bool, error) { + workdir, err := os.Getwd() + if err != nil { + return false, fmt.Errorf("get working directory: %w", err) + } + resolved, err := resolveHCLExpConfig(os.Getenv("HOME"), workdir, command) + if err != nil { + return false, err + } + renderHCLExpConfigInfoIfTerminal(os.Stderr, resolved.Files, term.IsTerminal(int(os.Stderr.Fd()))) + return effectiveIgnoreColumnOrder(resolved, flagWasSet(fs, "ignore-column-order"), flagValue), nil +} + +func resolveHCLExpConfig(home, workdir, command string) (hclexpConfigResolution, error) { + if !isComparisonCommand(command) { + return hclexpConfigResolution{}, fmt.Errorf("unsupported config subcommand %q", command) + } + + var resolved hclexpConfigResolution + seen := map[string]bool{} + for _, dir := range []string{home, workdir} { + if dir == "" { + continue + } + path, err := filepath.Abs(filepath.Join(dir, hclexpConfigFilename)) + if err != nil { + return hclexpConfigResolution{}, fmt.Errorf("resolve config path in %q: %w", dir, err) + } + path = filepath.Clean(path) + if seen[path] { + continue + } + seen[path] = true + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + continue + } + return hclexpConfigResolution{}, fmt.Errorf("stat %s: %w", path, err) + } + + parser := hclparse.NewParser() + file, diagnostics := parser.ParseHCLFile(path) + if diagnostics.HasErrors() { + return hclexpConfigResolution{}, fmt.Errorf("parse %s: %s", path, diagnostics) + } + var config hclexpConfigFile + if diagnostics := gohcl.DecodeBody(file.Body, nil, &config); diagnostics.HasErrors() { + return hclexpConfigResolution{}, fmt.Errorf("parse %s: %s", path, diagnostics) + } + resolved.Files = append(resolved.Files, path) + applyHCLExpConfigSection(&resolved, config.Global) + applyHCLExpConfigSection(&resolved, config.section(command)) + } + return resolved, nil +} + +func (c hclexpConfigFile) section(command string) *hclexpConfigSection { + switch command { + case "diff": + return c.Diff + case "drift": + return c.Drift + case "plan": + return c.Plan + case "web": + return c.Web + default: + return nil + } +} + +func isComparisonCommand(command string) bool { + switch command { + case "diff", "drift", "plan", "web": + return true + default: + return false + } +} + +func applyHCLExpConfigSection(resolved *hclexpConfigResolution, section *hclexpConfigSection) { + if section == nil || section.IgnoreColumnOrder == nil { + return + } + value := *section.IgnoreColumnOrder + resolved.IgnoreColumnOrder = &value +} + +func effectiveIgnoreColumnOrder(resolved hclexpConfigResolution, explicit bool, flagValue bool) bool { + if explicit { + return flagValue + } + if resolved.IgnoreColumnOrder != nil { + return *resolved.IgnoreColumnOrder + } + return flagValue +} + +func renderHCLExpConfigInfo(w io.Writer, files []string) { + for _, file := range files { + fmt.Fprintf(w, "hclexp: loaded config %s\n", file) + } +} + +func renderHCLExpConfigInfoIfTerminal(w io.Writer, files []string, terminal bool) { + if terminal { + renderHCLExpConfigInfo(w, files) + } +} diff --git a/cmd/hclexp/cli_config_e2e_test.go b/cmd/hclexp/cli_config_e2e_test.go new file mode 100644 index 0000000..570b138 --- /dev/null +++ b/cmd/hclexp/cli_config_e2e_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + hclload "github.com/posthog/chschema/internal/loader/hcl" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHCLExpConfigCLIProcess(t *testing.T) { + if os.Getenv("HCLEXP_CONFIG_HELPER") != "1" { + return + } + for i, arg := range os.Args { + if arg != "--" || i+1 >= len(os.Args) { + continue + } + command, commandArgs := os.Args[i+1], os.Args[i+2:] + switch command { + case "diff": + runDiff(commandArgs) + case "drift": + runDrift(commandArgs) + case "plan": + runPlan(commandArgs) + case "web": + runWeb(commandArgs) + default: + t.Fatalf("unsupported helper command %q", command) + } + os.Exit(0) + } + t.Fatal("missing CLI arguments") +} + +type hclexpConfigE2EFixture struct { + home, workdir, left, right, dump, manifest, desiredRoot string +} + +func newHCLExpConfigE2EFixture(t *testing.T) hclexpConfigE2EFixture { + t.Helper() + root := t.TempDir() + fixture := hclexpConfigE2EFixture{ + home: filepath.Join(root, "home"), + workdir: filepath.Join(root, "project"), + desiredRoot: filepath.Join(root, "project", "desired"), + dump: filepath.Join(root, "project", "dump"), + } + fixture.left = filepath.Join(fixture.workdir, "left.hcl") + fixture.right = filepath.Join(fixture.workdir, "right.hcl") + fixture.manifest = filepath.Join(fixture.desiredRoot, "manifest.hcl") + + writeFileT(t, filepath.Join(fixture.home, hclexpConfigFilename), ` +global { ignore_column_order = true } +`) + writeFileT(t, filepath.Join(fixture.workdir, hclexpConfigFilename), ` +global { ignore_column_order = false } +diff { ignore_column_order = true } +drift { ignore_column_order = true } +plan { ignore_column_order = true } +web { ignore_column_order = true } +`) + writeFileT(t, fixture.left, planColumnOrderHCL("a", "b")) + writeFileT(t, fixture.right, planColumnOrderHCL("b", "a")) + writeFileT(t, fixture.manifest, `role "ops" { + env "prod" { layers = ["schema.hcl"] } +} +`) + writeFileT(t, filepath.Join(fixture.desiredRoot, "schema.hcl"), planColumnOrderHCL("b", "a")) + writeFileT(t, filepath.Join(fixture.dump, "node-a.hcl"), configDumpNode("node-a", "a", "a", "b")) + writeFileT(t, filepath.Join(fixture.dump, "node-b.hcl"), configDumpNode("node-b", "b", "b", "a")) + return fixture +} + +func configDumpNode(node, replica, first, second string) string { + return `node "` + node + `" { + macros = { cluster = "ops", hostClusterRole = "ops", shard = "1", replica = "` + replica + `" } +} +` + planColumnOrderHCL(first, second) +} + +func TestHCLExpConfigEveryComparisonCommandEndToEnd(t *testing.T) { + fixture := newHCLExpConfigE2EFixture(t) + + t.Run("diff", func(t *testing.T) { + stdout, stderr, err := runHCLExpConfigCLI(fixture, "diff", "-left", fixture.left, "-right", fixture.right) + require.NoError(t, err, stderr) + assert.Equal(t, "no differences\n", stdout) + assert.Empty(t, stderr, "non-terminal output contains no config status") + + stdout, _, err = runHCLExpConfigCLI(fixture, "diff", "-left", fixture.left, "-right", fixture.right, "-ignore-column-order=false") + require.NoError(t, err) + assert.Contains(t, stdout, "column_order") + }) + + t.Run("plan dump", func(t *testing.T) { + args := []string{ + "-manifest", fixture.manifest, "-layer-root", fixture.desiredRoot, + "-env", "prod", "-dump", fixture.dump, + } + stdout, stderr, err := runHCLExpConfigCLI(fixture, "plan", args...) + require.NoError(t, err, stderr) + var clean hclload.PlanResult + require.NoError(t, json.Unmarshal([]byte(stdout), &clean), stdout) + assert.Empty(t, clean.Unsafe) + require.Len(t, clean.Roles, 1) + assert.Empty(t, clean.Roles[0].Objects) + + stdout, _, err = runHCLExpConfigCLI(fixture, "plan", append(args, "-ignore-column-order=false")...) + require.NoError(t, err) + var orderSensitive hclload.PlanResult + require.NoError(t, json.Unmarshal([]byte(stdout), &orderSensitive), stdout) + assert.NotEmpty(t, orderSensitive.Unsafe) + assert.NotEmpty(t, orderSensitive.Roles[0].Objects) + }) + + t.Run("drift", func(t *testing.T) { + stdout, stderr, err := runHCLExpConfigCLI(fixture, "drift", "-dir", fixture.dump) + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "OK (all identical)") + + stdout, _, err = runHCLExpConfigCLI(fixture, "drift", "-dir", fixture.dump, "-ignore-column-order=false") + require.Error(t, err, "order-sensitive drift exits non-zero") + assert.Contains(t, stdout, "drifting") + }) + + t.Run("web dump", func(t *testing.T) { + addr := reserveLoopbackAddress(t) + cmd := hclexpConfigCommand(fixture, "web", "-dump", fixture.dump, "-addr", addr, "-reload-interval", "0") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + require.NoError(t, cmd.Start()) + defer func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + + body := fetchEventually(t, "http://"+addr+"/object-diffs") + assert.Contains(t, body, `name="ignore_column_order" value="1" checked`) + assert.Contains(t, body, `0different`) + }) +} + +func TestHCLExpConfigCLITrueOverridesFalseEndToEnd(t *testing.T) { + fixture := newHCLExpConfigE2EFixture(t) + writeFileT(t, filepath.Join(fixture.workdir, hclexpConfigFilename), `global { ignore_column_order = false }`) + stdout, stderr, err := runHCLExpConfigCLI( + fixture, "diff", "-left", fixture.left, "-right", fixture.right, "-ignore-column-order=true", + ) + require.NoError(t, err, stderr) + assert.Equal(t, "no differences\n", stdout) +} + +func TestHCLExpConfigHomeDiscoveryEndToEnd(t *testing.T) { + fixture := newHCLExpConfigE2EFixture(t) + writeFileT(t, filepath.Join(fixture.workdir, hclexpConfigFilename), "# no project overrides\n") + stdout, stderr, err := runHCLExpConfigCLI(fixture, "diff", "-left", fixture.left, "-right", fixture.right) + require.NoError(t, err, stderr) + assert.Equal(t, "no differences\n", stdout, "the home global setting is applied") +} + +func TestHCLExpConfigInvalidFileFailsClosedEndToEnd(t *testing.T) { + fixture := newHCLExpConfigE2EFixture(t) + writeFileT(t, filepath.Join(fixture.workdir, hclexpConfigFilename), `global { ignore_column_orders = true }`) + stdout, stderr, err := runHCLExpConfigCLI(fixture, "diff", "-left", fixture.left, "-right", fixture.right) + require.Error(t, err) + assert.Empty(t, stdout) + assert.Contains(t, stderr, filepath.Join(fixture.workdir, hclexpConfigFilename)) + assert.Contains(t, stderr, "Unsupported argument") +} + +func runHCLExpConfigCLI(fixture hclexpConfigE2EFixture, command string, args ...string) (string, string, error) { + cmd := hclexpConfigCommand(fixture, command, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +func hclexpConfigCommand(fixture hclexpConfigE2EFixture, command string, args ...string) *exec.Cmd { + commandArgs := []string{"-test.run=^TestHCLExpConfigCLIProcess$", "--", command} + commandArgs = append(commandArgs, args...) + cmd := exec.Command(os.Args[0], commandArgs...) + cmd.Dir = fixture.workdir + cmd.Env = hclexpConfigTestEnv(fixture.home, "HCLEXP_CONFIG_HELPER=1") + return cmd +} + +func hclexpConfigTestEnv(home string, extra ...string) []string { + overridden := map[string]bool{"HOME": true} + for _, entry := range extra { + if key, _, ok := strings.Cut(entry, "="); ok { + overridden[key] = true + } + } + env := make([]string, 0, len(os.Environ())+1+len(extra)) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if !overridden[key] { + env = append(env, entry) + } + } + env = append(env, "HOME="+home) + return append(env, extra...) +} + +func reserveLoopbackAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := listener.Addr().String() + require.NoError(t, listener.Close()) + return addr +} + +func fetchEventually(t *testing.T, url string) string { + t.Helper() + client := &http.Client{Timeout: time.Second} + deadline := time.Now().Add(10 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + response, err := client.Get(url) + if err == nil { + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + if readErr == nil && response.StatusCode == http.StatusOK { + return string(body) + } + lastErr = readErr + } else { + lastErr = err + } + time.Sleep(20 * time.Millisecond) + } + require.NoError(t, lastErr) + return "" +} diff --git a/cmd/hclexp/cli_config_test.go b/cmd/hclexp/cli_config_test.go new file mode 100644 index 0000000..17af9a2 --- /dev/null +++ b/cmd/hclexp/cli_config_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveHCLExpConfigPrecedence(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + workdir := filepath.Join(t.TempDir(), "project") + writeFileT(t, filepath.Join(home, hclexpConfigFilename), ` +global { ignore_column_order = true } +plan { ignore_column_order = false } +`) + writeFileT(t, filepath.Join(workdir, hclexpConfigFilename), ` +global { ignore_column_order = false } +diff { ignore_column_order = true } +web { ignore_column_order = true } +`) + + tests := []struct { + command string + want bool + }{ + {command: "diff", want: true}, + {command: "drift", want: false}, + {command: "plan", want: false}, + {command: "web", want: true}, + } + for _, tc := range tests { + t.Run(tc.command, func(t *testing.T) { + resolved, err := resolveHCLExpConfig(home, workdir, tc.command) + require.NoError(t, err) + require.NotNil(t, resolved.IgnoreColumnOrder) + assert.Equal(t, tc.want, *resolved.IgnoreColumnOrder) + assert.Equal(t, []string{ + filepath.Join(home, hclexpConfigFilename), + filepath.Join(workdir, hclexpConfigFilename), + }, resolved.Files) + }) + } +} + +func TestResolveHCLExpConfigMissingAndDeduplicated(t *testing.T) { + dir := t.TempDir() + resolved, err := resolveHCLExpConfig(dir, dir, "diff") + require.NoError(t, err) + assert.Nil(t, resolved.IgnoreColumnOrder) + assert.Empty(t, resolved.Files) + + writeFileT(t, filepath.Join(dir, hclexpConfigFilename), `global { ignore_column_order = true }`) + resolved, err = resolveHCLExpConfig(dir, dir, "diff") + require.NoError(t, err) + require.NotNil(t, resolved.IgnoreColumnOrder) + assert.True(t, *resolved.IgnoreColumnOrder) + assert.Equal(t, []string{filepath.Join(dir, hclexpConfigFilename)}, resolved.Files) +} + +func TestResolveHCLExpConfigRejectsInvalidAndUnknownSettings(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "invalid syntax", body: `global { ignore_column_order = }`, want: "Invalid expression"}, + {name: "unknown setting", body: `global { ignore_column_orders = true }`, want: "Unsupported argument"}, + {name: "unknown command", body: `validate { ignore_column_order = true }`, want: "Unsupported block type"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, hclexpConfigFilename) + writeFileT(t, path, tc.body) + _, err := resolveHCLExpConfig("", dir, "diff") + require.Error(t, err) + assert.Contains(t, err.Error(), path) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestEffectiveIgnoreColumnOrderCLIWinsBothWays(t *testing.T) { + configuredTrue := true + configuredFalse := false + assert.False(t, effectiveIgnoreColumnOrder(hclexpConfigResolution{IgnoreColumnOrder: &configuredTrue}, true, false)) + assert.True(t, effectiveIgnoreColumnOrder(hclexpConfigResolution{IgnoreColumnOrder: &configuredFalse}, true, true)) + assert.True(t, effectiveIgnoreColumnOrder(hclexpConfigResolution{IgnoreColumnOrder: &configuredTrue}, false, false)) + assert.False(t, effectiveIgnoreColumnOrder(hclexpConfigResolution{}, false, false)) +} + +func TestRenderHCLExpConfigInfo(t *testing.T) { + var out bytes.Buffer + files := []string{"/home/alice/.hclexp.config", "/work/project/.hclexp.config"} + renderHCLExpConfigInfoIfTerminal(&out, files, false) + assert.Empty(t, out.String()) + renderHCLExpConfigInfoIfTerminal(&out, files, true) + assert.Equal(t, "hclexp: loaded config /home/alice/.hclexp.config\nhclexp: loaded config /work/project/.hclexp.config\n", out.String()) +} diff --git a/cmd/hclexp/diff_column_order_e2e_test.go b/cmd/hclexp/diff_column_order_e2e_test.go index 12cc844..f2214cc 100644 --- a/cmd/hclexp/diff_column_order_e2e_test.go +++ b/cmd/hclexp/diff_column_order_e2e_test.go @@ -78,6 +78,7 @@ func runDiffColumnOrderCLI(left, right string, extra ...string) ([]byte, error) args := []string{"-test.run=^TestDiffColumnOrderCLIProcess$", "--", "-left", left, "-right", right} args = append(args, extra...) cmd := exec.Command(os.Args[0], args...) - cmd.Env = append(os.Environ(), "HCLEXP_DIFF_COLUMN_ORDER_HELPER=1") + cmd.Dir = filepath.Dir(left) + cmd.Env = hclexpConfigTestEnv(filepath.Join(filepath.Dir(left), "test-home"), "HCLEXP_DIFF_COLUMN_ORDER_HELPER=1") return cmd.Output() } diff --git a/cmd/hclexp/drift.go b/cmd/hclexp/drift.go index 4e94b82..9ae01e3 100644 --- a/cmd/hclexp/drift.go +++ b/cmd/hclexp/drift.go @@ -51,8 +51,14 @@ func runDrift(args []string) { details := fs.Bool("details", false, "print the full change set of each drifting node against its group reference") excludeFlag := fs.String("exclude", "", "HCL exclude config: objects matching its patterns/object_types are dropped from every node before comparing") formatFlag := fs.String("format", "text", "output format: text (default) or json") - ignoreColumnOrder := fs.Bool("ignore-column-order", false, "ignore table and materialized-view column declaration order") + ignoreColumnOrder := fs.Bool("ignore-column-order", false, "ignore table and materialized-view column declaration order (overrides .hclexp.config)") _ = fs.Parse(args) + configuredColumnOrder, err := configuredIgnoreColumnOrder(fs, "drift", *ignoreColumnOrder) + if err != nil { + fmt.Fprintf(os.Stderr, "drift: failed to load hclexp config: %v\n", err) + os.Exit(1) + } + *ignoreColumnOrder = configuredColumnOrder if *dirFlag == "" { fmt.Fprintln(os.Stderr, "drift: -dir is required") diff --git a/cmd/hclexp/hclexp.go b/cmd/hclexp/hclexp.go index d0f3534..9028ab1 100644 --- a/cmd/hclexp/hclexp.go +++ b/cmd/hclexp/hclexp.go @@ -117,6 +117,10 @@ Commands: help print this help Run "hclexp -h" for command-specific flags. + +Comparison defaults: + diff, drift, plan, and dump-mode web read $HOME/.hclexp.config followed by + /.hclexp.config. Explicit command-line flags win. `) } @@ -908,8 +912,14 @@ func runDiff(args []string) { excludeFlag := fs.String("exclude", "", "HCL exclude config: objects matching its patterns/object_types are dropped from both sides before diffing") allowRaw := fs.Bool("allow-raw", false, "capture unparseable objects on live sides as raw{} blocks instead of failing") scopeFlag := fs.String("scope", "all", "object scope: all (exact), left (ignore right-only objects), or right (ignore left-only objects)") - ignoreColumnOrder := fs.Bool("ignore-column-order", false, "ignore table and materialized-view column declaration order") + ignoreColumnOrder := fs.Bool("ignore-column-order", false, "ignore table and materialized-view column declaration order (overrides .hclexp.config)") _ = fs.Parse(args) + configuredColumnOrder, err := configuredIgnoreColumnOrder(fs, "diff", *ignoreColumnOrder) + if err != nil { + slog.Error("failed to load hclexp config", "err", err) + os.Exit(1) + } + *ignoreColumnOrder = configuredColumnOrder if *leftFlag == "" || *rightFlag == "" { slog.Error("both -left and -right are required") diff --git a/cmd/hclexp/plan.go b/cmd/hclexp/plan.go index 6fc3889..3c77c01 100644 --- a/cmd/hclexp/plan.go +++ b/cmd/hclexp/plan.go @@ -77,8 +77,14 @@ func runPlan(args []string) { scopeFlag := fs.String("scope", "all", "dump object scope: all (exact) or desired (ignore live-only objects)") formatFlag := fs.String("format", "json", "output format: json (default) or text") excludeFlag := fs.String("exclude", "", "HCL exclude config: objects matching its patterns/object_types are dropped from both sides before diffing") - ignoreColumnOrder := fs.Bool("ignore-column-order", false, "ignore table and materialized-view column declaration order") + ignoreColumnOrder := fs.Bool("ignore-column-order", false, "ignore table and materialized-view column declaration order (overrides .hclexp.config)") _ = fs.Parse(args) + configuredColumnOrder, err := configuredIgnoreColumnOrder(fs, "plan", *ignoreColumnOrder) + if err != nil { + slog.Error("failed to load hclexp config", "err", err) + os.Exit(1) + } + *ignoreColumnOrder = configuredColumnOrder if *manifestFlag == "" || *envFlag == "" { slog.Error("-manifest and -env are required") diff --git a/cmd/hclexp/plan_column_order_e2e_test.go b/cmd/hclexp/plan_column_order_e2e_test.go index 53966b6..034808e 100644 --- a/cmd/hclexp/plan_column_order_e2e_test.go +++ b/cmd/hclexp/plan_column_order_e2e_test.go @@ -47,7 +47,7 @@ func TestPlanIgnoreColumnOrderEndToEnd(t *testing.T) { "-env", "prod", "-dump", dumpRoot, } - output, err := runPlanColumnOrderCLI(args...) + output, err := runPlanColumnOrderCLI(root, args...) require.NoError(t, err, string(output)) var orderSensitive hclload.PlanResult require.NoError(t, json.Unmarshal(output, &orderSensitive), string(output)) @@ -60,7 +60,7 @@ func TestPlanIgnoreColumnOrderEndToEnd(t *testing.T) { }}, object.Changes) } - ignored, err := runPlanColumnOrderCLI(append(args, "-ignore-column-order")...) + ignored, err := runPlanColumnOrderCLI(root, append(args, "-ignore-column-order")...) require.NoError(t, err, string(ignored)) var clean hclload.PlanResult require.NoError(t, json.Unmarshal(ignored, &clean), string(ignored)) @@ -69,7 +69,7 @@ func TestPlanIgnoreColumnOrderEndToEnd(t *testing.T) { require.Len(t, clean.Roles, 1) assert.Empty(t, clean.Roles[0].Objects) - text, err := runPlanColumnOrderCLI(append(args, "-ignore-column-order", "-format", "text")...) + text, err := runPlanColumnOrderCLI(root, append(args, "-ignore-column-order", "-format", "text")...) require.NoError(t, err, string(text)) assert.Equal(t, "no changes\n", string(text)) } @@ -91,9 +91,10 @@ func planColumnOrderHCL(first, second string) string { ` } -func runPlanColumnOrderCLI(args ...string) ([]byte, error) { +func runPlanColumnOrderCLI(workdir string, args ...string) ([]byte, error) { commandArgs := append([]string{"-test.run=^TestPlanColumnOrderCLIProcess$", "--"}, args...) cmd := exec.Command(os.Args[0], commandArgs...) - cmd.Env = append(os.Environ(), "HCLEXP_PLAN_COLUMN_ORDER_HELPER=1") + cmd.Dir = workdir + cmd.Env = hclexpConfigTestEnv(filepath.Join(workdir, "test-home"), "HCLEXP_PLAN_COLUMN_ORDER_HELPER=1") return cmd.Output() } diff --git a/cmd/hclexp/web.go b/cmd/hclexp/web.go index f9d67d3..4923532 100644 --- a/cmd/hclexp/web.go +++ b/cmd/hclexp/web.go @@ -37,7 +37,7 @@ func runWeb(args []string) { globFlag := flags.String("glob", "*", "with -dump: comma-separated filename globs selecting node dumps") addrFlag := flags.String("addr", ":8080", "address to listen on (host:port)") reloadFlag := flags.Duration("reload-interval", 2*time.Second, "re-stat the source files at most this often and reload on change; 0 disables") - ignoreColumnOrder := flags.Bool("ignore-column-order", false, "with -dump: ignore table and materialized-view column declaration order by default (changeable for the browser session)") + ignoreColumnOrder := flags.Bool("ignore-column-order", false, "with -dump: ignore table and materialized-view column declaration order by default (overrides .hclexp.config; changeable for the browser session)") _ = flags.Parse(args) explicit := map[string]bool{} flags.Visit(func(f *flag.Flag) { explicit[f.Name] = true }) @@ -51,6 +51,12 @@ func runWeb(args []string) { fmt.Fprintln(os.Stderr, "web: -dump is mutually exclusive with -config, -layer, -manifest, -env, and -layer-root") os.Exit(2) } + configuredColumnOrder, err := configuredIgnoreColumnOrder(flags, "web", *ignoreColumnOrder) + if err != nil { + slog.Error("failed to load hclexp config", "err", err) + os.Exit(1) + } + *ignoreColumnOrder = configuredColumnOrder runWebDump(*dumpFlag, *globFlag, *addrFlag, *reloadFlag, hclload.DiffOptions{IgnoreColumnOrder: *ignoreColumnOrder}) return } diff --git a/cmd/hclexp/web_manifest_test.go b/cmd/hclexp/web_manifest_test.go index db06439..95f5859 100644 --- a/cmd/hclexp/web_manifest_test.go +++ b/cmd/hclexp/web_manifest_test.go @@ -195,7 +195,8 @@ func TestWebDumpParallelLoadingStatusEndToEnd(t *testing.T) { cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestWebParallelLoadingCLIProcess$", "--", "-dump", root, "-glob", "*", "-addr", "127.0.0.1:0", "-reload-interval", "0") - cmd.Env = append(os.Environ(), "HCLEXP_WEB_PARALLEL_HELPER=1") + cmd.Dir = root + cmd.Env = hclexpConfigTestEnv(filepath.Join(root, "test-home"), "HCLEXP_WEB_PARALLEL_HELPER=1") var stdout bytes.Buffer cmd.Stdout = &stdout stderr, err := cmd.StderrPipe() diff --git a/docs/README.hcl.md b/docs/README.hcl.md index fcd4d21..1e5d7d4 100644 --- a/docs/README.hcl.md +++ b/docs/README.hcl.md @@ -747,6 +747,49 @@ reported once with all referencing nodes. `-skip-validation`, the generated DDL, a table is created before any Distributed table that forwards to it, and dropped after it. +## CLI comparison defaults — `.hclexp.config` + +`diff`, `drift`, `plan`, and dump-mode `web` load comparison defaults from two +HCL locations, in order: `$HOME/.hclexp.config`, then +`/.hclexp.config`. These are exact locations; hclexp does +not walk through parent directories. If home and the working directory are the +same directory, the file is loaded once. + +Each file has an optional `global` block and optional `diff`, `drift`, `plan`, +and `web` blocks. A command block overrides `global` from the same file: + +```hcl +global { + ignore_column_order = true +} + +diff { + ignore_column_order = false +} + +plan { + ignore_column_order = true +} +``` + +The full precedence order is: + +1. built-in default; +2. home `global`, then the home command block; +3. working-directory `global`, then the working-directory command block; +4. an explicit CLI value. + +An explicit false is significant, so `-ignore-column-order=false` reverses a +configured `true`. Currently `ignore_column_order` is the only config setting. +Unknown settings or blocks and malformed HCL fail closed with the config path +in the error. + +When stderr is an interactive terminal, every successfully read file is +reported as `hclexp: loaded config `. The message is written to stderr, +so JSON and redirected stdout remain machine-clean. Dump-mode `web` uses the +resolved value as the initial browser-session comparison setting; its UI can +still change that setting for the session. + ## Directional object scope — `hclexp diff -scope` An ordinary two-way diff is exact: every object present on only one side is a