Skip to content

Commit 63dfc3d

Browse files
committed
fix(config): [global] and [docker] unknown-key warnings carry "did you mean?"
Pre-fix, the unknown-key warning was asymmetric across INI section types: job sections (job-exec, job-run, etc.) printed '(did you mean "..."?)' via findClosestMatch, but [global] and [docker] printed bare '(typo?)' with no suggestion — leaving the operator to read the line and guess. Surfaced in PR #677's parallel-reviewer pass: the new webhook-default-preset global key is easy to mistype (webhook-defauls-preset swaps t/s) and the typo logged a [global] warning with no hint at the right spelling. Fix extracts a logSectionUnknownKeyWarnings helper that mirrors logJobUnknownKeyWarnings's filename/suggestion matrix and call it from both [global] and [docker] paths in logUnknownKeyWarnings (file-based) and BuildFromString (string-based). Known keys come from extractMapstructureKeys(Config{}.Global) and extractMapstructureKeys(DockerConfig{}) via two thin helpers (globalKnownKeys / dockerKnownKeys) so the suggestion list cannot drift from the actual decoded struct. Three new tests: - TestGlobalSectionUnknownKeyWarning_DidYouMean: the issue's exact example — webhook-defauls-preset suggests webhook-default-preset. - TestGlobalSectionUnknownKeyWarning_NoSuggestion: locks the fallback to '(typo?)' when no close match exists. - TestDockerSectionUnknownKeyWarning_DidYouMean: same parity assertion for the [docker] path so a future code split can't silently drop suggestions on one half. Closes #678. Tracks against #621 (global-keys drift detection family). Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
1 parent 33d4f93 commit 63dfc3d

2 files changed

Lines changed: 126 additions & 15 deletions

File tree

cli/config.go

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -241,19 +241,50 @@ func logUnknownKeyWarnings(logger *slog.Logger, filename string, res *parseResul
241241
return
242242
}
243243

244-
for _, key := range res.unknownGlobal {
245-
logger.Warn(fmt.Sprintf("Unknown configuration key '%s' in [global] section (typo?)", key),
246-
"key", key, "file", filename)
247-
}
248-
for _, key := range res.unknownDocker {
249-
logger.Warn(fmt.Sprintf("Unknown configuration key '%s' in [docker] section (typo?)", key),
250-
"key", key, "file", filename)
251-
}
244+
logSectionUnknownKeyWarnings(logger, "global", res.unknownGlobal, globalKnownKeys(), filename)
245+
logSectionUnknownKeyWarnings(logger, "docker", res.unknownDocker, dockerKnownKeys(), filename)
252246

253247
// Log warnings for unknown keys in job sections
254248
logJobUnknownKeyWarnings(logger, res.unknownJobs, filename)
255249
}
256250

251+
// logSectionUnknownKeyWarnings emits a "Unknown configuration key … in [section]"
252+
// warning for each key, with a "did you mean?" suggestion when a close match is
253+
// found in knownKeys. Used by [global] and [docker] sections so the suggestion
254+
// behavior is at parity with the job-section path (issue #678).
255+
func logSectionUnknownKeyWarnings(logger *slog.Logger, section string, unknownKeys, knownKeys []string, filename string) {
256+
for _, key := range unknownKeys {
257+
suggestion := findClosestMatch(key, knownKeys)
258+
var msg string
259+
switch {
260+
case suggestion != "" && filename != "":
261+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section of %s (did you mean '%s'?)",
262+
key, section, filename, suggestion)
263+
case suggestion != "":
264+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section (did you mean '%s'?)", key, section, suggestion)
265+
case filename != "":
266+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section of %s (typo?)", key, section, filename)
267+
default:
268+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section (typo?)", key, section)
269+
}
270+
logger.Warn(msg, "key", key, "file", filename)
271+
}
272+
}
273+
274+
// globalKnownKeys returns the list of valid mapstructure keys for the [global]
275+
// INI section. Derived from Config{}.Global so the suggestion list cannot
276+
// drift from the actual decoded struct.
277+
func globalKnownKeys() []string {
278+
return extractMapstructureKeys(Config{}.Global)
279+
}
280+
281+
// dockerKnownKeys returns the list of valid mapstructure keys for the [docker]
282+
// INI section. Derived from DockerConfig{} so the suggestion list cannot drift
283+
// from the actual decoded struct.
284+
func dockerKnownKeys() []string {
285+
return extractMapstructureKeys(DockerConfig{})
286+
}
287+
257288
// logJobUnknownKeyWarnings logs warnings for unknown keys in job sections with
258289
// "did you mean?" suggestions. If filename is non-empty, it is included in the message.
259290
func logJobUnknownKeyWarnings(logger *slog.Logger, unknownJobs []jobUnknownKeys, filename string) {
@@ -322,13 +353,9 @@ func BuildFromString(configStr string, logger *slog.Logger) (*Config, error) {
322353
if parseRes != nil {
323354
usedKeys = parseRes.usedKeys
324355

325-
// Log warnings for unknown keys
326-
for _, key := range parseRes.unknownGlobal {
327-
logger.Warn(fmt.Sprintf("Unknown configuration key '%s' in [global] section (typo?)", key))
328-
}
329-
for _, key := range parseRes.unknownDocker {
330-
logger.Warn(fmt.Sprintf("Unknown configuration key '%s' in [docker] section (typo?)", key))
331-
}
356+
// Log warnings for unknown keys (empty filename for string-based config)
357+
logSectionUnknownKeyWarnings(logger, "global", parseRes.unknownGlobal, globalKnownKeys(), "")
358+
logSectionUnknownKeyWarnings(logger, "docker", parseRes.unknownDocker, dockerKnownKeys(), "")
332359

333360
// Log warnings for unknown keys in job sections (empty filename for string-based config)
334361
logJobUnknownKeyWarnings(logger, parseRes.unknownJobs, "")

cli/config_decode_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,90 @@ typo2 = value2
470470
"Should have warning for job2")
471471
}
472472

473+
// TestGlobalSectionUnknownKeyWarning_DidYouMean pins the parity fix from
474+
// issue #678: a typo on any [global] mapstructure-tagged key should produce
475+
// a "did you mean?" line citing the nearest match within Levenshtein
476+
// threshold. Pre-fix, only job-section warnings carried the suggestion;
477+
// [global] just emitted "(typo?)" and left the operator to guess.
478+
func TestGlobalSectionUnknownKeyWarning_DidYouMean(t *testing.T) {
479+
t.Parallel()
480+
481+
// "webhook-defauls-preset" swaps t/s on "webhook-default-preset" — the
482+
// exact mistype called out in the issue body.
483+
//nolint:misspell // intentional typo for did-you-mean assertion
484+
configStr := `
485+
[global]
486+
webhook-defauls-preset = json-post
487+
`
488+
489+
logger, handler := test.NewTestLoggerWithHandler()
490+
_, err := BuildFromString(configStr, logger)
491+
require.NoError(t, err)
492+
493+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
494+
//nolint:misspell // intentional typo for did-you-mean assertion
495+
assert.True(t, handler.HasWarning("Unknown configuration key 'webhook-defauls-preset'"),
496+
"Should warn about 'webhook-defauls-preset'")
497+
assert.True(t, handler.HasWarning("[global] section"),
498+
"Should name the [global] section")
499+
//nolint:misspell // intentional typo for did-you-mean assertion
500+
assert.True(t, handler.HasWarning("did you mean 'webhook-default-preset'"),
501+
"Should suggest 'webhook-default-preset' for 'webhook-defauls-preset'")
502+
}
503+
504+
// TestGlobalSectionUnknownKeyWarning_NoSuggestion pins that the [global]
505+
// path still falls back to "(typo?)" — same shape as the existing job-section
506+
// no-suggestion test — when no close match exists. Asymmetry against the
507+
// suggestion case is what makes the parity fix meaningful.
508+
func TestGlobalSectionUnknownKeyWarning_NoSuggestion(t *testing.T) {
509+
t.Parallel()
510+
511+
configStr := `
512+
[global]
513+
zzz-totally-unrelated-key = value
514+
`
515+
516+
logger, handler := test.NewTestLoggerWithHandler()
517+
_, err := BuildFromString(configStr, logger)
518+
require.NoError(t, err)
519+
520+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
521+
assert.True(t, handler.HasWarning("Unknown configuration key 'zzz-totally-unrelated-key'"),
522+
"Should warn about 'zzz-totally-unrelated-key'")
523+
assert.True(t, handler.HasWarning("[global] section"),
524+
"Should name the [global] section")
525+
assert.True(t, handler.HasWarning("typo?"),
526+
"Should fall back to '(typo?)' when no close match found")
527+
assert.False(t, handler.HasWarning("did you mean"),
528+
"Should not suggest when no close match")
529+
}
530+
531+
// TestDockerSectionUnknownKeyWarning_DidYouMean confirms the same parity fix
532+
// also applies to the [docker] section path (#678). The two sections share
533+
// the same code path, but explicitly asserting both prevents a future split
534+
// from silently dropping suggestions on one half.
535+
func TestDockerSectionUnknownKeyWarning_DidYouMean(t *testing.T) {
536+
t.Parallel()
537+
538+
// "inculde-stopped" swaps u/n on "include-stopped".
539+
configStr := `
540+
[docker]
541+
inculde-stopped = true
542+
`
543+
544+
logger, handler := test.NewTestLoggerWithHandler()
545+
_, err := BuildFromString(configStr, logger)
546+
require.NoError(t, err)
547+
548+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
549+
assert.True(t, handler.HasWarning("Unknown configuration key 'inculde-stopped'"),
550+
"Should warn about 'inculde-stopped'")
551+
assert.True(t, handler.HasWarning("[docker] section"),
552+
"Should name the [docker] section")
553+
assert.True(t, handler.HasWarning("did you mean 'include-stopped'"),
554+
"Should suggest 'include-stopped' for 'inculde-stopped'")
555+
}
556+
473557
// Phase 8: Additional coverage tests for config_decode.go
474558

475559
func TestWeakDecodeConsistent_NilInput(t *testing.T) {

0 commit comments

Comments
 (0)