3 Commits

Author SHA1 Message Date
6ec3d79700 add dot-accessor notation 2026-03-20 05:54:41 -04:00
a81a2027ae refactor(api)!: remove Delete method from public API
Environment variables are unaffected by Delete, making it misleading —
callers would expect a key to be fully gone but env vars would still
resolve. Sub is the better abstraction for scoped config.
2026-03-09 19:36:31 +00:00
ff8a3444f1 Merge pull request #5 from taigrr/cd/ci-updates-and-delete-method
feat(api): add Delete and Sub methods, update CI workflows
2026-03-08 14:30:09 -04:00
4 changed files with 204 additions and 92 deletions

View File

@@ -88,10 +88,6 @@ func SetString(key string, value string) {
defaultConfigManager.SetString(key, value)
}
func Delete(key string) {
defaultConfigManager.Delete(key)
}
func Sub(key string) *ConfigManager {
return defaultConfigManager.Sub(key)
}

View File

@@ -8,17 +8,71 @@ import (
)
// resolve looks up a key in combinedConfig, falling back to envConfig.
// It supports dot notation (e.g., "services.mas.server") to traverse nested maps.
func (c *ConfigManager) resolve(key string) (ConfigMap, bool) {
lower := strings.ToLower(key)
// First, try direct lookup (for top-level keys or keys without dots)
if v, ok := c.combinedConfig[lower]; ok {
return v, true
}
if v, ok := c.envConfig[lower]; ok {
return v, true
}
// If key contains dots, try traversing nested maps
if strings.Contains(lower, ".") {
if v, ok := c.resolveNested(lower, c.combinedConfig); ok {
return v, true
}
if v, ok := c.resolveNested(lower, c.envConfig); ok {
return v, true
}
}
return ConfigMap{}, false
}
// resolveNested traverses nested maps using dot-separated key paths.
func (c *ConfigManager) resolveNested(key string, config map[string]ConfigMap) (ConfigMap, bool) {
parts := strings.Split(key, ".")
if len(parts) < 2 {
return ConfigMap{}, false
}
// Look up the first part in the config
firstPart := parts[0]
entry, ok := config[firstPart]
if !ok {
return ConfigMap{}, false
}
// Traverse the remaining parts through nested maps
current := entry.Value
for i := 1; i < len(parts); i++ {
m, ok := current.(map[string]any)
if !ok {
return ConfigMap{}, false
}
// Try case-insensitive lookup in the nested map
part := parts[i]
found := false
for k, v := range m {
if strings.EqualFold(k, part) {
current = v
found = true
break
}
}
if !found {
return ConfigMap{}, false
}
}
return ConfigMap{Key: key, Value: current}, true
}
func (c *ConfigManager) Get(key string) any {
c.mutex.RLock()
defer c.mutex.RUnlock()

View File

@@ -1555,81 +1555,6 @@ func TestPackageLevelGetInt64(t *testing.T) {
}
}
func TestDelete(t *testing.T) {
cm := NewConfigManager()
cm.SetDefault("keep", "yes")
cm.SetDefault("remove", "default")
cm.Set("remove", "override")
if !cm.IsSet("remove") {
t.Fatal("remove should be set before delete")
}
cm.Delete("remove")
if cm.IsSet("remove") {
t.Error("remove should not be set after delete")
}
if cm.Get("remove") != nil {
t.Error("Get(remove) should return nil after delete")
}
if cm.GetString("keep") != "yes" {
t.Error("keep should be unaffected by deleting remove")
}
}
func TestDeleteFromAllLayers(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "config.toml")
if err := os.WriteFile(configFile, []byte(`filekey = "fromfile"`), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("toml"); err != nil {
t.Fatal(err)
}
cm.SetDefault("filekey", "default")
if err := cm.ReadInConfig(); err != nil {
t.Fatal(err)
}
cm.Set("filekey", "override")
if cm.GetString("filekey") != "override" {
t.Fatal("expected override value before delete")
}
cm.Delete("filekey")
if cm.IsSet("filekey") {
t.Error("filekey should not be set after delete from all layers")
}
}
func TestDeleteCaseInsensitive(t *testing.T) {
cm := NewConfigManager()
cm.Set("MyKey", "value")
cm.Delete("MYKEY")
if cm.IsSet("mykey") {
t.Error("delete should be case-insensitive")
}
}
func TestPackageLevelDelete(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("temp", "value")
if !IsSet("temp") {
t.Fatal("temp should be set")
}
Delete("temp")
if IsSet("temp") {
t.Error("temp should not be set after package-level Delete")
}
}
func TestSub(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "config.toml")
@@ -1721,3 +1646,153 @@ func TestPackageLevelSub(t *testing.T) {
t.Errorf("Sub(db).GetInt(port) = %d, want 5432", sub.GetInt("port"))
}
}
func TestDotNotationAccess(t *testing.T) {
const tomlWithNested = `
port = 8080
[services.mas]
server = "192.168.1.15"
user = "central"
disabled = false
[services.salesforce.prod]
host = "sf.example.com"
port = 443
`
dir := t.TempDir()
configFile := filepath.Join(dir, "nested.toml")
if err := os.WriteFile(configFile, []byte(tomlWithNested), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("toml"); err != nil {
t.Fatal(err)
}
if err := cm.ReadInConfig(); err != nil {
t.Fatal(err)
}
// Top-level key should still work
if got := cm.GetInt("port"); got != 8080 {
t.Errorf("GetInt(port) = %d, want 8080", got)
}
// Dot notation for nested string
if got := cm.GetString("services.mas.server"); got != "192.168.1.15" {
t.Errorf("GetString(services.mas.server) = %q, want %q", got, "192.168.1.15")
}
if got := cm.GetString("services.mas.user"); got != "central" {
t.Errorf("GetString(services.mas.user) = %q, want %q", got, "central")
}
// Dot notation for nested bool
if got := cm.GetBool("services.mas.disabled"); got != false {
t.Errorf("GetBool(services.mas.disabled) = %v, want false", got)
}
// Dot notation for nested map
masMap := cm.GetStringMap("services.mas")
if masMap == nil {
t.Fatal("GetStringMap(services.mas) = nil")
}
if masMap["server"] != "192.168.1.15" {
t.Errorf("services.mas map server = %v, want %q", masMap["server"], "192.168.1.15")
}
// Three levels deep
if got := cm.GetString("services.salesforce.prod.host"); got != "sf.example.com" {
t.Errorf("GetString(services.salesforce.prod.host) = %q, want %q", got, "sf.example.com")
}
if got := cm.GetInt("services.salesforce.prod.port"); got != 443 {
t.Errorf("GetInt(services.salesforce.prod.port) = %d, want 443", got)
}
// GetStringMap at intermediate level
sfProdMap := cm.GetStringMap("services.salesforce.prod")
if sfProdMap == nil {
t.Fatal("GetStringMap(services.salesforce.prod) = nil")
}
if sfProdMap["host"] != "sf.example.com" {
t.Errorf("salesforce.prod map host = %v, want %q", sfProdMap["host"], "sf.example.com")
}
// Nonexistent nested key should return zero value
if got := cm.GetString("services.mas.nonexistent"); got != "" {
t.Errorf("GetString(services.mas.nonexistent) = %q, want empty", got)
}
if got := cm.GetStringMap("services.nonexistent"); got != nil {
t.Errorf("GetStringMap(services.nonexistent) = %v, want nil", got)
}
}
func TestDotNotationCaseInsensitive(t *testing.T) {
cm := NewConfigManager()
cm.Set("services", map[string]any{
"MAS": map[string]any{
"Server": "192.168.1.15",
},
})
// Should work regardless of case
if got := cm.GetString("services.mas.server"); got != "192.168.1.15" {
t.Errorf("GetString(services.mas.server) = %q, want %q", got, "192.168.1.15")
}
if got := cm.GetString("SERVICES.MAS.SERVER"); got != "192.168.1.15" {
t.Errorf("GetString(SERVICES.MAS.SERVER) = %q, want %q", got, "192.168.1.15")
}
if got := cm.GetString("Services.Mas.Server"); got != "192.168.1.15" {
t.Errorf("GetString(Services.Mas.Server) = %q, want %q", got, "192.168.1.15")
}
}
func TestDotNotationWithAllGetters(t *testing.T) {
cm := NewConfigManager()
cm.Set("config", map[string]any{
"nested": map[string]any{
"string": "hello",
"int": 42,
"int64": int64(9223372036854775807),
"float": 3.14,
"bool": true,
"dur": "30s",
"strings": []string{"a", "b", "c"},
"ints": []any{1, 2, 3},
"map": map[string]any{"key": "value"},
},
})
if got := cm.Get("config.nested.string"); got != "hello" {
t.Errorf("Get(config.nested.string) = %v, want %q", got, "hello")
}
if got := cm.GetString("config.nested.string"); got != "hello" {
t.Errorf("GetString(config.nested.string) = %q, want %q", got, "hello")
}
if got := cm.GetInt("config.nested.int"); got != 42 {
t.Errorf("GetInt(config.nested.int) = %d, want 42", got)
}
if got := cm.GetInt64("config.nested.int64"); got != 9223372036854775807 {
t.Errorf("GetInt64(config.nested.int64) = %d, want 9223372036854775807", got)
}
if got := cm.GetFloat64("config.nested.float"); got != 3.14 {
t.Errorf("GetFloat64(config.nested.float) = %f, want 3.14", got)
}
if got := cm.GetBool("config.nested.bool"); got != true {
t.Errorf("GetBool(config.nested.bool) = %v, want true", got)
}
if got := cm.GetDuration("config.nested.dur"); got != 30*time.Second {
t.Errorf("GetDuration(config.nested.dur) = %v, want 30s", got)
}
if got := cm.GetStringSlice("config.nested.strings"); len(got) != 3 || got[0] != "a" {
t.Errorf("GetStringSlice(config.nested.strings) = %v, want [a b c]", got)
}
if got := cm.GetIntSlice("config.nested.ints"); len(got) != 3 || got[0] != 1 {
t.Errorf("GetIntSlice(config.nested.ints) = %v, want [1 2 3]", got)
}
if got := cm.GetStringMap("config.nested.map"); got == nil || got["key"] != "value" {
t.Errorf("GetStringMap(config.nested.map) = %v, want map[key:value]", got)
}
}

View File

@@ -28,19 +28,6 @@ func (c *ConfigManager) Set(key string, value any) {
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
}
// Delete removes a key from all configuration layers (overrides, file,
// defaults) and rebuilds the combined configuration. Environment variables
// are not affected since they are loaded from the process environment.
func (c *ConfigManager) Delete(key string) {
c.mutex.Lock()
lower := strings.ToLower(key)
delete(c.overrideConfig, lower)
delete(c.fileConfig, lower)
delete(c.defaultConfig, lower)
delete(c.combinedConfig, lower)
c.mutex.Unlock()
}
func (c *ConfigManager) SetDefault(key string, value any) {
c.mutex.Lock()
defer c.mutex.Unlock()