Skip to content

Commit 6acb70a

Browse files
feat(config): rename default config to .oasdiff.*; add --config flag and OASDIFF_CONFIG env var
Switch the default config-file lookup from oasdiff.{json,yaml,yml,toml,hcl} to .oasdiff.{json,yaml,yml,toml,hcl}. The dotfile convention matches the overwhelming pattern for CI/lint tools (.eslintrc, .prettierrc, .golangci.yml, .yamllint, .flake8, .markdownlint.json) — putting oasdiff.yaml at the root reads as a project-defining file, which oasdiff isn't. For backward compatibility with existing CLI users who relied on the old filename, two new override mechanisms: - --config <path> persistent flag on the root command - OASDIFF_CONFIG environment variable Precedence: --config > OASDIFF_CONFIG > default .oasdiff.* lookup. When either override is set, the file MUST exist (missing or malformed file is an error, unlike the silent-skip semantics of the default lookup). The env var earns its place specifically for CI workflows: a customer with multiple oasdiff invocations in one workflow sets OASDIFF_CONFIG once at the workflow's env: block instead of duplicating --config per step. This matches industry convention (KUBECONFIG, AWS_CONFIG_FILE, DOCKER_CONFIG, NPM_CONFIG_USERCONFIG). Migration for existing oasdiff.yaml users: rename to .oasdiff.yaml, OR pass --config oasdiff.yaml (or set OASDIFF_CONFIG=oasdiff.yaml). Tests: 7 new cases in internal/viper_test.go cover the default lookup, legacy-filename ignored, missing-config-not-an-error, --config explicit path, --config missing file is an error, --config overrides default, OASDIFF_CONFIG env var, and --config wins over OASDIFF_CONFIG.
1 parent 2ae1a46 commit 6acb70a

5 files changed

Lines changed: 219 additions & 8 deletions

File tree

docs/CONFIG-FILES.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
# Configuration Files
22
The `oasdiff` command can read its configuration from a file.
3-
This is useful for complex configurations or repeated usage patterns.
4-
The config file should be named oasdiff.{json,yaml,yml,toml,hcl} and placed in the directory where the command is run.
5-
For example, see [oasdiff.yaml](../examples/oasdiff.yaml).
3+
This is useful for complex configurations or repeated usage patterns.
4+
5+
## Default lookup
6+
7+
By default, `oasdiff` looks for `.oasdiff.{json,yaml,yml,toml,hcl}` in the directory where the command is run.
8+
9+
For example, see [.oasdiff.yaml](../examples/.oasdiff.yaml).
10+
11+
## Explicit override
12+
13+
To use a different filename or path, pass `--config <path>` or set the `OASDIFF_CONFIG` environment variable. When either is set, the default lookup is skipped and the file at the given path must exist (missing or malformed file is an error).
14+
15+
Precedence: `--config <path>` > `OASDIFF_CONFIG` > default `.oasdiff.*` lookup.
16+
17+
```sh
18+
# Explicit flag (per-invocation)
19+
oasdiff diff --config ./my-config.yaml base.yaml revision.yaml
20+
21+
# Environment variable (set once for a shell or CI workflow)
22+
export OASDIFF_CONFIG=./my-config.yaml
23+
oasdiff diff base.yaml revision.yaml
24+
```
625

726
The configuration file supports the exact same flags that are supported by the command-line.
827
Notes:
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# oasdiff configuration file
22
# This file is used to customize the behavior of oasdiff.
3-
# Place in the directory where the command is run
3+
# Place at the directory where the command is run, or override the
4+
# location with --config <path> or the OASDIFF_CONFIG env var.
45
format: json
56
color: never
67
attributes:

internal/run.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
2020
rootCmd.SetErr(stderr)
2121
rootCmd.Version = build.Version
2222

23+
// --config is a persistent flag on the root command so every subcommand
24+
// inherits it. Lookup order is documented in internal/viper.go's
25+
// readConfFile: --config > OASDIFF_CONFIG env var > .oasdiff.* in cwd.
26+
rootCmd.PersistentFlags().String("config", "", "path to config file (overrides .oasdiff.* lookup; can also use the OASDIFF_CONFIG env var)")
27+
2328
rootCmd.AddCommand(
2429
getDiffCmd(),
2530
getSummaryCmd(),

internal/viper.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package internal
22

33
import (
44
"fmt"
5+
"os"
56
"strings"
67

78
"github.com/oasdiff/oasdiff/checker"
@@ -14,16 +15,21 @@ import (
1415
"slices"
1516
)
1617

18+
// EnvConfigPath is the environment variable that overrides the default
19+
// config-file lookup. Lower precedence than the --config flag.
20+
const EnvConfigPath = "OASDIFF_CONFIG"
21+
1722
type IViper interface {
1823
SetConfigName(in string)
24+
SetConfigFile(in string)
1925
AddConfigPath(in string)
2026
ReadInConfig() error
2127
BindPFlag(key string, flag *pflag.Flag) error
2228
UnmarshalExact(rawVal any, opts ...viper.DecoderConfigOption) error
2329
}
2430

2531
func RunViper(cmd *cobra.Command, v IViper) *ReturnError {
26-
if err := readConfFile(v); err != nil {
32+
if err := readConfFile(cmd, v); err != nil {
2733
return getErrConfigFileProblem(err)
2834
}
2935

@@ -38,10 +44,25 @@ func RunViper(cmd *cobra.Command, v IViper) *ReturnError {
3844
return nil
3945
}
4046

41-
func readConfFile(v IViper) error {
47+
// readConfFile loads the oasdiff configuration file. Resolution order:
48+
//
49+
// 1. --config <path> flag (when set, the file MUST exist; missing or malformed file is an error)
50+
// 2. OASDIFF_CONFIG environment variable (same semantics)
51+
// 3. Default cwd lookup for .oasdiff.{json,yaml,yml,toml,hcl} (silent when missing)
52+
//
53+
// Returns nil when no config is found via the default lookup; only the two
54+
// explicit-override paths surface "file not found" as an error.
55+
func readConfFile(cmd *cobra.Command, v IViper) error {
56+
if path := explicitConfigPath(cmd); path != "" {
57+
v.SetConfigFile(path)
58+
if err := v.ReadInConfig(); err != nil {
59+
return fmt.Errorf("read error: %s", err)
60+
}
61+
return nil
62+
}
4263

43-
// the config file should be named oasdiff.{json,yaml,yml,toml,hcl} in the directory where the command is run
44-
v.SetConfigName("oasdiff")
64+
// Default lookup: .oasdiff.{json,yaml,yml,toml,hcl} in cwd.
65+
v.SetConfigName(".oasdiff")
4566
v.AddConfigPath(".")
4667

4768
if err := v.ReadInConfig(); err != nil {
@@ -55,6 +76,20 @@ func readConfFile(v IViper) error {
5576
return nil
5677
}
5778

79+
// explicitConfigPath returns the explicit config path requested by the
80+
// caller via --config or OASDIFF_CONFIG. The flag wins when both are set.
81+
// Returns "" when neither is set (caller falls back to cwd lookup).
82+
func explicitConfigPath(cmd *cobra.Command) string {
83+
if cmd != nil {
84+
if flag := cmd.Flag("config"); flag != nil {
85+
if path := flag.Value.String(); path != "" {
86+
return path
87+
}
88+
}
89+
}
90+
return os.Getenv(EnvConfigPath)
91+
}
92+
5893
func bindFlags(cmd *cobra.Command, v IViper) error {
5994
var result error
6095
persitentFlags := cmd.PersistentFlags()

internal/viper_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package internal_test
22

33
import (
44
"errors"
5+
"os"
6+
"path/filepath"
57
"strings"
68
"testing"
79

@@ -164,3 +166,152 @@ func TestViper_InvalidFlag(t *testing.T) {
164166

165167
require.EqualError(t, internal.RunViper(&cmd, v), "failed to load config file: validation error: decoding failed due to the following error(s):\n\n'internal.Config' has invalid keys: invalid")
166168
}
169+
170+
// ---------------------------------------------------------------------
171+
// Config-file lookup: .oasdiff.* in cwd; --config flag; OASDIFF_CONFIG
172+
// env var; precedence flag > env > default.
173+
//
174+
// Each test uses t.Chdir(t.TempDir()) so it runs in an isolated cwd
175+
// without polluting the test working directory or interfering with
176+
// sibling tests.
177+
// ---------------------------------------------------------------------
178+
179+
// writeFile writes content to path, failing the test on error.
180+
func writeFile(t *testing.T, path, content string) {
181+
t.Helper()
182+
require.NoError(t, os.WriteFile(path, []byte(content), 0600))
183+
}
184+
185+
// chdirIsolated moves the test into a fresh temp dir for the duration
186+
// of the test. Returns the temp dir path so callers can construct
187+
// absolute paths to fixtures they create inside it.
188+
func chdirIsolated(t *testing.T) string {
189+
t.Helper()
190+
dir := t.TempDir()
191+
t.Chdir(dir)
192+
return dir
193+
}
194+
195+
// TestViper_DefaultLookup_DotOasdiffYaml: when .oasdiff.yaml exists in
196+
// cwd and no override is set, it is loaded. Asserted via a value that
197+
// validate() rejects — error message proves the file was read.
198+
func TestViper_DefaultLookup_DotOasdiffYaml(t *testing.T) {
199+
chdirIsolated(t)
200+
writeFile(t, ".oasdiff.yaml", "lang: invalid\n")
201+
202+
cmd := cobra.Command{}
203+
require.EqualError(t,
204+
internal.RunViper(&cmd, viper.New()),
205+
"failed to load config file: invalid lang \"invalid\", allowed values: en, ru, pt-br, es")
206+
}
207+
208+
// TestViper_DefaultLookup_LegacyOasdiffYamlIsIgnored: a legacy
209+
// oasdiff.yaml (no leading dot) at repo root is no longer recognized
210+
// by the default lookup. The CLI should silently skip it.
211+
func TestViper_DefaultLookup_LegacyOasdiffYamlIsIgnored(t *testing.T) {
212+
chdirIsolated(t)
213+
// Legacy filename — would have been picked up before the .oasdiff.*
214+
// move. Now it should be ignored entirely.
215+
writeFile(t, "oasdiff.yaml", "lang: invalid\n")
216+
217+
cmd := cobra.Command{}
218+
// No error: validate() never sees the bad value because the file
219+
// wasn't loaded.
220+
require.Nil(t, internal.RunViper(&cmd, viper.New()))
221+
}
222+
223+
// TestViper_DefaultLookup_NoConfigIsNotAnError: with no .oasdiff.*
224+
// in cwd and no override, RunViper succeeds.
225+
func TestViper_DefaultLookup_NoConfigIsNotAnError(t *testing.T) {
226+
chdirIsolated(t)
227+
228+
cmd := cobra.Command{}
229+
require.Nil(t, internal.RunViper(&cmd, viper.New()))
230+
}
231+
232+
// TestViper_ConfigFlag_ExplicitPath: --config <path> loads the file
233+
// at the given path, regardless of cwd's .oasdiff.*.
234+
func TestViper_ConfigFlag_ExplicitPath(t *testing.T) {
235+
dir := chdirIsolated(t)
236+
customPath := filepath.Join(dir, "custom-config.yaml")
237+
writeFile(t, customPath, "lang: invalid\n")
238+
239+
cmd := cobra.Command{}
240+
cmd.PersistentFlags().String("config", "", "")
241+
require.NoError(t, cmd.PersistentFlags().Set("config", customPath))
242+
243+
require.EqualError(t,
244+
internal.RunViper(&cmd, viper.New()),
245+
"failed to load config file: invalid lang \"invalid\", allowed values: en, ru, pt-br, es")
246+
}
247+
248+
// TestViper_ConfigFlag_MissingFileIsError: --config pointing at a
249+
// non-existent file is an explicit error (unlike the silent-skip
250+
// behavior of the default lookup).
251+
func TestViper_ConfigFlag_MissingFileIsError(t *testing.T) {
252+
chdirIsolated(t)
253+
254+
cmd := cobra.Command{}
255+
cmd.PersistentFlags().String("config", "", "")
256+
require.NoError(t, cmd.PersistentFlags().Set("config", "does-not-exist.yaml"))
257+
258+
err := internal.RunViper(&cmd, viper.New())
259+
require.NotNil(t, err)
260+
require.Contains(t, err.Error(), "read error")
261+
}
262+
263+
// TestViper_ConfigFlag_OverridesDefaultLookup: when --config is set,
264+
// the default cwd lookup is skipped — even if .oasdiff.yaml is also
265+
// present at cwd. Proves the flag's path is used, not the default.
266+
func TestViper_ConfigFlag_OverridesDefaultLookup(t *testing.T) {
267+
dir := chdirIsolated(t)
268+
// Default-lookup file with a value that WOULD fail validation if loaded.
269+
writeFile(t, ".oasdiff.yaml", "lang: invalid\n")
270+
// Explicit-path file that's clean.
271+
customPath := filepath.Join(dir, "clean.yaml")
272+
writeFile(t, customPath, "lang: en\n")
273+
274+
cmd := cobra.Command{}
275+
cmd.PersistentFlags().String("config", "", "")
276+
require.NoError(t, cmd.PersistentFlags().Set("config", customPath))
277+
278+
// No error: the explicit clean.yaml was loaded, not the bad .oasdiff.yaml.
279+
require.Nil(t, internal.RunViper(&cmd, viper.New()))
280+
}
281+
282+
// TestViper_ConfigEnvVar: OASDIFF_CONFIG points at a config file in
283+
// the absence of --config.
284+
func TestViper_ConfigEnvVar(t *testing.T) {
285+
dir := chdirIsolated(t)
286+
customPath := filepath.Join(dir, "env-config.yaml")
287+
writeFile(t, customPath, "lang: invalid\n")
288+
289+
t.Setenv("OASDIFF_CONFIG", customPath)
290+
291+
cmd := cobra.Command{}
292+
require.EqualError(t,
293+
internal.RunViper(&cmd, viper.New()),
294+
"failed to load config file: invalid lang \"invalid\", allowed values: en, ru, pt-br, es")
295+
}
296+
297+
// TestViper_ConfigFlagWinsOverEnv: when both --config and
298+
// OASDIFF_CONFIG are set, the flag takes precedence.
299+
func TestViper_ConfigFlagWinsOverEnv(t *testing.T) {
300+
dir := chdirIsolated(t)
301+
// Env points at a clean file; flag points at a bad one. If env
302+
// were honored, no error. If flag wins, validation fails.
303+
envPath := filepath.Join(dir, "env-clean.yaml")
304+
writeFile(t, envPath, "lang: en\n")
305+
flagPath := filepath.Join(dir, "flag-bad.yaml")
306+
writeFile(t, flagPath, "lang: invalid\n")
307+
308+
t.Setenv("OASDIFF_CONFIG", envPath)
309+
310+
cmd := cobra.Command{}
311+
cmd.PersistentFlags().String("config", "", "")
312+
require.NoError(t, cmd.PersistentFlags().Set("config", flagPath))
313+
314+
require.EqualError(t,
315+
internal.RunViper(&cmd, viper.New()),
316+
"failed to load config file: invalid lang \"invalid\", allowed values: en, ru, pt-br, es")
317+
}

0 commit comments

Comments
 (0)