Compare commits

...
Sign in to create a new pull request.

13 commits
main ... dev

Author SHA1 Message Date
Snider
94650e05b2 fix: migrate module paths from forge.lthn.ai to dappco.re
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:11 +01:00
7c02e2c30e Merge pull request '[agent/codex:gpt-5.3-codex-spark] Read ~/spec/code/core/go/config/RFC.md fully. Find features ...' (#4) from agent/read---spec-code-core-go-config-rfc-md-f into dev 2026-04-03 07:34:13 +00:00
Virgil
07cf008a3a feat(config): expose service env prefix option
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 07:34:06 +00:00
Virgil
411c82a6d4 fix(config): align public docs with Set semantics
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:31:04 +00:00
Virgil
f9439cd3d8 fix(config): normalise env prefix option
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:25:05 +00:00
Virgil
2dbb8fb6ba fix(config): align yaml path validation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:22:59 +00:00
Virgil
8d29e924e8 fix(config): reject non-yaml save paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:20:26 +00:00
Virgil
25559c4913 fix(config): validate file types before read
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:18:01 +00:00
Virgil
d6f7c05838 fix(config): make iteration deterministic
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:09:32 +00:00
Virgil
6dd94aff4c fix(config): validate config file types explicitly
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:06:44 +00:00
Virgil
1f081bcd92 docs(config): add agent-facing examples and correct storage alias
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:02:28 +00:00
Snider
2405ecb84a chore: sync dependencies for v0.1.8
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:53:19 +00:00
Snider
e4ee5cbec9 chore: sync dependencies for v0.1.7
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:48:27 +00:00
9 changed files with 375 additions and 65 deletions

View file

@ -34,7 +34,7 @@ This prevents environment variables from leaking into saved config files. When i
**Service wrapper**: `Service` in `service.go` wraps `Config` with framework lifecycle (`core.Startable`). Both `Config` and `Service` satisfy `core.Config`, enforced by compile-time assertions. **Service wrapper**: `Service` in `service.go` wraps `Config` with framework lifecycle (`core.Startable`). Both `Config` and `Service` satisfy `core.Config`, enforced by compile-time assertions.
**Storage abstraction**: All file I/O goes through `io.Medium` (from `go-io`). Tests use `io.NewMockMedium()` with an in-memory `Files` map — never touch the real filesystem. **Storage abstraction**: All file I/O goes through `coreio.Medium` (from `go-io`). Tests use `coreio.NewMockMedium()` with an in-memory `Files` map — never touch the real filesystem.
## Conventions ## Conventions

120
config.go
View file

@ -1,6 +1,6 @@
// Package config provides layered configuration management for the Core framework. // 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. // Values are stored in a YAML file at ~/.core/config.yaml by default.
// //
// Keys use dot notation for nested access: // Keys use dot notation for nested access:
@ -13,9 +13,9 @@ package config
import ( import (
"fmt" "fmt"
"iter" "iter"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
@ -30,8 +30,8 @@ import (
// It uses viper as the underlying configuration engine. // It uses viper as the underlying configuration engine.
type Config struct { type Config struct {
mu sync.RWMutex mu sync.RWMutex
v *viper.Viper // Full configuration (file + env + defaults) full *viper.Viper // Full configuration (file + env + defaults)
f *viper.Viper // File-backed configuration only (for persistence) file *viper.Viper // File-backed configuration only (for persistence)
medium coreio.Medium medium coreio.Medium
path string path string
} }
@ -56,7 +56,7 @@ func WithPath(path string) Option {
// WithEnvPrefix sets the prefix for environment variables. // WithEnvPrefix sets the prefix for environment variables.
func WithEnvPrefix(prefix string) Option { func WithEnvPrefix(prefix string) Option {
return func(c *Config) { return func(c *Config) {
c.v.SetEnvPrefix(prefix) c.full.SetEnvPrefix(strings.TrimSuffix(prefix, "_"))
} }
} }
@ -65,13 +65,13 @@ func WithEnvPrefix(prefix string) Option {
// If no path is provided, it defaults to ~/.core/config.yaml. // If no path is provided, it defaults to ~/.core/config.yaml.
func New(opts ...Option) (*Config, error) { func New(opts ...Option) (*Config, error) {
c := &Config{ c := &Config{
v: viper.New(), full: viper.New(),
f: viper.New(), file: viper.New(),
} }
// Configure viper defaults // Configure viper defaults
c.v.SetEnvPrefix("CORE_CONFIG") c.full.SetEnvPrefix("CORE_CONFIG")
c.v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) c.full.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
for _, opt := range opts { for _, opt := range opts {
opt(c) opt(c)
@ -89,7 +89,7 @@ func New(opts ...Option) (*Config, error) {
c.path = filepath.Join(home, ".core", "config.yaml") c.path = filepath.Join(home, ".core", "config.yaml")
} }
c.v.AutomaticEnv() c.full.AutomaticEnv()
// Load existing config file if it exists // Load existing config file if it exists
if c.medium.Exists(c.path) { if c.medium.Exists(c.path) {
@ -101,35 +101,60 @@ func New(opts ...Option) (*Config, error) {
return c, nil return c, nil
} }
func configTypeForPath(path string) (string, error) {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" && filepath.Base(path) == ".env" {
return "env", nil
}
if ext == "" {
return "yaml", nil
}
switch ext {
case ".yaml", ".yml":
return "yaml", nil
case ".json":
return "json", nil
case ".toml":
return "toml", nil
case ".env":
return "env", nil
default:
return "", coreerr.E("config.configTypeForPath", "unsupported config file type: "+path, nil)
}
}
// LoadFile reads a configuration file from the given medium and path and merges it into the current config. // 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 dotenv files (.env).
func (c *Config) LoadFile(m coreio.Medium, path string) error { func (c *Config) LoadFile(m coreio.Medium, path string) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
configType, err := configTypeForPath(path)
if err != nil {
return coreerr.E("config.LoadFile", "failed to determine config file type: "+path, err)
}
content, err := m.Read(path) content, err := m.Read(path)
if err != nil { if err != nil {
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to read config file: %s", path), err) return coreerr.E("config.LoadFile", fmt.Sprintf("failed to read config file: %s", path), err)
} }
ext := filepath.Ext(path) parsed := viper.New()
configType := "yaml" parsed.SetConfigType(configType)
if ext == "" && filepath.Base(path) == ".env" { if err := parsed.MergeConfig(strings.NewReader(content)); err != nil {
configType = "env" return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file: %s", path), err)
} else if ext != "" {
configType = strings.TrimPrefix(ext, ".")
} }
// Load into file-backed viper settings := parsed.AllSettings()
c.f.SetConfigType(configType)
if err := c.f.MergeConfig(strings.NewReader(content)); err != nil { // Keep the persisted and runtime views aligned with the same parsed data.
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file (f): %s", path), err) if err := c.file.MergeConfigMap(settings); err != nil {
return coreerr.E("config.LoadFile", "failed to merge config into file settings", err)
} }
// Load into full viper if err := c.full.MergeConfigMap(settings); err != nil {
c.v.SetConfigType(configType) return coreerr.E("config.LoadFile", "failed to merge config into full settings", err)
if err := c.v.MergeConfig(strings.NewReader(content)); err != nil {
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file (v): %s", path), err)
} }
return nil return nil
@ -143,17 +168,17 @@ func (c *Config) Get(key string, out any) error {
defer c.mu.RUnlock() defer c.mu.RUnlock()
if key == "" { if key == "" {
if err := c.v.Unmarshal(out); err != nil { if err := c.full.Unmarshal(out); err != nil {
return coreerr.E("config.Get", "failed to unmarshal full config", err) return coreerr.E("config.Get", "failed to unmarshal full config", err)
} }
return nil return nil
} }
if !c.v.IsSet(key) { if !c.full.IsSet(key) {
return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil) return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil)
} }
if err := c.v.UnmarshalKey(key, out); err != nil { if err := c.full.UnmarshalKey(key, out); err != nil {
return coreerr.E("config.Get", fmt.Sprintf("failed to unmarshal key: %s", key), err) return coreerr.E("config.Get", fmt.Sprintf("failed to unmarshal key: %s", key), err)
} }
return nil return nil
@ -165,8 +190,8 @@ func (c *Config) Set(key string, v any) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.f.Set(key, v) c.file.Set(key, v)
c.v.Set(key, v) c.full.Set(key, v)
return nil return nil
} }
@ -177,17 +202,32 @@ func (c *Config) Commit() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := Save(c.medium, c.path, c.f.AllSettings()); err != nil { if err := Save(c.medium, c.path, c.file.AllSettings()); err != nil {
return coreerr.E("config.Commit", "failed to save config", err) return coreerr.E("config.Commit", "failed to save config", err)
} }
return nil return nil
} }
// All returns an iterator over all configuration values (including environment variables). // All returns an iterator over all configuration values in lexical key order
// (including environment variables).
func (c *Config) All() iter.Seq2[string, any] { func (c *Config) All() iter.Seq2[string, any] {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return maps.All(c.v.AllSettings())
settings := c.full.AllSettings()
keys := make([]string, 0, len(settings))
for key := range settings {
keys = append(keys, key)
}
sort.Strings(keys)
return func(yield func(string, any) bool) {
for _, key := range keys {
if !yield(key, settings[key]) {
return
}
}
}
} }
// Path returns the path to the configuration file. // Path returns the path to the configuration file.
@ -199,6 +239,13 @@ func (c *Config) Path() string {
// Returns the parsed data as a map, or an error if the file cannot be read or parsed. // Returns the parsed data as a map, or an error if the file cannot be read or parsed.
// Deprecated: Use Config.LoadFile instead. // Deprecated: Use Config.LoadFile instead.
func Load(m coreio.Medium, path string) (map[string]any, error) { func Load(m coreio.Medium, path string) (map[string]any, error) {
switch ext := strings.ToLower(filepath.Ext(path)); ext {
case "", ".yaml", ".yml":
// These paths are safe to treat as YAML sources.
default:
return nil, coreerr.E("config.Load", "unsupported config file type: "+path, nil)
}
content, err := m.Read(path) content, err := m.Read(path)
if err != nil { if err != nil {
return nil, coreerr.E("config.Load", "failed to read config file: "+path, err) return nil, coreerr.E("config.Load", "failed to read config file: "+path, err)
@ -216,6 +263,13 @@ func Load(m coreio.Medium, path string) (map[string]any, error) {
// Save writes configuration data to a YAML file at the given path. // Save writes configuration data to a YAML file at the given path.
// It ensures the parent directory exists before writing. // It ensures the parent directory exists before writing.
func Save(m coreio.Medium, path string, data map[string]any) error { func Save(m coreio.Medium, path string, data map[string]any) error {
switch ext := strings.ToLower(filepath.Ext(path)); ext {
case "", ".yaml", ".yml":
// These paths are safe to treat as YAML destinations.
default:
return coreerr.E("config.Save", "unsupported config file type: "+path, nil)
}
out, err := yaml.Marshal(data) out, err := yaml.Marshal(data)
if err != nil { if err != nil {
return coreerr.E("config.Save", "failed to marshal config", err) return coreerr.E("config.Save", "failed to marshal config", err)

View file

@ -1,11 +1,14 @@
package config package config
import ( import (
"context"
"fmt"
"maps" "maps"
"os" "os"
"testing" "testing"
coreio "forge.lthn.ai/core/go-io" coreio "forge.lthn.ai/core/go-io"
core "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -89,6 +92,23 @@ func TestConfig_All_Good(t *testing.T) {
assert.Equal(t, "val2", all["key2"]) assert.Equal(t, "val2", all["key2"])
} }
func TestConfig_All_Order_Good(t *testing.T) {
m := coreio.NewMockMedium()
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml"))
assert.NoError(t, err)
_ = cfg.Set("zulu", "last")
_ = cfg.Set("alpha", "first")
var keys []string
for key, _ := range cfg.All() {
keys = append(keys, key)
}
assert.Equal(t, []string{"alpha", "zulu"}, keys)
}
func TestConfig_Path_Good(t *testing.T) { func TestConfig_Path_Good(t *testing.T) {
m := coreio.NewMockMedium() m := coreio.NewMockMedium()
@ -198,6 +218,21 @@ func TestLoadEnv_Good(t *testing.T) {
assert.Equal(t, "value", result["simple"]) assert.Equal(t, "value", result["simple"])
} }
func TestLoadEnv_PrefixNormalisation_Good(t *testing.T) {
t.Setenv("MYAPP_SETTING", "secret")
t.Setenv("MYAPP_ALPHA", "first")
keys := make([]string, 0, 2)
values := make([]string, 0, 2)
for key, value := range Env("MYAPP") {
keys = append(keys, key)
values = append(values, value.(string))
}
assert.Equal(t, []string{"alpha", "setting"}, keys)
assert.Equal(t, []string{"first", "secret"}, values)
}
func TestLoad_Bad(t *testing.T) { func TestLoad_Bad(t *testing.T) {
m := coreio.NewMockMedium() m := coreio.NewMockMedium()
@ -206,6 +241,15 @@ func TestLoad_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "failed to read config file") assert.Contains(t, err.Error(), "failed to read config file")
} }
func TestLoad_UnsupportedPath_Bad(t *testing.T) {
m := coreio.NewMockMedium()
m.Files["/tmp/test/config.json"] = `{"app":{"name":"core"}}`
_, err := Load(m, "/tmp/test/config.json")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported config file type")
}
func TestLoad_InvalidYAML_Bad(t *testing.T) { func TestLoad_InvalidYAML_Bad(t *testing.T) {
m := coreio.NewMockMedium() m := coreio.NewMockMedium()
m.Files["/tmp/test/config.yaml"] = "invalid: yaml: content: [[[[" m.Files["/tmp/test/config.yaml"] = "invalid: yaml: content: [[[["
@ -215,6 +259,68 @@ func TestLoad_InvalidYAML_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "failed to parse config file") assert.Contains(t, err.Error(), "failed to parse config file")
} }
func TestConfig_LoadFile_JSON_Good(t *testing.T) {
m := coreio.NewMockMedium()
m.Files["/tmp/test/config.json"] = `{"app":{"name":"core"}}`
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.json"))
assert.NoError(t, err)
var name string
err = cfg.Get("app.name", &name)
assert.NoError(t, err)
assert.Equal(t, "core", name)
}
func TestConfig_LoadFile_Extensionless_Good(t *testing.T) {
m := coreio.NewMockMedium()
m.Files["/tmp/test/config"] = "app:\n name: core\n"
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config"))
assert.NoError(t, err)
var name string
err = cfg.Get("app.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"] = "app = { name = \"core\" }\n"
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.toml"))
assert.NoError(t, err)
var name string
err = cfg.Get("app.name", &name)
assert.NoError(t, err)
assert.Equal(t, "core", name)
}
func TestConfig_LoadFile_Unsupported_Bad(t *testing.T) {
m := coreio.NewMockMedium()
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.txt"))
assert.NoError(t, err)
m.Files["/tmp/test/config.txt"] = "app.name=core"
err = cfg.LoadFile(m, "/tmp/test/config.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported config file type")
}
func TestConfig_LoadFile_Unsupported_NoRead_Bad(t *testing.T) {
m := coreio.NewMockMedium()
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.txt"))
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 TestSave_Good(t *testing.T) { func TestSave_Good(t *testing.T) {
m := coreio.NewMockMedium() m := coreio.NewMockMedium()
@ -230,6 +336,39 @@ func TestSave_Good(t *testing.T) {
assert.Contains(t, content, "key: value") assert.Contains(t, content, "key: value")
} }
func TestSave_Extensionless_Good(t *testing.T) {
m := coreio.NewMockMedium()
err := Save(m, "/tmp/test/config", map[string]any{"key": "value"})
assert.NoError(t, err)
content, readErr := m.Read("/tmp/test/config")
assert.NoError(t, readErr)
assert.Contains(t, content, "key: value")
}
func TestSave_UnsupportedPath_Bad(t *testing.T) {
m := coreio.NewMockMedium()
err := Save(m, "/tmp/test/config.json", map[string]any{"key": "value"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported config file type")
}
func TestConfig_Commit_UnsupportedPath_Bad(t *testing.T) {
m := coreio.NewMockMedium()
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.json"))
assert.NoError(t, err)
err = cfg.Set("key", "value")
assert.NoError(t, err)
err = cfg.Commit()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported config file type")
}
func TestConfig_LoadFile_Env(t *testing.T) { func TestConfig_LoadFile_Env(t *testing.T) {
m := coreio.NewMockMedium() m := coreio.NewMockMedium()
m.Files["/.env"] = "FOO=bar\nBAZ=qux" m.Files["/.env"] = "FOO=bar\nBAZ=qux"
@ -259,6 +398,39 @@ func TestConfig_WithEnvPrefix(t *testing.T) {
assert.Equal(t, "secret", setting) assert.Equal(t, "secret", setting)
} }
func TestConfig_WithEnvPrefix_TrailingUnderscore_Good(t *testing.T) {
t.Setenv("MYAPP_SETTING", "secret")
m := coreio.NewMockMedium()
cfg, err := New(WithMedium(m), WithEnvPrefix("MYAPP_"))
assert.NoError(t, err)
var setting string
err = cfg.Get("setting", &setting)
assert.NoError(t, err)
assert.Equal(t, "secret", setting)
}
func TestService_OnStartup_WithEnvPrefix_Good(t *testing.T) {
t.Setenv("MYAPP_SETTING", "secret")
m := coreio.NewMockMedium()
svc := &Service{
ServiceRuntime: core.NewServiceRuntime(nil, ServiceOptions{
EnvPrefix: "MYAPP",
Medium: m,
}),
}
err := svc.OnStartup(context.Background())
assert.NoError(t, err)
var setting string
err = svc.Get("setting", &setting)
assert.NoError(t, err)
assert.Equal(t, "secret", setting)
}
func TestConfig_Get_EmptyKey(t *testing.T) { func TestConfig_Get_EmptyKey(t *testing.T) {
m := coreio.NewMockMedium() m := coreio.NewMockMedium()
m.Files["/config.yaml"] = "app:\n name: test\nversion: 1" m.Files["/config.yaml"] = "app:\n name: test\nversion: 1"
@ -279,3 +451,56 @@ func TestConfig_Get_EmptyKey(t *testing.T) {
assert.Equal(t, "test", full.App.Name) assert.Equal(t, "test", full.App.Name)
assert.Equal(t, 1, full.Version) assert.Equal(t, 1, full.Version)
} }
func ExampleConfig_Get() {
m := coreio.NewMockMedium()
cfg, _ := New(WithMedium(m), WithPath("/tmp/example/config.yaml"))
_ = cfg.Set("dev.editor", "vim")
var editor string
_ = cfg.Get("dev.editor", &editor)
fmt.Println(editor)
// Output: vim
}
func ExampleConfig_Commit() {
m := coreio.NewMockMedium()
cfg, _ := New(WithMedium(m), WithPath("/tmp/example/config.yaml"))
_ = cfg.Set("app.name", "core")
_ = cfg.Commit()
content, _ := m.Read("/tmp/example/config.yaml")
fmt.Print(content)
// Output:
// app:
// name: core
}
func ExampleEnv() {
t := "EXAMPLE_FOO_BAR"
_ = os.Setenv(t, "baz")
defer os.Unsetenv(t)
for key, value := range Env("EXAMPLE_") {
fmt.Printf("%s=%s\n", key, value)
}
// Output: foo.bar=baz
}
func ExampleConfig_LoadFile() {
m := coreio.NewMockMedium()
m.Files["/.env"] = "FOO=bar\n"
cfg, _ := New(WithMedium(m), WithPath("/config.yaml"))
_ = cfg.LoadFile(m, "/.env")
var foo string
_ = cfg.Get("foo", &foo)
fmt.Println(foo)
// Output: bar
}

View file

@ -19,8 +19,8 @@ description: Internal design of config -- dual-viper layering, the Medium abstra
```go ```go
type Config struct { type Config struct {
mu sync.RWMutex mu sync.RWMutex
v *viper.Viper // full configuration (file + env + defaults) full *viper.Viper // full configuration (file + env + defaults)
f *viper.Viper // file-backed configuration only (for persistence) file *viper.Viper // file-backed configuration only (for persistence)
medium coreio.Medium medium coreio.Medium
path string path string
} }
@ -28,10 +28,10 @@ type Config struct {
`Config` is the central type. It holds **two** Viper instances: `Config` is the central type. It holds **two** Viper instances:
- **`v`** (full) -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `v`. - **`full`** -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `full`.
- **`f`** (file) -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `f`. - **`file`** -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `file`.
This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive `SaveConfig()` would serialise env vars into the YAML file. By maintaining `f` as a clean copy, `Commit()` only persists what should be persisted. This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive `SaveConfig()` would serialise env vars into the YAML file. By maintaining `file` as a clean copy, `Commit()` only persists what should be persisted.
### Option ### Option
@ -91,19 +91,19 @@ func LoadEnv(prefix string) map[string]any // deprecated
``` ```
New(opts...) New(opts...)
| |
+-- create two viper instances (v, f) +-- create two viper instances (full, file)
+-- set env prefix on v ("CORE_CONFIG_" default) +-- set env prefix on full ("CORE_CONFIG_" default)
+-- set env key replacer ("." <-> "_") +-- set env key replacer ("." <-> "_")
+-- apply functional options +-- apply functional options
+-- default medium to io.Local if nil +-- default medium to io.Local if nil
+-- default path to ~/.core/config.yaml if empty +-- default path to ~/.core/config.yaml if empty
+-- enable v.AutomaticEnv() +-- enable full.AutomaticEnv()
+-- if config file exists: +-- if config file exists:
LoadFile(medium, path) LoadFile(medium, path)
+-- medium.Read(path) +-- medium.Read(path)
+-- detect config type from extension +-- detect config type from extension
+-- f.MergeConfig(content) +-- file.MergeConfig(content)
+-- v.MergeConfig(content) +-- full.MergeConfig(content)
``` ```
### Read (`Get`) ### Read (`Get`)
@ -113,15 +113,15 @@ Get(key, &out)
| |
+-- RLock +-- RLock
+-- if key == "": +-- if key == "":
| v.Unmarshal(out) // full config into a struct | full.Unmarshal(out) // full config into a struct
+-- else: +-- else:
| v.IsSet(key)? | full.IsSet(key)?
| yes -> v.UnmarshalKey(key, out) | yes -> full.UnmarshalKey(key, out)
| no -> error "key not found" | no -> error "key not found"
+-- RUnlock +-- RUnlock
``` ```
Because `v` has `AutomaticEnv()` enabled, `v.IsSet(key)` returns true if the key exists in the file **or** as a `CORE_CONFIG_*` environment variable. Because `full` has `AutomaticEnv()` enabled, `full.IsSet(key)` returns true if the key exists in the file **or** as a `CORE_CONFIG_*` environment variable.
### Write (`Set` + `Commit`) ### Write (`Set` + `Commit`)
@ -129,32 +129,32 @@ Because `v` has `AutomaticEnv()` enabled, `v.IsSet(key)` returns true if the key
Set(key, value) Set(key, value)
| |
+-- Lock +-- Lock
+-- f.Set(key, value) // track for persistence +-- file.Set(key, value) // track for persistence
+-- v.Set(key, value) // make visible to Get() +-- full.Set(key, value) // make visible to Get()
+-- Unlock +-- Unlock
Commit() Commit()
| |
+-- Lock +-- Lock
+-- Save(medium, path, f.AllSettings()) +-- Save(medium, path, file.AllSettings())
| +-- yaml.Marshal(data) | +-- yaml.Marshal(data)
| +-- medium.EnsureDir(dir) | +-- medium.EnsureDir(dir)
| +-- medium.Write(path, content) | +-- medium.Write(path, content)
+-- Unlock +-- Unlock
``` ```
Note that `Commit()` serialises `f.AllSettings()`, not `v.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file. Note that `Commit()` serialises `file.AllSettings()`, not `full.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file.
### File Loading (`LoadFile`) ### File Loading (`LoadFile`)
`LoadFile` supports both YAML and `.env` files. The config type is inferred from the file extension: `LoadFile` supports YAML, JSON, TOML, and `.env` files. The config type is inferred from the file extension:
- `.yaml`, `.yml` -- YAML - `.yaml`, `.yml` -- YAML
- `.json` -- JSON - `.json` -- JSON
- `.toml` -- TOML - `.toml` -- TOML
- `.env` (no extension, basename `.env`) -- dotenv format - `.env` (no extension, basename `.env`) -- dotenv format
Content is merged (not replaced) into both `v` and `f` via `MergeConfig`, so multiple files can be layered. Content is merged (not replaced) into both `full` and `file` via `MergeConfig`, so multiple files can be layered.
## Concurrency ## Concurrency

View file

@ -5,7 +5,7 @@ description: Layered configuration management for the Core framework with file,
# config # 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 ## Module Path

30
env.go
View file

@ -3,17 +3,36 @@ package config
import ( import (
"iter" "iter"
"os" "os"
"sort"
"strings" "strings"
) )
func normaliseEnvPrefix(prefix string) string {
if prefix == "" || strings.HasSuffix(prefix, "_") {
return prefix
}
return prefix + "_"
}
// Env returns an iterator over environment variables with the given prefix, // Env returns an iterator over environment variables with the given prefix,
// providing them as dot-notation keys and values. // providing them as dot-notation keys and values.
// //
// The prefix may be supplied with or without a trailing underscore.
//
// For example, with prefix "CORE_CONFIG_": // For example, with prefix "CORE_CONFIG_":
// //
// CORE_CONFIG_FOO_BAR=baz -> yields ("foo.bar", "baz") // CORE_CONFIG_FOO_BAR=baz -> yields ("foo.bar", "baz")
func Env(prefix string) iter.Seq2[string, any] { func Env(prefix string) iter.Seq2[string, any] {
return func(yield func(string, any) bool) { return func(yield func(string, any) bool) {
prefix = normaliseEnvPrefix(prefix)
type entry struct {
key string
value any
}
var entries []entry
for _, env := range os.Environ() { for _, env := range os.Environ() {
if !strings.HasPrefix(env, prefix) { if !strings.HasPrefix(env, prefix) {
continue continue
@ -27,12 +46,19 @@ func Env(prefix string) iter.Seq2[string, any] {
name := parts[0] name := parts[0]
value := parts[1] value := parts[1]
// Strip prefix and convert to dot notation
key := strings.TrimPrefix(name, prefix) key := strings.TrimPrefix(name, prefix)
key = strings.ToLower(key) key = strings.ToLower(key)
key = strings.ReplaceAll(key, "_", ".") key = strings.ReplaceAll(key, "_", ".")
if !yield(key, value) { entries = append(entries, entry{key: key, value: value})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].key < entries[j].key
})
for _, entry := range entries {
if !yield(entry.key, entry.value) {
return return
} }
} }

8
go.mod
View file

@ -1,11 +1,11 @@
module forge.lthn.ai/core/config module dappco.re/go/core/config
go 1.26.0 go 1.26.0
require ( require (
forge.lthn.ai/core/go v0.3.1 dappco.re/go/core v0.3.3
forge.lthn.ai/core/go-io v0.1.5 dappco.re/go/core/io v0.1.7
forge.lthn.ai/core/go-log v0.0.4 dappco.re/go/core/log v0.0.4
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1

8
go.sum
View file

@ -1,7 +1,7 @@
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc= forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM= forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=

View file

@ -18,6 +18,8 @@ type Service struct {
type ServiceOptions struct { type ServiceOptions struct {
// Path overrides the default config file path. // Path overrides the default config file path.
Path string Path string
// EnvPrefix overrides the default environment variable prefix.
EnvPrefix string
// Medium overrides the default storage medium. // Medium overrides the default storage medium.
Medium coreio.Medium Medium coreio.Medium
} }
@ -39,6 +41,9 @@ func (s *Service) OnStartup(_ context.Context) error {
if opts.Path != "" { if opts.Path != "" {
configOpts = append(configOpts, WithPath(opts.Path)) configOpts = append(configOpts, WithPath(opts.Path))
} }
if opts.EnvPrefix != "" {
configOpts = append(configOpts, WithEnvPrefix(opts.EnvPrefix))
}
if opts.Medium != nil { if opts.Medium != nil {
configOpts = append(configOpts, WithMedium(opts.Medium)) configOpts = append(configOpts, WithMedium(opts.Medium))
} }