Skip to content

Commit ac82f93

Browse files
committed
feat: Warn on unknown config keys and suggest corrections
Adds a warning message for unknown keys found in the configuration file. This helps users identify typos by suggesting the closest valid key. To support this, the mapstructure dependency was switched to a version that provides metadata on unused keys.
1 parent cb37682 commit ac82f93

5 files changed

Lines changed: 263 additions & 4 deletions

File tree

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ require (
1515
github.com/md14454/gosensors v0.0.0-20180726083412-bded752ab001
1616
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
1717
github.com/mitchellh/go-homedir v1.1.0
18-
github.com/mitchellh/mapstructure v1.5.0
1918
github.com/natefinch/atomic v1.0.1
2019
github.com/oklog/run v1.2.0
2120
github.com/orcaman/concurrent-map/v2 v2.0.1

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,6 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ
9595
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
9696
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
9797
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
98-
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
99-
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
10098
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
10199
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
102100
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=

internal/configuration/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import (
99
"time"
1010

1111
"github.com/creasty/defaults"
12+
"github.com/go-viper/mapstructure/v2"
1213
"github.com/markusressel/fan2go/internal/control_loop"
13-
"github.com/mitchellh/mapstructure"
1414

1515
"github.com/markusressel/fan2go/internal/ui"
1616
"github.com/mitchellh/go-homedir"
@@ -152,6 +152,7 @@ func LoadConfig() (Configuration, error) {
152152
return cfg, err
153153
}
154154

155+
var md mapstructure.Metadata
155156
err := viper.Unmarshal(
156157
&cfg,
157158
viper.DecodeHook(
@@ -164,11 +165,16 @@ func LoadConfig() (Configuration, error) {
164165
mapstructure.TextUnmarshallerHookFunc(),
165166
),
166167
),
168+
func(dc *mapstructure.DecoderConfig) {
169+
dc.Metadata = &md
170+
},
167171
)
168172
if err != nil {
169173
return cfg, err
170174
}
171175

176+
WarnUnknownKeys(&cfg, md.Unused, viper.Get)
177+
172178
// apply default values again to set any nested struct defaults that were
173179
// created after the initial parsing pass
174180
if err = defaults.Set(&cfg); err != nil {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package configuration
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"regexp"
7+
"strings"
8+
9+
"github.com/markusressel/fan2go/internal/ui"
10+
)
11+
12+
// WarnUnknownKeys checks unused configuration keys, determines if they are sections,
13+
// finds suggestions for typos, and prints warnings.
14+
func WarnUnknownKeys(cfg interface{}, unusedKeys []string, getValue func(string) interface{}) {
15+
re := regexp.MustCompile(`\[(\d+)\]`)
16+
for _, unused := range unusedKeys {
17+
key := strings.ToLower(unused)
18+
viperKey := re.ReplaceAllString(key, ".$1")
19+
val := getValue(viperKey)
20+
21+
isSection := false
22+
if val != nil {
23+
kind := reflect.TypeOf(val).Kind()
24+
if kind == reflect.Map || kind == reflect.Slice {
25+
isSection = true
26+
}
27+
}
28+
29+
suggestion := ""
30+
pathParts := strings.Split(viperKey, ".")
31+
if len(pathParts) > 0 {
32+
unknownPart := pathParts[len(pathParts)-1]
33+
parentPath := pathParts[:len(pathParts)-1]
34+
35+
parentType := getParentType(parentPath, reflect.TypeOf(cfg))
36+
validKeys := getValidKeys(parentType)
37+
38+
closest := getClosestMatch(unknownPart, validKeys)
39+
if closest != "" {
40+
suggestion = fmt.Sprintf(" - did you mean '%s'?", closest)
41+
}
42+
}
43+
44+
if isSection {
45+
ui.Warning("Unknown configuration section (and its contents): %s%s", key, suggestion)
46+
} else {
47+
ui.Warning("Unknown configuration key: %s%s", key, suggestion)
48+
}
49+
}
50+
}
51+
52+
func levenshtein(a, b string) int {
53+
d := make([][]int, len(a)+1)
54+
for i := range d {
55+
d[i] = make([]int, len(b)+1)
56+
d[i][0] = i
57+
}
58+
for j := range d[0] {
59+
d[0][j] = j
60+
}
61+
for j := 1; j <= len(b); j++ {
62+
for i := 1; i <= len(a); i++ {
63+
if a[i-1] == b[j-1] {
64+
d[i][j] = d[i-1][j-1]
65+
} else {
66+
min := d[i-1][j] + 1
67+
if d[i][j-1]+1 < min {
68+
min = d[i][j-1] + 1
69+
}
70+
if d[i-1][j-1]+1 < min {
71+
min = d[i-1][j-1] + 1
72+
}
73+
d[i][j] = min
74+
}
75+
}
76+
}
77+
return d[len(a)][len(b)]
78+
}
79+
80+
func getClosestMatch(target string, options []string) string {
81+
closest := ""
82+
minDist := -1
83+
for _, opt := range options {
84+
dist := levenshtein(target, opt)
85+
if minDist == -1 || dist < minDist {
86+
minDist = dist
87+
closest = opt
88+
}
89+
}
90+
if minDist != -1 && minDist <= 3 && len(target) > 2 {
91+
return closest
92+
}
93+
return ""
94+
}
95+
96+
func getValidKeys(parentType reflect.Type) []string {
97+
var keys []string
98+
if parentType == nil {
99+
return keys
100+
}
101+
for parentType.Kind() == reflect.Ptr {
102+
parentType = parentType.Elem()
103+
}
104+
if parentType.Kind() != reflect.Struct {
105+
return keys
106+
}
107+
for i := 0; i < parentType.NumField(); i++ {
108+
field := parentType.Field(i)
109+
if field.PkgPath != "" { // unexported
110+
continue
111+
}
112+
113+
name := ""
114+
for _, tagKey := range []string{"mapstructure", "json"} {
115+
tag := field.Tag.Get(tagKey)
116+
if tag != "" && tag != "-" {
117+
name = strings.Split(tag, ",")[0]
118+
break
119+
}
120+
}
121+
if name == "" {
122+
name = strings.ToLower(field.Name)
123+
}
124+
keys = append(keys, name)
125+
}
126+
return keys
127+
}
128+
129+
func getParentType(path []string, currentType reflect.Type) reflect.Type {
130+
for currentType.Kind() == reflect.Ptr {
131+
currentType = currentType.Elem()
132+
}
133+
if len(path) == 0 {
134+
return currentType
135+
}
136+
part := path[0]
137+
138+
switch currentType.Kind() {
139+
case reflect.Struct:
140+
for i := 0; i < currentType.NumField(); i++ {
141+
field := currentType.Field(i)
142+
name := strings.ToLower(field.Name)
143+
for _, tagKey := range []string{"mapstructure", "json"} {
144+
tag := field.Tag.Get(tagKey)
145+
if tag != "" && tag != "-" {
146+
tagOpts := strings.Split(tag, ",")
147+
if strings.ToLower(tagOpts[0]) == part {
148+
name = strings.ToLower(tagOpts[0])
149+
break
150+
}
151+
}
152+
}
153+
if name == part {
154+
return getParentType(path[1:], field.Type)
155+
}
156+
}
157+
return nil
158+
case reflect.Slice, reflect.Array, reflect.Map:
159+
return getParentType(path[1:], currentType.Elem())
160+
}
161+
return nil
162+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package configuration
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestLevenshtein(t *testing.T) {
9+
tests := []struct {
10+
a, b string
11+
want int
12+
}{
13+
{"fan", "fans", 1},
14+
{"platorm", "platform", 1},
15+
{"sensors", "sensor", 1},
16+
{"same", "same", 0},
17+
}
18+
19+
for _, tt := range tests {
20+
if got := levenshtein(tt.a, tt.b); got != tt.want {
21+
t.Errorf("levenshtein(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
22+
}
23+
}
24+
}
25+
26+
func TestGetClosestMatch(t *testing.T) {
27+
tests := []struct {
28+
target string
29+
options []string
30+
want string
31+
}{
32+
{"fan", []string{"fans", "sensors"}, "fans"},
33+
{"plattorm", []string{"platform", "index"}, "platform"},
34+
{"xyz", []string{"platform", "index"}, ""}, // too far
35+
{"ab", []string{"abc", "def"}, ""}, // too short
36+
}
37+
38+
for _, tt := range tests {
39+
if got := getClosestMatch(tt.target, tt.options); got != tt.want {
40+
t.Errorf("getClosestMatch(%q, %v) = %q, want %q", tt.target, tt.options, got, tt.want)
41+
}
42+
}
43+
}
44+
45+
func TestGetValidKeys(t *testing.T) {
46+
cfg := Configuration{}
47+
keys := getValidKeys(reflect.TypeOf(cfg))
48+
49+
expected := []string{"dbpath", "runfaninitializationinparallel", "maxrpmdiffforsettledfan", "fanresponsedelay", "tempsensorpollingrate", "temprollingwindowsize", "rpmpollingrate", "rpmrollingwindowsize", "controlleradjustmenttickrate", "analysis", "fancontroller", "fans", "sensors", "curves", "api", "statistics", "profiling"}
50+
51+
if len(keys) != len(expected) {
52+
t.Errorf("getValidKeys() returned %d keys, want %d", len(keys), len(expected))
53+
}
54+
55+
// simple check for some known keys
56+
foundFans := false
57+
for _, k := range keys {
58+
if k == "fans" {
59+
foundFans = true
60+
break
61+
}
62+
}
63+
if !foundFans {
64+
t.Errorf("getValidKeys() missing 'fans'")
65+
}
66+
}
67+
68+
func TestGetParentType(t *testing.T) {
69+
cfg := Configuration{}
70+
71+
// Root level
72+
typ := getParentType([]string{}, reflect.TypeOf(cfg))
73+
if typ != reflect.TypeOf(cfg) {
74+
t.Errorf("getParentType() for root failed")
75+
}
76+
77+
// Fans slice element type (FanConfig)
78+
typ = getParentType([]string{"fans", "0"}, reflect.TypeOf(cfg))
79+
if typ.Name() != "FanConfig" {
80+
t.Errorf("getParentType() for fans.0 = %v, want FanConfig", typ)
81+
}
82+
83+
// HwMon config inside fan
84+
typ = getParentType([]string{"fans", "0", "hwmon"}, reflect.TypeOf(cfg))
85+
if typ.Name() != "HwMonFanConfig" {
86+
t.Errorf("getParentType() for fans.0.hwmon = %v, want HwMonFanConfig", typ)
87+
}
88+
89+
// Invalid path
90+
typ = getParentType([]string{"invalid", "path"}, reflect.TypeOf(cfg))
91+
if typ != nil {
92+
t.Errorf("getParentType() for invalid path should be nil, got %v", typ)
93+
}
94+
}

0 commit comments

Comments
 (0)