diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..93ce070 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,188 @@ +--- +title: Architecture +description: Internal design of go-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..8d14843 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,121 @@ +--- +title: Development +description: How to build, test, and contribute to go-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/go-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 + +``` +go-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/go-config.git` + +## Licence + +EUPL-1.2 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..27ba6bc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,141 @@ +--- +title: go-config +description: Layered configuration management for the Core framework with file, environment, and in-memory resolution. +--- + +# go-config + +`forge.lthn.ai/core/go-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/go-config +``` + +Requires **Go 1.26+**. + +## Quick Start + +### Standalone usage + +```go +package main + +import ( + "fmt" + config "forge.lthn.ai/core/go-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/go-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