diff --git a/config.go b/config.go index 2dbe4ce..36dc856 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,6 @@ // Package config provides layered configuration management for the Core framework. // -// Configuration values are resolved in priority order: defaults -> file -> env -> flags. +// Configuration values are resolved in priority order: defaults -> file -> env -> Set(). // Values are stored in a YAML file at ~/.core/config.yaml by default. // // Keys use dot notation for nested access: @@ -11,6 +11,7 @@ package config import ( + "errors" "iter" "maps" "os" @@ -101,24 +102,21 @@ func New(opts ...Option) (*Config, error) { } // LoadFile reads a configuration file from the given medium and path and merges it into the current config. -// It supports YAML and environment files (.env). +// It supports YAML, JSON, TOML, and environment files (.env). func (c *Config) LoadFile(m coreio.Medium, path string) error { c.mu.Lock() defer c.mu.Unlock() + configType, err := configTypeForPath(path) + if err != nil { + return coreerr.E("config.LoadFile", "unsupported config file type: "+path, err) + } + content, err := m.Read(path) if err != nil { return coreerr.E("config.LoadFile", "failed to read config file: "+path, err) } - ext := filepath.Ext(path) - configType := "yaml" - if ext == "" && filepath.Base(path) == ".env" { - configType = "env" - } else if ext != "" { - configType = strings.TrimPrefix(ext, ".") - } - // Load into file-backed viper c.f.SetConfigType(configType) if err := c.f.MergeConfig(strings.NewReader(content)); err != nil { @@ -134,6 +132,24 @@ func (c *Config) LoadFile(m coreio.Medium, path string) error { return nil } +func configTypeForPath(path string) (string, error) { + ext := filepath.Ext(path) + switch { + case ext == "" && filepath.Base(path) == ".env": + return "env", nil + case ext == "": + return "yaml", nil + } + + configType := strings.TrimPrefix(ext, ".") + switch configType { + case "yaml", "yml", "json", "toml", "env": + return configType, nil + default: + return "", errors.New("unsupported config file type") + } +} + // Get retrieves a configuration value by dot-notation key and stores it in out. // If key is empty, it unmarshals the entire configuration into out. // The out parameter must be a pointer to the target type. diff --git a/config_test.go b/config_test.go index 930cf2a..4745927 100644 --- a/config_test.go +++ b/config_test.go @@ -246,6 +246,50 @@ func TestConfig_LoadFile_Env(t *testing.T) { assert.Equal(t, "bar", foo) } +func TestConfig_LoadFile_JSON_Good(t *testing.T) { + m := coreio.NewMockMedium() + m.Files["/tmp/test/config.json"] = `{"service":{"name":"core"}}` + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + err = cfg.LoadFile(m, "/tmp/test/config.json") + assert.NoError(t, err) + + var name string + err = cfg.Get("service.name", &name) + assert.NoError(t, err) + assert.Equal(t, "core", name) +} + +func TestConfig_LoadFile_TOML_Good(t *testing.T) { + m := coreio.NewMockMedium() + m.Files["/tmp/test/config.toml"] = "service = { name = \"core\" }\n" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + err = cfg.LoadFile(m, "/tmp/test/config.toml") + assert.NoError(t, err) + + var name string + err = cfg.Get("service.name", &name) + assert.NoError(t, err) + assert.Equal(t, "core", name) +} + +func TestConfig_LoadFile_Unsupported_Bad(t *testing.T) { + m := coreio.NewMockMedium() + m.Files["/tmp/test/config.txt"] = "service = core\n" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + err = cfg.LoadFile(m, "/tmp/test/config.txt") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported config file type") +} + func TestConfig_WithEnvPrefix(t *testing.T) { t.Setenv("MYAPP_SETTING", "secret") diff --git a/docs/index.md b/docs/index.md index dea219d..f0045fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Layered configuration management for the Core framework with file, # config -`forge.lthn.ai/core/config` provides layered configuration management for applications built on the Core framework. It resolves values through a priority chain -- defaults, file, environment variables, flags -- so that the same codebase works identically across local development, CI, and production without code changes. +`forge.lthn.ai/core/config` provides layered configuration management for applications built on the Core framework. It resolves values through a priority chain -- defaults, file, environment variables, and explicit `Set()` calls -- so that the same codebase works identically across local development, CI, and production without code changes. ## Module Path @@ -85,7 +85,7 @@ Values are resolved in ascending priority order: 1. **Defaults** -- hardcoded fallbacks (via `Set()` before any file load) 2. **File** -- YAML loaded from `~/.core/config.yaml` (or a custom path) 3. **Environment variables** -- prefixed with `CORE_CONFIG_` by default -4. **Explicit Set()** -- in-memory overrides applied at runtime +4. **Explicit `Set()`** -- in-memory overrides applied at runtime Environment variables always override file values. An explicit `Set()` call overrides everything.