docs: add human-friendly documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-11 13:02:40 +00:00
parent ddf301fc24
commit 67bffa5439
3 changed files with 450 additions and 0 deletions

188
docs/architecture.md Normal file
View file

@ -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

121
docs/development.md Normal file
View file

@ -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 <noreply@anthropic.com>` when pair-programming with Claude
- Push via SSH: `ssh://git@forge.lthn.ai:2223/core/go-config.git`
## Licence
EUPL-1.2

141
docs/index.md Normal file
View file

@ -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