From 3f6ddfbcd4cf78eaacad317c88401a8042d92ca8 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:29:54 +0000 Subject: [PATCH] feat(config): add reflection-based configFields framework --- cmd/config_fields.go | 55 +++++++++++++++ cmd/config_fields_test.go | 138 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 cmd/config_fields.go create mode 100644 cmd/config_fields_test.go diff --git a/cmd/config_fields.go b/cmd/config_fields.go new file mode 100644 index 0000000..6627cec --- /dev/null +++ b/cmd/config_fields.go @@ -0,0 +1,55 @@ +package cmd + +import "reflect" + +// ConfigField represents a single leaf field in the Config struct, +// mapped to its dotted TOML key path. +type ConfigField struct { + Key string // dotted key, e.g. "git.auto_commit" + Value any // current value + Default any // value from defaultConfig() + IsDefault bool // Value == Default + Field reflect.Value // settable reflect.Value (from cfg pointer) + Kind reflect.Kind // field type kind +} + +// configFields walks cfg and defaults in parallel, returning a ConfigField +// for every leaf field. Keys are built from TOML struct tags. +func configFields(cfg, defaults *Config) []ConfigField { + var fields []ConfigField + walk(reflect.ValueOf(cfg).Elem(), reflect.ValueOf(defaults).Elem(), "", &fields) + return fields +} + +func walk(cv, dv reflect.Value, prefix string, out *[]ConfigField) { + ct := cv.Type() + for i := 0; i < ct.NumField(); i++ { + sf := ct.Field(i) + tag := sf.Tag.Get("toml") + if tag == "" || tag == "-" { + continue + } + + key := tag + if prefix != "" { + key = prefix + "." + tag + } + + cfv := cv.Field(i) + dfv := dv.Field(i) + + if sf.Type.Kind() == reflect.Struct { + walk(cfv, dfv, key, out) + continue + } + + *out = append(*out, ConfigField{ + Key: key, + Value: cfv.Interface(), + Default: dfv.Interface(), + IsDefault: reflect.DeepEqual(cfv.Interface(), dfv.Interface()), + Field: cfv, + Kind: sf.Type.Kind(), + }) + } +} diff --git a/cmd/config_fields_test.go b/cmd/config_fields_test.go new file mode 100644 index 0000000..1f994d6 --- /dev/null +++ b/cmd/config_fields_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "reflect" + "testing" +) + +func TestConfigFieldsReturnsAllFields(t *testing.T) { + cfg := defaultConfig() + defaults := defaultConfig() + fields := configFields(&cfg, &defaults) + + // Count expected leaf fields by walking the struct + expected := countLeafFields(reflect.TypeOf(Config{})) + if len(fields) != expected { + t.Errorf("configFields returned %d fields, want %d", len(fields), expected) + } +} + +func countLeafFields(t reflect.Type) int { + n := 0 + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.Type.Kind() == reflect.Struct { + n += countLeafFields(f.Type) + } else { + n++ + } + } + return n +} + +func TestConfigFieldsDottedKeys(t *testing.T) { + cfg := defaultConfig() + defaults := defaultConfig() + fields := configFields(&cfg, &defaults) + + want := map[string]bool{ + "display_ascii_art": true, + "key.always_prompt_delete": true, + "key.always_prompt_glob_delete": true, + "key.always_prompt_overwrite": true, + "store.default_store_name": true, + "store.list_all_stores": true, + "store.always_prompt_delete": true, + "store.always_prompt_overwrite": true, + "git.auto_fetch": true, + "git.auto_commit": true, + "git.auto_push": true, + } + + got := make(map[string]bool) + for _, f := range fields { + got[f.Key] = true + } + + for k := range want { + if !got[k] { + t.Errorf("missing key %q", k) + } + } + for k := range got { + if !want[k] { + t.Errorf("unexpected key %q", k) + } + } +} + +func TestConfigFieldsAllDefaults(t *testing.T) { + cfg := defaultConfig() + defaults := defaultConfig() + fields := configFields(&cfg, &defaults) + + for _, f := range fields { + if !f.IsDefault { + t.Errorf("field %q should be default, got Value=%v Default=%v", f.Key, f.Value, f.Default) + } + } +} + +func TestConfigFieldsDetectsNonDefault(t *testing.T) { + cfg := defaultConfig() + cfg.Git.AutoCommit = true + defaults := defaultConfig() + fields := configFields(&cfg, &defaults) + + for _, f := range fields { + if f.Key == "git.auto_commit" { + if f.IsDefault { + t.Errorf("git.auto_commit should not be default after change") + } + if f.Value != true { + t.Errorf("git.auto_commit Value = %v, want true", f.Value) + } + return + } + } + t.Error("git.auto_commit not found in fields") +} + +func TestConfigFieldsSettable(t *testing.T) { + cfg := defaultConfig() + defaults := defaultConfig() + fields := configFields(&cfg, &defaults) + + for _, f := range fields { + if f.Key == "git.auto_push" { + if f.Kind != reflect.Bool { + t.Errorf("git.auto_push Kind = %v, want Bool", f.Kind) + } + f.Field.SetBool(true) + if !cfg.Git.AutoPush { + t.Error("setting field via reflect did not update cfg") + } + return + } + } + t.Error("git.auto_push not found in fields") +} + +func TestConfigFieldsStringField(t *testing.T) { + cfg := defaultConfig() + defaults := defaultConfig() + fields := configFields(&cfg, &defaults) + + for _, f := range fields { + if f.Key == "store.default_store_name" { + if f.Kind != reflect.String { + t.Errorf("store.default_store_name Kind = %v, want String", f.Kind) + } + if f.Value != "default" { + t.Errorf("store.default_store_name Value = %v, want 'default'", f.Value) + } + return + } + } + t.Error("store.default_store_name not found in fields") +}