Skip to content

Commit f43b649

Browse files
fix(config): resolve relative paths in config files against config dir, not cwd
Closes #900. Path-valued config keys (err-ignore, warn-ignore, severity-levels, template) referenced from a config file are now resolved relative to the config file's directory, not the process's current working directory. Matches the convention established by ESLint, Prettier, golangci-lint, and similar tools — a config file is self-contained; relative paths in it refer to siblings of the config, not arbitrary files at cwd. Without this fix, --config <subdir>/.oasdiff.yaml silently fails for any path-valued flag because the CLI looks for those files at cwd instead of next to the config. Reproduced in #900. Implementation in internal/viper.go::resolveConfigRelativePaths, called after CLI flags are bound. Per key, the rewrite is skipped if: - The CLI flag was explicitly set by the user (their typed path means what they typed, relative to cwd). - The value is empty or already absolute. Skipped overall when no config file was loaded. Backward compatibility: strict. Existing setups continue to work identically: - Default-cwd-lookup users (.oasdiff.yaml or oasdiff.yaml in cwd): configDir == cwd, so relative paths resolve to <abs cwd>/<file>. Functionally identical to the previous cwd-relative behaviour. - Config-with-absolute-paths users: absolute paths are skipped by the rewrite, behaviour unchanged. - --config <path> users with relative paths in the config file: this case was broken before (the bug from #900) and is now fixed. The only "regression" surface is the theoretical user who relied on --config + cwd-relative-not-config-relative paths, which is a feature that's a few hours old and can't have meaningful adoption. Tests: 6 new cases in internal/viper_test.go cover relative path resolution against config dir, absolute paths unchanged, CLI flag override preserved, all four path keys handled, default lookup in cwd unchanged, and explicit back-compat for the legacy oasdiff.yaml filename in cwd. Plus a `template` field added to the Config struct so validate() recognizes the key. CONFIG-FILES.md documents the new resolution semantics with the convention citation.
1 parent 3dab475 commit f43b649

3 files changed

Lines changed: 212 additions & 0 deletions

File tree

docs/CONFIG-FILES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,6 @@ Notes:
3939
- `err-ignore`: configuration file for ignoring errors
4040
- `severity-levels`: configuration file for custom severity levels
4141
- `warn-ignore`: configuration file for ignoring warnings
42+
- `template`: custom Go template file for changelog generation
43+
44+
**Relative paths in these flags are resolved against the config file's directory**, not the process's current working directory. So when you write `err-ignore: rules.txt` in `path/to/.oasdiff.yaml`, oasdiff reads `path/to/rules.txt`. Absolute paths and paths set via CLI flag are not rewritten.

internal/viper.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package internal
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"strings"
78

89
"github.com/oasdiff/oasdiff/checker"
@@ -19,11 +20,25 @@ import (
1920
// config-file lookup. Lower precedence than the --config flag.
2021
const EnvConfigPath = "OASDIFF_CONFIG"
2122

23+
// configRelativePathKeys lists viper keys whose values are file paths.
24+
// When read from a config file, relative values in these keys are
25+
// resolved against the config file's own directory. Absolute values
26+
// and values explicitly set via CLI flag are left alone.
27+
var configRelativePathKeys = []string{
28+
"err-ignore",
29+
"warn-ignore",
30+
"severity-levels",
31+
"template",
32+
}
33+
2234
type IViper interface {
2335
SetConfigName(in string)
2436
SetConfigFile(in string)
2537
AddConfigPath(in string)
2638
ReadInConfig() error
39+
ConfigFileUsed() string
40+
GetString(key string) string
41+
Set(key string, value any)
2742
BindPFlag(key string, flag *pflag.Flag) error
2843
UnmarshalExact(rawVal any, opts ...viper.DecoderConfigOption) error
2944
}
@@ -41,9 +56,48 @@ func RunViper(cmd *cobra.Command, v IViper) *ReturnError {
4156
return getErrConfigFileProblem(err)
4257
}
4358

59+
// After CLI flags are bound, rewrite path-valued config-file values
60+
// to be relative to the config file's directory (vs. cwd). Skips
61+
// values where the user explicitly set the corresponding CLI flag —
62+
// those mean what the user typed.
63+
resolveConfigRelativePaths(cmd, v)
64+
4465
return nil
4566
}
4667

68+
// resolveConfigRelativePaths walks the documented path-valued config
69+
// keys and rewrites relative values to be anchored at the config file's
70+
// directory. A config file is self-contained — relative paths in it
71+
// refer to siblings of the config, not arbitrary files at the process's
72+
// cwd.
73+
//
74+
// Skipped per key:
75+
// - The corresponding CLI flag was explicitly set by the user
76+
// (Changed=true). Their typed path means what they typed.
77+
// - The value is empty, or already absolute.
78+
//
79+
// Skipped overall when no config file was loaded.
80+
func resolveConfigRelativePaths(cmd *cobra.Command, v IViper) {
81+
configFile := v.ConfigFileUsed()
82+
if configFile == "" {
83+
return
84+
}
85+
configDir := filepath.Dir(configFile)
86+
87+
for _, key := range configRelativePathKeys {
88+
if cmd != nil {
89+
if flag := cmd.Flag(key); flag != nil && flag.Changed {
90+
continue
91+
}
92+
}
93+
val := v.GetString(key)
94+
if val == "" || filepath.IsAbs(val) {
95+
continue
96+
}
97+
v.Set(key, filepath.Join(configDir, val))
98+
}
99+
}
100+
47101
// readConfFile loads the oasdiff configuration file. Resolution order:
48102
//
49103
// 1. --config <path> flag (when set, the file MUST exist; missing or malformed file is an error)
@@ -152,6 +206,7 @@ type Config struct {
152206
StripPrefixRevision string `mapstructure:"strip-prefix-revision"`
153207
IncludePathParams bool `mapstructure:"include-path-params"`
154208
AllowExternalRefs bool `mapstructure:"allow-external-refs"`
209+
Template string `mapstructure:"template"`
155210
}
156211

157212
// validate checks that each of the provided configuration values is one of the generally accepted values

internal/viper_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,160 @@ func TestViper_ConfigEnvVar(t *testing.T) {
308308
"failed to load config file: invalid lang \"invalid\", allowed values: en, ru, pt-br, es")
309309
}
310310

311+
// ---------------------------------------------------------------------
312+
// Relative-path resolution: path-valued config keys (err-ignore,
313+
// warn-ignore, severity-levels, template) should resolve against the
314+
// config file's directory, not the process's cwd. Matches behaviour
315+
// of ESLint, Prettier, golangci-lint, etc.
316+
// ---------------------------------------------------------------------
317+
318+
// TestViper_RelativePath_ResolvesAgainstConfigDir: when the config
319+
// file lives in a subdirectory and references a sibling file via a
320+
// relative path, the path is rewritten to be anchored at the config's
321+
// directory.
322+
func TestViper_RelativePath_ResolvesAgainstConfigDir(t *testing.T) {
323+
root := chdirIsolated(t)
324+
configDir := filepath.Join(root, "subdir")
325+
require.NoError(t, os.Mkdir(configDir, 0700))
326+
327+
configPath := filepath.Join(configDir, "cfg.yaml")
328+
writeFile(t, configPath, "err-ignore: ignore-rules.txt\n")
329+
// Sibling file next to the config — what the relative path should
330+
// resolve to.
331+
ignorePath := filepath.Join(configDir, "ignore-rules.txt")
332+
writeFile(t, ignorePath, "")
333+
334+
cmd := cobra.Command{}
335+
cmd.PersistentFlags().String("config", "", "")
336+
cmd.PersistentFlags().String("err-ignore", "", "")
337+
require.NoError(t, cmd.PersistentFlags().Set("config", configPath))
338+
339+
v := viper.New()
340+
require.Nil(t, internal.RunViper(&cmd, v))
341+
require.Equal(t, ignorePath, v.GetString("err-ignore"))
342+
}
343+
344+
// TestViper_RelativePath_AbsoluteValueUnchanged: an absolute path in
345+
// the config file is left alone, regardless of where the config lives.
346+
func TestViper_RelativePath_AbsoluteValueUnchanged(t *testing.T) {
347+
root := chdirIsolated(t)
348+
configDir := filepath.Join(root, "subdir")
349+
require.NoError(t, os.Mkdir(configDir, 0700))
350+
351+
absoluteIgnore := filepath.Join(root, "elsewhere.txt")
352+
writeFile(t, absoluteIgnore, "")
353+
354+
configPath := filepath.Join(configDir, "cfg.yaml")
355+
writeFile(t, configPath, "err-ignore: "+absoluteIgnore+"\n")
356+
357+
cmd := cobra.Command{}
358+
cmd.PersistentFlags().String("config", "", "")
359+
cmd.PersistentFlags().String("err-ignore", "", "")
360+
require.NoError(t, cmd.PersistentFlags().Set("config", configPath))
361+
362+
v := viper.New()
363+
require.Nil(t, internal.RunViper(&cmd, v))
364+
require.Equal(t, absoluteIgnore, v.GetString("err-ignore"))
365+
}
366+
367+
// TestViper_RelativePath_CLIFlagOverridesConfig: when the CLI flag
368+
// is explicitly set by the user, its value wins over the config and
369+
// is NOT rewritten — the user's path means what they typed.
370+
func TestViper_RelativePath_CLIFlagOverridesConfig(t *testing.T) {
371+
root := chdirIsolated(t)
372+
configDir := filepath.Join(root, "subdir")
373+
require.NoError(t, os.Mkdir(configDir, 0700))
374+
375+
configPath := filepath.Join(configDir, "cfg.yaml")
376+
writeFile(t, configPath, "err-ignore: from-config.txt\n")
377+
378+
cmd := cobra.Command{}
379+
cmd.PersistentFlags().String("config", "", "")
380+
cmd.PersistentFlags().String("err-ignore", "", "")
381+
require.NoError(t, cmd.PersistentFlags().Set("config", configPath))
382+
// User explicitly passes --err-ignore; this should win and stay
383+
// as typed (not rewritten relative to configDir).
384+
require.NoError(t, cmd.PersistentFlags().Set("err-ignore", "from-cli.txt"))
385+
386+
v := viper.New()
387+
require.Nil(t, internal.RunViper(&cmd, v))
388+
require.Equal(t, "from-cli.txt", v.GetString("err-ignore"))
389+
}
390+
391+
// TestViper_RelativePath_AllPathKeysHandled: each documented path-valued
392+
// config key gets the same rewrite treatment.
393+
func TestViper_RelativePath_AllPathKeysHandled(t *testing.T) {
394+
root := chdirIsolated(t)
395+
configDir := filepath.Join(root, "subdir")
396+
require.NoError(t, os.Mkdir(configDir, 0700))
397+
398+
// One YAML setting all four path-valued keys with relative values.
399+
configPath := filepath.Join(configDir, "cfg.yaml")
400+
writeFile(t, configPath, `
401+
err-ignore: err.txt
402+
warn-ignore: warn.txt
403+
severity-levels: sev.txt
404+
template: tmpl.tmpl
405+
`)
406+
407+
cmd := cobra.Command{}
408+
cmd.PersistentFlags().String("config", "", "")
409+
for _, k := range []string{"err-ignore", "warn-ignore", "severity-levels", "template"} {
410+
cmd.PersistentFlags().String(k, "", "")
411+
}
412+
require.NoError(t, cmd.PersistentFlags().Set("config", configPath))
413+
414+
v := viper.New()
415+
require.Nil(t, internal.RunViper(&cmd, v))
416+
417+
for k, expected := range map[string]string{
418+
"err-ignore": filepath.Join(configDir, "err.txt"),
419+
"warn-ignore": filepath.Join(configDir, "warn.txt"),
420+
"severity-levels": filepath.Join(configDir, "sev.txt"),
421+
"template": filepath.Join(configDir, "tmpl.tmpl"),
422+
} {
423+
require.Equal(t, expected, v.GetString(k), "key %q", k)
424+
}
425+
}
426+
427+
// TestViper_RelativePath_DefaultLookup_ConfigInCwd: when the config
428+
// file is loaded from cwd via the default lookup (.oasdiff.yaml),
429+
// relative paths still work — they get rewritten to absolute paths
430+
// anchored at cwd, which is functionally equivalent to the
431+
// pre-existing cwd-relative behaviour. No behaviour change for the
432+
// default case.
433+
func TestViper_RelativePath_DefaultLookup_ConfigInCwd(t *testing.T) {
434+
root := chdirIsolated(t)
435+
writeFile(t, ".oasdiff.yaml", "err-ignore: ignore-rules.txt\n")
436+
437+
cmd := cobra.Command{}
438+
cmd.PersistentFlags().String("err-ignore", "", "")
439+
440+
v := viper.New()
441+
require.Nil(t, internal.RunViper(&cmd, v))
442+
// Path is rewritten to an absolute path under cwd. Functionally
443+
// equivalent to "ignore-rules.txt" relative to cwd.
444+
require.Equal(t, filepath.Join(root, "ignore-rules.txt"), v.GetString("err-ignore"))
445+
}
446+
447+
// TestViper_RelativePath_BackCompat_LegacyOasdiffYamlInCwd: explicitly
448+
// covers the population this fix could conceivably regress —
449+
// long-standing CLI users with the legacy `oasdiff.yaml` filename in
450+
// cwd referencing a sibling file via a relative path. They were
451+
// reading <cwd>/<file>; after the fix, the path is rewritten to
452+
// <abs cwd>/<file>. Same file, same behaviour.
453+
func TestViper_RelativePath_BackCompat_LegacyOasdiffYamlInCwd(t *testing.T) {
454+
root := chdirIsolated(t)
455+
writeFile(t, "oasdiff.yaml", "err-ignore: my-rules.txt\n")
456+
457+
cmd := cobra.Command{}
458+
cmd.PersistentFlags().String("err-ignore", "", "")
459+
460+
v := viper.New()
461+
require.Nil(t, internal.RunViper(&cmd, v))
462+
require.Equal(t, filepath.Join(root, "my-rules.txt"), v.GetString("err-ignore"))
463+
}
464+
311465
// TestViper_ConfigFlagWinsOverEnv: when both --config and
312466
// OASDIFF_CONFIG are set, the flag takes precedence.
313467
func TestViper_ConfigFlagWinsOverEnv(t *testing.T) {

0 commit comments

Comments
 (0)