Skip to content

Commit 91c48cc

Browse files
authored
fix(config): [global] and [docker] unknown-key warnings carry 'did you mean?' (#678) (#695)
## Summary Brings the \`[global]\` and \`[docker]\` unknown-key warnings into parity with job sections so operator typos now suggest the nearest mapstructure key. Pre-fix: \`\`\` WARN Unknown configuration key 'webhook-defauls-preset' in [global] section (typo?) \`\`\` Post-fix: \`\`\` WARN Unknown configuration key 'webhook-defauls-preset' in [global] section (did you mean 'webhook-default-preset'?) \`\`\` Closes [#678](#678). ## Approach Extracted a \`logSectionUnknownKeyWarnings(logger, section, unknownKeys, knownKeys, filename)\` helper that mirrors the filename / suggestion matrix already used by \`logJobUnknownKeyWarnings\`, then routed both \`[global]\` and \`[docker]\` paths through it from both call sites (file-based \`logUnknownKeyWarnings\` and string-based \`BuildFromString\`). Known keys come from \`extractMapstructureKeys(Config{}.Global)\` and \`extractMapstructureKeys(DockerConfig{})\` via thin \`globalKnownKeys\` / \`dockerKnownKeys\` helpers, so the suggestion list cannot drift from the actual decoded struct — the same drift-resistance pattern as \`getKnownKeysForJobType\`. ## Tests Three new tests in \`cli/config_decode_test.go\`: 1. **\`TestGlobalSectionUnknownKeyWarning_DidYouMean\`** — the exact mistype called out in the issue body (\`webhook-defauls-preset\` → \`webhook-default-preset\`). 2. **\`TestGlobalSectionUnknownKeyWarning_NoSuggestion\`** — locks the fallback to \`(typo?)\` when no close match exists (asymmetry against the suggestion case is what makes the parity fix meaningful). 3. **\`TestDockerSectionUnknownKeyWarning_DidYouMean\`** — same parity assertion for the \`[docker]\` path so a future code split can't silently drop suggestions on one half. ## Test plan - [x] \`go test ./...\` passes (full repo, 14 packages, ~58s) - [x] \`golangci-lint run\` clean (intentional typos in test carry \`//nolint:misspell\`) - [x] \`go vet ./...\` clean - [ ] CI green ## References - Surfaced in: [#677](#677) (parallel-reviewer pass) - Tracks against: [#621](#621) (global-keys drift detection family)
2 parents 71b49e7 + 675db1d commit 91c48cc

2 files changed

Lines changed: 203 additions & 11 deletions

File tree

cli/config.go

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,19 +241,61 @@ 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)
244+
if len(res.unknownGlobal) > 0 {
245+
logSectionUnknownKeyWarnings(logger, "global", res.unknownGlobal, globalKnownKeys(), filename)
247246
}
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)
247+
if len(res.unknownDocker) > 0 {
248+
logSectionUnknownKeyWarnings(logger, "docker", res.unknownDocker, dockerKnownKeys(), filename)
251249
}
252250

253251
// Log warnings for unknown keys in job sections
254252
logJobUnknownKeyWarnings(logger, res.unknownJobs, filename)
255253
}
256254

255+
// logSectionUnknownKeyWarnings emits a "Unknown configuration key … in [section]"
256+
// warning for each key, with a "did you mean?" suggestion when a close match is
257+
// found in knownKeys. Used by [global] and [docker] sections so the suggestion
258+
// behavior is at parity with the job-section path (issue #678).
259+
func logSectionUnknownKeyWarnings(logger *slog.Logger, section string, unknownKeys, knownKeys []string, filename string) {
260+
for _, key := range unknownKeys {
261+
suggestion := findClosestMatch(key, knownKeys)
262+
var msg string
263+
switch {
264+
case suggestion != "" && filename != "":
265+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section of %s (did you mean '%s'?)",
266+
key, section, filename, suggestion)
267+
case suggestion != "":
268+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section (did you mean '%s'?)", key, section, suggestion)
269+
case filename != "":
270+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section of %s (typo?)", key, section, filename)
271+
default:
272+
msg = fmt.Sprintf("Unknown configuration key '%s' in [%s] section (typo?)", key, section)
273+
}
274+
// Drop the "file" structured attr for string-based configs (filename
275+
// is empty) so JSON logs don't carry a noisy file="" field for the
276+
// BuildFromString path.
277+
if filename != "" {
278+
logger.Warn(msg, "key", key, "file", filename)
279+
} else {
280+
logger.Warn(msg, "key", key)
281+
}
282+
}
283+
}
284+
285+
// globalKnownKeys returns the list of valid mapstructure keys for the [global]
286+
// INI section. Derived from Config{}.Global so the suggestion list cannot
287+
// drift from the actual decoded struct.
288+
func globalKnownKeys() []string {
289+
return extractMapstructureKeys(Config{}.Global)
290+
}
291+
292+
// dockerKnownKeys returns the list of valid mapstructure keys for the [docker]
293+
// INI section. Derived from DockerConfig{} so the suggestion list cannot drift
294+
// from the actual decoded struct.
295+
func dockerKnownKeys() []string {
296+
return extractMapstructureKeys(DockerConfig{})
297+
}
298+
257299
// logJobUnknownKeyWarnings logs warnings for unknown keys in job sections with
258300
// "did you mean?" suggestions. If filename is non-empty, it is included in the message.
259301
func logJobUnknownKeyWarnings(logger *slog.Logger, unknownJobs []jobUnknownKeys, filename string) {
@@ -322,12 +364,12 @@ func BuildFromString(configStr string, logger *slog.Logger) (*Config, error) {
322364
if parseRes != nil {
323365
usedKeys = parseRes.usedKeys
324366

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))
367+
// Log warnings for unknown keys (empty filename for string-based config)
368+
if len(parseRes.unknownGlobal) > 0 {
369+
logSectionUnknownKeyWarnings(logger, "global", parseRes.unknownGlobal, globalKnownKeys(), "")
328370
}
329-
for _, key := range parseRes.unknownDocker {
330-
logger.Warn(fmt.Sprintf("Unknown configuration key '%s' in [docker] section (typo?)", key))
371+
if len(parseRes.unknownDocker) > 0 {
372+
logSectionUnknownKeyWarnings(logger, "docker", parseRes.unknownDocker, dockerKnownKeys(), "")
331373
}
332374

333375
// Log warnings for unknown keys in job sections (empty filename for string-based config)

cli/config_decode_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package cli
55

66
import (
7+
"os"
8+
"path/filepath"
79
"testing"
810
"time"
911

@@ -470,6 +472,154 @@ typo2 = value2
470472
"Should have warning for job2")
471473
}
472474

475+
// TestGlobalSectionUnknownKeyWarning_DidYouMean pins the parity fix from
476+
// issue #678: a typo on any [global] mapstructure-tagged key should produce
477+
// a "did you mean?" line citing the nearest match within Levenshtein
478+
// threshold. Pre-fix, only job-section warnings carried the suggestion;
479+
// [global] just emitted "(typo?)" and left the operator to guess.
480+
func TestGlobalSectionUnknownKeyWarning_DidYouMean(t *testing.T) {
481+
t.Parallel()
482+
483+
// "webhook-defauls-preset" swaps t/s on "webhook-default-preset" — the
484+
// exact mistype called out in the issue body.
485+
//nolint:misspell // intentional typo for did-you-mean assertion
486+
configStr := `
487+
[global]
488+
webhook-defauls-preset = json-post
489+
`
490+
491+
logger, handler := test.NewTestLoggerWithHandler()
492+
_, err := BuildFromString(configStr, logger)
493+
require.NoError(t, err)
494+
495+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
496+
//nolint:misspell // intentional typo for did-you-mean assertion
497+
assert.True(t, handler.HasWarning("Unknown configuration key 'webhook-defauls-preset'"),
498+
"Should warn about 'webhook-defauls-preset'")
499+
assert.True(t, handler.HasWarning("[global] section"),
500+
"Should name the [global] section")
501+
//nolint:misspell // intentional typo for did-you-mean assertion
502+
assert.True(t, handler.HasWarning("did you mean 'webhook-default-preset'"),
503+
"Should suggest 'webhook-default-preset' for 'webhook-defauls-preset'")
504+
}
505+
506+
// TestGlobalSectionUnknownKeyWarning_NoSuggestion pins that the [global]
507+
// path still falls back to "(typo?)" — same shape as the existing job-section
508+
// no-suggestion test — when no close match exists. Asymmetry against the
509+
// suggestion case is what makes the parity fix meaningful.
510+
func TestGlobalSectionUnknownKeyWarning_NoSuggestion(t *testing.T) {
511+
t.Parallel()
512+
513+
configStr := `
514+
[global]
515+
zzz-totally-unrelated-key = value
516+
`
517+
518+
logger, handler := test.NewTestLoggerWithHandler()
519+
_, err := BuildFromString(configStr, logger)
520+
require.NoError(t, err)
521+
522+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
523+
assert.True(t, handler.HasWarning("Unknown configuration key 'zzz-totally-unrelated-key'"),
524+
"Should warn about 'zzz-totally-unrelated-key'")
525+
assert.True(t, handler.HasWarning("[global] section"),
526+
"Should name the [global] section")
527+
assert.True(t, handler.HasWarning("typo?"),
528+
"Should fall back to '(typo?)' when no close match found")
529+
assert.False(t, handler.HasWarning("did you mean"),
530+
"Should not suggest when no close match")
531+
}
532+
533+
// TestDockerSectionUnknownKeyWarning_DidYouMean confirms the same parity fix
534+
// also applies to the [docker] section path (#678). The two sections share
535+
// the same code path, but explicitly asserting both prevents a future split
536+
// from silently dropping suggestions on one half. Also pins cross-section
537+
// isolation: a [docker] typo must NOT be misattributed to [global].
538+
func TestDockerSectionUnknownKeyWarning_DidYouMean(t *testing.T) {
539+
t.Parallel()
540+
541+
// "inculde-stopped" swaps u/n on "include-stopped".
542+
configStr := `
543+
[docker]
544+
inculde-stopped = true
545+
`
546+
547+
logger, handler := test.NewTestLoggerWithHandler()
548+
_, err := BuildFromString(configStr, logger)
549+
require.NoError(t, err)
550+
551+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
552+
assert.True(t, handler.HasWarning("Unknown configuration key 'inculde-stopped'"),
553+
"Should warn about 'inculde-stopped'")
554+
assert.True(t, handler.HasWarning("[docker] section"),
555+
"Should name the [docker] section")
556+
assert.False(t, handler.HasWarning("[global] section"),
557+
"Should NOT misattribute a [docker] typo to the [global] section")
558+
assert.True(t, handler.HasWarning("did you mean 'include-stopped'"),
559+
"Should suggest 'include-stopped' for 'inculde-stopped'")
560+
}
561+
562+
// TestGlobalSectionUnknownKeyWarning_NonSquashedKey complements the
563+
// _DidYouMean test (which targets `webhook-default-preset` inside the
564+
// squashed WebhookGlobalConfig) by exercising a key declared DIRECTLY on
565+
// the anonymous Global struct. Together they lock both halves of
566+
// extractMapstructureKeysFromType: squash recursion AND direct-field
567+
// enumeration. Without this, a refactor that breaks one path but not the
568+
// other could pass the existing tests.
569+
func TestGlobalSectionUnknownKeyWarning_NonSquashedKey(t *testing.T) {
570+
t.Parallel()
571+
572+
// "enabel-pprof" swaps the el on "enable-pprof", a key declared
573+
// directly on Config{}.Global (not via squash).
574+
configStr := `
575+
[global]
576+
enabel-pprof = true
577+
`
578+
579+
logger, handler := test.NewTestLoggerWithHandler()
580+
_, err := BuildFromString(configStr, logger)
581+
require.NoError(t, err)
582+
583+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
584+
assert.True(t, handler.HasWarning("Unknown configuration key 'enabel-pprof'"),
585+
"Should warn about 'enabel-pprof'")
586+
assert.True(t, handler.HasWarning("did you mean 'enable-pprof'"),
587+
"Should suggest 'enable-pprof' for 'enabel-pprof'")
588+
}
589+
590+
// TestGlobalSectionUnknownKeyWarning_FileBased pins the suggestion
591+
// behavior on the BuildFromFile path (logUnknownKeyWarnings), the
592+
// production code path the issue reporter actually hits. The other
593+
// _DidYouMean tests cover BuildFromString (filename == ""); this one
594+
// covers the four-arm switch's filename != "" branch and asserts the
595+
// "of <filename>" substring lands in the message.
596+
func TestGlobalSectionUnknownKeyWarning_FileBased(t *testing.T) {
597+
t.Parallel()
598+
599+
tmpDir := t.TempDir()
600+
configPath := filepath.Join(tmpDir, "config.ini")
601+
//nolint:misspell // intentional typo for did-you-mean assertion
602+
content := `[global]
603+
webhook-defauls-preset = json-post
604+
`
605+
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o644))
606+
607+
logger, handler := test.NewTestLoggerWithHandler()
608+
_, err := BuildFromFile(configPath, logger)
609+
require.NoError(t, err)
610+
611+
assert.Equal(t, 1, handler.WarningCount(), "Expected 1 warning for unknown key")
612+
//nolint:misspell // intentional typo for did-you-mean assertion
613+
assert.True(t, handler.HasWarning("Unknown configuration key 'webhook-defauls-preset'"),
614+
"Should warn about the typo")
615+
assert.True(t, handler.HasWarning("[global] section"),
616+
"Should name the [global] section")
617+
assert.True(t, handler.HasWarning("of "+configPath),
618+
"Should include the filename (file-based path's message variant)")
619+
assert.True(t, handler.HasWarning("did you mean 'webhook-default-preset'"),
620+
"Should suggest 'webhook-default-preset' even on the file-based path")
621+
}
622+
473623
// Phase 8: Additional coverage tests for config_decode.go
474624

475625
func TestWeakDecodeConsistent_NilInput(t *testing.T) {

0 commit comments

Comments
 (0)