From cd93ad8081fa112a5f9f1d521fa410ea13441f14 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 14 Mar 2026 10:21:18 +0000 Subject: [PATCH] feat: rename go-config to core/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module path changed from forge.lthn.ai/core/go-config to forge.lthn.ai/core/config. Package name remains "config" — only the module path changed. Co-Authored-By: Virgil --- .core/build.yaml | 24 ++++ .core/release.yaml | 20 +++ CLAUDE.md | 54 +++++++++ config.go | 237 ++++++++++++++++++++++++++++++++++++ config_test.go | 281 +++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 188 +++++++++++++++++++++++++++++ docs/development.md | 121 +++++++++++++++++++ docs/index.md | 141 ++++++++++++++++++++++ env.go | 52 ++++++++ go.mod | 28 +++++ go.sum | 48 ++++++++ service.go | 91 ++++++++++++++ 12 files changed, 1285 insertions(+) create mode 100644 .core/build.yaml create mode 100644 .core/release.yaml create mode 100644 CLAUDE.md create mode 100644 config.go create mode 100644 config_test.go create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 docs/index.md create mode 100644 env.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 service.go diff --git a/.core/build.yaml b/.core/build.yaml new file mode 100644 index 0000000..28ab608 --- /dev/null +++ b/.core/build.yaml @@ -0,0 +1,24 @@ +version: 1 + +project: + name: config + description: Configuration management + binary: "" + +build: + cgo: false + flags: + - -trimpath + ldflags: + - -s + - -w + +targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 diff --git a/.core/release.yaml b/.core/release.yaml new file mode 100644 index 0000000..5d63525 --- /dev/null +++ b/.core/release.yaml @@ -0,0 +1,20 @@ +version: 1 + +project: + name: config + repository: core/config + +publishers: [] + +changelog: + include: + - feat + - fix + - perf + - refactor + exclude: + - chore + - docs + - style + - test + - ci diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..51464a0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Test Commands + +This project uses the Core CLI (`core` binary), not `go` directly. + +```bash +core go test # run all tests +core go test --run TestConfig_Get_Good # run a single test +core go cov # test with coverage +core go cov --open # coverage + open HTML report + +core go qa # format, vet, lint, test +core go qa full # adds race detector, vuln scan, security audit + +core go fmt # format +core go vet # vet +core go lint # lint +``` + +This is a library package — there is no binary to build or run. + +## Architecture + +**Dual-Viper pattern**: `Config` holds two `*viper.Viper` instances: +- `v` (full) — file + env + defaults; used for all reads (`Get`, `All`) +- `f` (file-only) — file + explicit `Set()` calls; used for persistence (`Commit`) + +This prevents environment variables from leaking into saved config files. When implementing new features, maintain this invariant: writes go to both `v` and `f`; reads come from `v`; persistence comes from `f`. + +**Resolution priority** (ascending): defaults → file → env vars (`CORE_CONFIG_*`) → `Set()` + +**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. + +## Conventions + +- **UK English** in comments and documentation (colour, organisation, centre) +- **Error wrapping**: `coreerr.E(caller, message, underlying)` from `go-log` +- **Test naming**: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases) +- **Functional options**: `New()` takes `...Option` (e.g. `WithMedium`, `WithPath`, `WithEnvPrefix`) +- **Conventional commits**: `type(scope): description` +- **Go workspace**: module is part of `~/Code/go.work` + +## Dependencies + +- `forge.lthn.ai/core/go-io` — `Medium` interface for storage +- `forge.lthn.ai/core/go-log` — `coreerr.E()` error helper +- `forge.lthn.ai/core/go/pkg/core` — `core.Config`, `core.Startable`, `core.ServiceRuntime` interfaces +- `github.com/spf13/viper` — configuration engine +- `github.com/stretchr/testify` — test assertions diff --git a/config.go b/config.go new file mode 100644 index 0000000..35c384c --- /dev/null +++ b/config.go @@ -0,0 +1,237 @@ +// Package config provides layered configuration management for the Core framework. +// +// Configuration values are resolved in priority order: defaults -> file -> env -> flags. +// Values are stored in a YAML file at ~/.core/config.yaml by default. +// +// Keys use dot notation for nested access: +// +// cfg.Set("dev.editor", "vim") +// var editor string +// cfg.Get("dev.editor", &editor) +package config + +import ( + "fmt" + "iter" + "maps" + "os" + "path/filepath" + "strings" + "sync" + + coreio "forge.lthn.ai/core/go-io" + coreerr "forge.lthn.ai/core/go-log" + core "forge.lthn.ai/core/go/pkg/core" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +// Config implements the core.Config interface with layered resolution. +// It uses viper as the underlying configuration engine. +type Config struct { + mu sync.RWMutex + v *viper.Viper // Full configuration (file + env + defaults) + f *viper.Viper // File-backed configuration only (for persistence) + medium coreio.Medium + path string +} + +// Option is a functional option for configuring a Config instance. +type Option func(*Config) + +// WithMedium sets the storage medium for configuration file operations. +func WithMedium(m coreio.Medium) Option { + return func(c *Config) { + c.medium = m + } +} + +// WithPath sets the path to the configuration file. +func WithPath(path string) Option { + return func(c *Config) { + c.path = path + } +} + +// WithEnvPrefix sets the prefix for environment variables. +func WithEnvPrefix(prefix string) Option { + return func(c *Config) { + c.v.SetEnvPrefix(prefix) + } +} + +// New creates a new Config instance with the given options. +// If no medium is provided, it defaults to io.Local. +// If no path is provided, it defaults to ~/.core/config.yaml. +func New(opts ...Option) (*Config, error) { + c := &Config{ + v: viper.New(), + f: viper.New(), + } + + // Configure viper defaults + c.v.SetEnvPrefix("CORE_CONFIG") + c.v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + for _, opt := range opts { + opt(c) + } + + if c.medium == nil { + c.medium = coreio.Local + } + + if c.path == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, coreerr.E("config.New", "failed to determine home directory", err) + } + c.path = filepath.Join(home, ".core", "config.yaml") + } + + c.v.AutomaticEnv() + + // Load existing config file if it exists + if c.medium.Exists(c.path) { + if err := c.LoadFile(c.medium, c.path); err != nil { + return nil, coreerr.E("config.New", "failed to load config file", err) + } + } + + return c, nil +} + +// 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). +func (c *Config) LoadFile(m coreio.Medium, path string) error { + c.mu.Lock() + defer c.mu.Unlock() + + content, err := m.Read(path) + if err != nil { + return coreerr.E("config.LoadFile", fmt.Sprintf("failed to read config file: %s", 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 { + return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file (f): %s", path), err) + } + + // Load into full viper + c.v.SetConfigType(configType) + 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 +} + +// 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. +func (c *Config) Get(key string, out any) error { + c.mu.RLock() + defer c.mu.RUnlock() + + if key == "" { + if err := c.v.Unmarshal(out); err != nil { + return coreerr.E("config.Get", "failed to unmarshal full config", err) + } + return nil + } + + if !c.v.IsSet(key) { + return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil) + } + + if err := c.v.UnmarshalKey(key, out); err != nil { + return coreerr.E("config.Get", fmt.Sprintf("failed to unmarshal key: %s", key), err) + } + return nil +} + +// Set stores a configuration value in memory. +// Call Commit() to persist changes to disk. +func (c *Config) Set(key string, v any) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.f.Set(key, v) + c.v.Set(key, v) + return nil +} + +// Commit persists any changes made via Set() to the configuration file on disk. +// This will only save the configuration that was loaded from the file or explicitly Set(), +// preventing environment variable leakage. +func (c *Config) Commit() error { + c.mu.Lock() + defer c.mu.Unlock() + + if err := Save(c.medium, c.path, c.f.AllSettings()); err != nil { + return coreerr.E("config.Commit", "failed to save config", err) + } + return nil +} + +// All returns an iterator over all configuration values (including environment variables). +func (c *Config) All() iter.Seq2[string, any] { + c.mu.RLock() + defer c.mu.RUnlock() + return maps.All(c.v.AllSettings()) +} + +// Path returns the path to the configuration file. +func (c *Config) Path() string { + return c.path +} + +// Load reads a YAML configuration file from the given medium and path. +// Returns the parsed data as a map, or an error if the file cannot be read or parsed. +// Deprecated: Use Config.LoadFile instead. +func Load(m coreio.Medium, path string) (map[string]any, error) { + content, err := m.Read(path) + if err != nil { + return nil, coreerr.E("config.Load", "failed to read config file: "+path, err) + } + + v := viper.New() + v.SetConfigType("yaml") + if err := v.ReadConfig(strings.NewReader(content)); err != nil { + return nil, coreerr.E("config.Load", "failed to parse config file: "+path, err) + } + + return v.AllSettings(), nil +} + +// Save writes configuration data to a YAML file at the given path. +// It ensures the parent directory exists before writing. +func Save(m coreio.Medium, path string, data map[string]any) error { + out, err := yaml.Marshal(data) + if err != nil { + return coreerr.E("config.Save", "failed to marshal config", err) + } + + dir := filepath.Dir(path) + if err := m.EnsureDir(dir); err != nil { + return coreerr.E("config.Save", "failed to create config directory: "+dir, err) + } + + if err := m.Write(path, string(out)); err != nil { + return coreerr.E("config.Save", "failed to write config file: "+path, err) + } + + return nil +} + +// Ensure Config implements core.Config at compile time. +var _ core.Config = (*Config)(nil) diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..6e75cef --- /dev/null +++ b/config_test.go @@ -0,0 +1,281 @@ +package config + +import ( + "maps" + "os" + "testing" + + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" +) + +func TestConfig_Get_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + err = cfg.Set("app.name", "core") + assert.NoError(t, err) + + var name string + err = cfg.Get("app.name", &name) + assert.NoError(t, err) + assert.Equal(t, "core", name) +} + +func TestConfig_Get_Bad(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var value string + err = cfg.Get("nonexistent.key", &value) + assert.Error(t, err) + assert.Contains(t, err.Error(), "key not found") +} + +func TestConfig_Set_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + err = cfg.Set("dev.editor", "vim") + assert.NoError(t, err) + + err = cfg.Commit() + assert.NoError(t, err) + + // Verify the value was saved to the medium + content, readErr := m.Read("/tmp/test/config.yaml") + assert.NoError(t, readErr) + assert.Contains(t, content, "editor: vim") + + // Verify we can read it back + var editor string + err = cfg.Get("dev.editor", &editor) + assert.NoError(t, err) + assert.Equal(t, "vim", editor) +} + +func TestConfig_Set_Nested_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + err = cfg.Set("a.b.c", "deep") + assert.NoError(t, err) + + var val string + err = cfg.Get("a.b.c", &val) + assert.NoError(t, err) + assert.Equal(t, "deep", val) +} + +func TestConfig_All_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + _ = cfg.Set("key1", "val1") + _ = cfg.Set("key2", "val2") + + all := maps.Collect(cfg.All()) + assert.Equal(t, "val1", all["key1"]) + assert.Equal(t, "val2", all["key2"]) +} + +func TestConfig_Path_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/custom/path/config.yaml")) + assert.NoError(t, err) + + assert.Equal(t, "/custom/path/config.yaml", cfg.Path()) +} + +func TestConfig_Load_Existing_Good(t *testing.T) { + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "app:\n name: existing\n" + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var name string + err = cfg.Get("app.name", &name) + assert.NoError(t, err) + assert.Equal(t, "existing", name) +} + +func TestConfig_Env_Good(t *testing.T) { + // Set environment variable + t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") + + m := io.NewMockMedium() + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var editor string + err = cfg.Get("dev.editor", &editor) + assert.NoError(t, err) + assert.Equal(t, "nano", editor) +} + +func TestConfig_Env_Overrides_File_Good(t *testing.T) { + // Set file config + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "dev:\n editor: vim\n" + + // Set environment override + t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var editor string + err = cfg.Get("dev.editor", &editor) + assert.NoError(t, err) + assert.Equal(t, "nano", editor) +} + +func TestConfig_Assign_Types_Good(t *testing.T) { + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "count: 42\nenabled: true\nratio: 3.14\n" + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + var count int + err = cfg.Get("count", &count) + assert.NoError(t, err) + assert.Equal(t, 42, count) + + var enabled bool + err = cfg.Get("enabled", &enabled) + assert.NoError(t, err) + assert.True(t, enabled) + + var ratio float64 + err = cfg.Get("ratio", &ratio) + assert.NoError(t, err) + assert.InDelta(t, 3.14, ratio, 0.001) +} + +func TestConfig_Assign_Any_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) + assert.NoError(t, err) + + _ = cfg.Set("key", "value") + + var val any + err = cfg.Get("key", &val) + assert.NoError(t, err) + assert.Equal(t, "value", val) +} + +func TestConfig_DefaultPath_Good(t *testing.T) { + m := io.NewMockMedium() + + cfg, err := New(WithMedium(m)) + assert.NoError(t, err) + + home, _ := os.UserHomeDir() + assert.Equal(t, home+"/.core/config.yaml", cfg.Path()) +} + +func TestLoadEnv_Good(t *testing.T) { + t.Setenv("CORE_CONFIG_FOO_BAR", "baz") + t.Setenv("CORE_CONFIG_SIMPLE", "value") + + result := LoadEnv("CORE_CONFIG_") + assert.Equal(t, "baz", result["foo.bar"]) + assert.Equal(t, "value", result["simple"]) +} + +func TestLoad_Bad(t *testing.T) { + m := io.NewMockMedium() + + _, err := Load(m, "/nonexistent/file.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read config file") +} + +func TestLoad_InvalidYAML_Bad(t *testing.T) { + m := io.NewMockMedium() + m.Files["/tmp/test/config.yaml"] = "invalid: yaml: content: [[[[" + + _, err := Load(m, "/tmp/test/config.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse config file") +} + +func TestSave_Good(t *testing.T) { + m := io.NewMockMedium() + + data := map[string]any{ + "key": "value", + } + + err := Save(m, "/tmp/test/config.yaml", data) + assert.NoError(t, err) + + content, readErr := m.Read("/tmp/test/config.yaml") + assert.NoError(t, readErr) + assert.Contains(t, content, "key: value") +} + +func TestConfig_LoadFile_Env(t *testing.T) { + m := io.NewMockMedium() + m.Files["/.env"] = "FOO=bar\nBAZ=qux" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + err = cfg.LoadFile(m, "/.env") + assert.NoError(t, err) + + var foo string + err = cfg.Get("foo", &foo) + assert.NoError(t, err) + assert.Equal(t, "bar", foo) +} + +func TestConfig_WithEnvPrefix(t *testing.T) { + t.Setenv("MYAPP_SETTING", "secret") + + m := io.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 TestConfig_Get_EmptyKey(t *testing.T) { + m := io.NewMockMedium() + m.Files["/config.yaml"] = "app:\n name: test\nversion: 1" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + type AppConfig struct { + App struct { + Name string `mapstructure:"name"` + } `mapstructure:"app"` + Version int `mapstructure:"version"` + } + + var full AppConfig + err = cfg.Get("", &full) + assert.NoError(t, err) + assert.Equal(t, "test", full.App.Name) + assert.Equal(t, 1, full.Version) +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3fb3fbc --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,188 @@ +--- +title: Architecture +description: Internal design of config -- dual-viper layering, the Medium abstraction, and the framework service wrapper. +--- + +# Architecture + +## Design Goals + +1. **Layered resolution** -- a single `Get()` call checks environment, file, and defaults without the caller needing to know which source won. +2. **Safe persistence** -- environment variables must never bleed into the saved config file. +3. **Pluggable storage** -- the file system is abstracted behind `io.Medium`, making tests deterministic and enabling future remote backends. +4. **Framework integration** -- the package satisfies the `core.Config` interface, so any Core service can consume configuration without importing this package directly. + +## Key Types + +### Config + +```go +type Config struct { + mu sync.RWMutex + v *viper.Viper // full configuration (file + env + defaults) + f *viper.Viper // file-backed configuration only (for persistence) + medium coreio.Medium + path string +} +``` + +`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`. +- **`f`** (file) -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `f`. + +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. + +### Option + +```go +type Option func(*Config) +``` + +Functional options configure the `Config` at creation time: + +| Option | Effect | +|-------------------|----------------------------------------------------| +| `WithMedium(m)` | Sets the storage backend (defaults to `io.Local`) | +| `WithPath(path)` | Sets the config file path (defaults to `~/.core/config.yaml`) | +| `WithEnvPrefix(p)`| Changes the environment variable prefix (defaults to `CORE_CONFIG`) | + +### Service + +```go +type Service struct { + *core.ServiceRuntime[ServiceOptions] + config *Config +} +``` + +`Service` wraps `Config` as a framework-managed service. It embeds `ServiceRuntime` for typed options and implements two interfaces: + +- **`core.Config`** -- `Get(key, out)` and `Set(key, v)` +- **`core.Startable`** -- `OnStartup(ctx)` triggers config file loading during the application lifecycle + +The service is registered as a factory function: + +```go +core.New(core.WithService(config.NewConfigService)) +``` + +The factory receives the `*core.Core` instance, constructs the service, and returns it. The framework calls `OnStartup` at the appropriate lifecycle phase, at which point the config file is loaded. + +### Env / LoadEnv + +```go +func Env(prefix string) iter.Seq2[string, any] +func LoadEnv(prefix string) map[string]any // deprecated +``` + +`Env` returns a Go 1.23+ iterator over environment variables matching a given prefix, yielding `(dotKey, value)` pairs. The conversion logic: + +1. Filter `os.Environ()` for entries starting with `prefix` +2. Strip the prefix +3. Lowercase and replace `_` with `.` + +`LoadEnv` is the older materialising variant and is deprecated in favour of the iterator. + +## Data Flow + +### Initialisation (`New`) + +``` +New(opts...) + | + +-- create two viper instances (v, f) + +-- set env prefix on v ("CORE_CONFIG_" default) + +-- set env key replacer ("." <-> "_") + +-- apply functional options + +-- default medium to io.Local if nil + +-- default path to ~/.core/config.yaml if empty + +-- enable v.AutomaticEnv() + +-- if config file exists: + LoadFile(medium, path) + +-- medium.Read(path) + +-- detect config type from extension + +-- f.MergeConfig(content) + +-- v.MergeConfig(content) +``` + +### Read (`Get`) + +``` +Get(key, &out) + | + +-- RLock + +-- if key == "": + | v.Unmarshal(out) // full config into a struct + +-- else: + | v.IsSet(key)? + | yes -> v.UnmarshalKey(key, out) + | no -> error "key not found" + +-- 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. + +### Write (`Set` + `Commit`) + +``` +Set(key, value) + | + +-- Lock + +-- f.Set(key, value) // track for persistence + +-- v.Set(key, value) // make visible to Get() + +-- Unlock + +Commit() + | + +-- Lock + +-- Save(medium, path, f.AllSettings()) + | +-- yaml.Marshal(data) + | +-- medium.EnsureDir(dir) + | +-- medium.Write(path, content) + +-- 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. + +### File Loading (`LoadFile`) + +`LoadFile` supports both YAML and `.env` files. The config type is inferred from the file extension: + +- `.yaml`, `.yml` -- YAML +- `.json` -- JSON +- `.toml` -- TOML +- `.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. + +## Concurrency + +All public methods on `Config` and `Service` are safe for concurrent use. A `sync.RWMutex` protects the internal state: + +- `Get`, `All` take a read lock +- `Set`, `Commit`, `LoadFile` take a write lock + +## The Medium Abstraction + +File operations go through `io.Medium` (from `forge.lthn.ai/core/go-io`), not `os` directly. This means: + +- **Tests** use `io.NewMockMedium()` -- an in-memory filesystem with a `Files` map +- **Production** uses `io.Local` -- the real local filesystem +- **Future** backends (S3, embedded assets) can implement `Medium` without changing config code + +## Compile-Time Interface Checks + +The package includes two compile-time assertions at the bottom of the respective files: + +```go +var _ core.Config = (*Config)(nil) // config.go +var _ core.Config = (*Service)(nil) // service.go +var _ core.Startable = (*Service)(nil) // service.go +``` + +These ensure that if the `core.Config` or `core.Startable` interfaces ever change, this package will fail to compile rather than fail at runtime. + +## Licence + +EUPL-1.2 diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ac49ea0 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,121 @@ +--- +title: Development +description: How to build, test, and contribute to config. +--- + +# Development + +## Prerequisites + +- **Go 1.26+** +- **Core CLI** (`core` binary) for running tests and quality checks +- The Go workspace at `~/Code/go.work` should include this module + +## Running Tests + +```bash +cd /path/to/config + +# All tests +core go test + +# Single test +core go test --run TestConfig_Get_Good + +# With coverage +core go cov +core go cov --open # opens HTML report in browser +``` + +### Test Naming Convention + +Tests follow the `_Good` / `_Bad` / `_Ugly` suffix pattern: + +| Suffix | Meaning | +|---------|---------------------------------| +| `_Good` | Happy path -- expected success | +| `_Bad` | Expected error conditions | +| `_Ugly` | Panics, edge cases, corruption | + +### Mock Medium + +Tests use `io.NewMockMedium()` to avoid touching the real filesystem. Pre-populate it by writing directly to the `Files` map: + +```go +m := io.NewMockMedium() +m.Files["/tmp/test/config.yaml"] = "app:\n name: existing\n" + +cfg, err := config.New(config.WithMedium(m), config.WithPath("/tmp/test/config.yaml")) +``` + +This pattern keeps tests fast, deterministic, and parallelisable. + +## Quality Checks + +```bash +# Format, vet, lint, test in one pass +core go qa + +# Full suite (adds race detector, vulnerability scan, security audit) +core go qa full + +# Individual commands +core go fmt +core go vet +core go lint +``` + +## Code Style + +- **UK English** in comments and documentation (colour, organisation, centre) +- **`declare(strict_types=1)`** equivalent: all functions have explicit parameter and return types +- **Error wrapping**: use `coreerr.E(caller, message, underlying)` from `go-log` +- **Formatting**: standard `gofmt` / `goimports` + +## Project Structure + +``` +config/ + .core/ + build.yaml # Build configuration (targets, flags) + release.yaml # Release configuration (changelog rules) + config.go # Config struct, New(), Get/Set/Commit, Load/Save + config_test.go # Tests + env.go # Env() iterator, LoadEnv() (deprecated) + service.go # Framework service wrapper (Startable) + go.mod + go.sum + docs/ + index.md # This documentation + architecture.md # Internal design + development.md # Build and contribution guide +``` + +## Adding a New Feature + +1. **Write the test first** -- add a `TestFeatureName_Good` (and `_Bad` if error paths exist) to `config_test.go`. +2. **Implement** -- keep the dual-viper invariant: writes go to both `v` and `f`; reads come from `v`; persistence comes from `f`. +3. **Run QA** -- `core go qa` must pass before committing. +4. **Update docs** -- if the change affects public API, update `docs/index.md` and `docs/architecture.md`. + +## Interface Compliance + +`Config` and `Service` both satisfy `core.Config`. `Service` additionally satisfies `core.Startable`. These are enforced at compile time: + +```go +var _ core.Config = (*Config)(nil) +var _ core.Config = (*Service)(nil) +var _ core.Startable = (*Service)(nil) +``` + +If you add a new interface method upstream in `core/go`, the compiler will tell you what to implement here. + +## Commit Guidelines + +- Use conventional commits: `type(scope): description` +- Include `Co-Authored-By: Claude Opus 4.6 ` when pair-programming with Claude +- Push via SSH: `ssh://git@forge.lthn.ai:2223/core/config.git` + +## Licence + +EUPL-1.2 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..dea219d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,141 @@ +--- +title: config +description: Layered configuration management for the Core framework with file, environment, and in-memory resolution. +--- + +# 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. + +## Module Path + +``` +forge.lthn.ai/core/config +``` + +Requires **Go 1.26+**. + +## Quick Start + +### Standalone usage + +```go +package main + +import ( + "fmt" + config "forge.lthn.ai/core/config" +) + +func main() { + cfg, err := config.New() // loads ~/.core/config.yaml if it exists + if err != nil { + panic(err) + } + + // Write a value and persist it + _ = cfg.Set("dev.editor", "vim") + _ = cfg.Commit() + + // Read it back + var editor string + _ = cfg.Get("dev.editor", &editor) + fmt.Println(editor) // "vim" +} +``` + +### As a Core framework service + +```go +import ( + config "forge.lthn.ai/core/config" + "forge.lthn.ai/core/go/pkg/core" +) + +app, _ := core.New( + core.WithService(config.NewConfigService), +) +// The config service loads automatically during OnStartup. +// Retrieve it later via core.ServiceFor[*config.Service](app). +``` + +## Package Layout + +| File | Purpose | +|-----------------|----------------------------------------------------------------| +| `config.go` | Core `Config` struct -- layered Get/Set, file load, commit | +| `env.go` | Environment variable iteration and prefix-based loading | +| `service.go` | Framework service wrapper with lifecycle (`Startable`) support | +| `config_test.go`| Tests following the `_Good` / `_Bad` / `_Ugly` convention | + +## Dependencies + +| Module | Role | +|-----------------------------------|-----------------------------------------| +| `forge.lthn.ai/core/go` | Core framework (`core.Config` interface, `ServiceRuntime`) | +| `forge.lthn.ai/core/go-io` | Storage abstraction (`Medium` for reading/writing files) | +| `forge.lthn.ai/core/go-log` | Contextual error helper (`E()`) | +| `github.com/spf13/viper` | Underlying configuration engine | +| `gopkg.in/yaml.v3` | YAML serialisation for `Commit()` | + +## Configuration Priority + +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 + +Environment variables always override file values. An explicit `Set()` call overrides everything. + +## Key Access + +All keys use **dot notation** for nested values: + +```go +cfg.Set("a.b.c", "deep") + +var val string +cfg.Get("a.b.c", &val) // "deep" +``` + +This maps to YAML structure: + +```yaml +a: + b: + c: deep +``` + +## Environment Variable Mapping + +Environment variables are mapped to dot-notation keys by: + +1. Stripping the prefix (default `CORE_CONFIG_`) +2. Lowercasing +3. Replacing `_` with `.` + +For example, `CORE_CONFIG_DEV_EDITOR=nano` resolves to key `dev.editor` with value `"nano"`. + +You can change the prefix with `WithEnvPrefix`: + +```go +cfg, _ := config.New(config.WithEnvPrefix("MYAPP")) +// MYAPP_SETTING=secret -> key "setting" +``` + +## Persisting Changes + +`Set()` only writes to memory. Call `Commit()` to flush changes to disk: + +```go +cfg.Set("dev.editor", "vim") +cfg.Commit() // writes to ~/.core/config.yaml +``` + +`Commit()` only persists values that were loaded from the file or explicitly set via `Set()`. Environment variable values are never leaked into the config file. + +## Licence + +EUPL-1.2 diff --git a/env.go b/env.go new file mode 100644 index 0000000..64c0372 --- /dev/null +++ b/env.go @@ -0,0 +1,52 @@ +package config + +import ( + "iter" + "os" + "strings" +) + +// Env returns an iterator over environment variables with the given prefix, +// providing them as dot-notation keys and values. +// +// For example, with prefix "CORE_CONFIG_": +// +// CORE_CONFIG_FOO_BAR=baz -> yields ("foo.bar", "baz") +func Env(prefix string) iter.Seq2[string, any] { + return func(yield func(string, any) bool) { + for _, env := range os.Environ() { + if !strings.HasPrefix(env, prefix) { + continue + } + + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + + name := parts[0] + value := parts[1] + + // Strip prefix and convert to dot notation + key := strings.TrimPrefix(name, prefix) + key = strings.ToLower(key) + key = strings.ReplaceAll(key, "_", ".") + + if !yield(key, value) { + return + } + } + } +} + +// LoadEnv parses environment variables with the given prefix and returns +// them as a flat map with dot-notation keys. +// +// Deprecated: Use Env for iterative access or collect into a map manually. +func LoadEnv(prefix string) map[string]any { + result := make(map[string]any) + for k, v := range Env(prefix) { + result[k] = v + } + return result +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..82fb154 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module forge.lthn.ai/core/config + +go 1.26.0 + +require ( + forge.lthn.ai/core/go-io v0.0.3 + forge.lthn.ai/core/go-log v0.0.1 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..50398aa --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI= +forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= +forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= +forge.lthn.ai/core/go-log v0.0.1/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/service.go b/service.go new file mode 100644 index 0000000..8c7acf8 --- /dev/null +++ b/service.go @@ -0,0 +1,91 @@ +package config + +import ( + "context" + + coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/go-io" + core "forge.lthn.ai/core/go/pkg/core" +) + +// Service wraps Config as a framework service with lifecycle support. +type Service struct { + *core.ServiceRuntime[ServiceOptions] + config *Config +} + +// ServiceOptions holds configuration for the config service. +type ServiceOptions struct { + // Path overrides the default config file path. + Path string + // Medium overrides the default storage medium. + Medium io.Medium +} + +// NewConfigService creates a new config service factory for the Core framework. +// Register it with core.WithService(config.NewConfigService). +func NewConfigService(c *core.Core) (any, error) { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + return svc, nil +} + +// OnStartup loads the configuration file during application startup. +func (s *Service) OnStartup(_ context.Context) error { + opts := s.Opts() + + var configOpts []Option + if opts.Path != "" { + configOpts = append(configOpts, WithPath(opts.Path)) + } + if opts.Medium != nil { + configOpts = append(configOpts, WithMedium(opts.Medium)) + } + + cfg, err := New(configOpts...) + if err != nil { + return err + } + + s.config = cfg + return nil +} + +// Get retrieves a configuration value by key. +func (s *Service) Get(key string, out any) error { + if s.config == nil { + return coreerr.E("config.Service.Get", "config not loaded", nil) + } + return s.config.Get(key, out) +} + +// Set stores a configuration value by key. +func (s *Service) Set(key string, v any) error { + if s.config == nil { + return coreerr.E("config.Service.Set", "config not loaded", nil) + } + return s.config.Set(key, v) +} + +// Commit persists any configuration changes to disk. +func (s *Service) Commit() error { + if s.config == nil { + return coreerr.E("config.Service.Commit", "config not loaded", nil) + } + return s.config.Commit() +} + +// LoadFile merges a configuration file into the central configuration. +func (s *Service) LoadFile(m io.Medium, path string) error { + if s.config == nil { + return coreerr.E("config.Service.LoadFile", "config not loaded", nil) + } + return s.config.LoadFile(m, path) +} + +// Ensure Service implements core.Config and Startable at compile time. +var ( + _ core.Config = (*Service)(nil) + _ core.Startable = (*Service)(nil) +)