- Go 98.8%
- Shell 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- Add EnvWithRules[T]: env var overlay that respects cascade/overwrite rules. Empty env vars on overwrite fields explicitly clear the value (explicit unset). Cascade fields skip empty env vars (backward compat). - Fix 0o777 → 0o755 directory permissions in load.go and write.go - Add CHANGELOG.md with v0.1.0 and v0.2.0 entries - Update README: EnvWithRules docs, empty-string-as-unset examples, updated load cascade diagram, updated Quick Start - Update config.go package doc with merge tag and EnvWithRules example - Add 6 tests for EnvWithRules (nil rules, overwrite empty, cascade empty, non-empty always applies, tag defaults, overwrite empty int) |
||
| .github/workflows | ||
| .opencode | ||
| cmd | ||
| core | ||
| docs | ||
| hooks | ||
| testdata | ||
| .gitignore | ||
| app.go | ||
| CHANGELOG.md | ||
| config.go | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| load_test.go | ||
| merge_test.go | ||
| README.md | ||
| stubs.go | ||
| write_test.go | ||
config
A lightweight, dependency-light configuration library for Go applications.
Structure
config/
├── config.go package doc
├── app.go App struct (re-exports from core)
├── stubs.go passthrough functions to core/
├── core/ implementation (load, merge, write, env, codec, tag, wildcard)
├── cmd/ CLI tools
├── docs/ documentation
└── README.md
Import git.merith.xyz/residual/config for the easy API. The core/ package has the full implementation.
Features
- Instance-based app configuration — no global state, no hardcoded paths
- Multi-format — TOML, YAML, and JSON config files
- Cascading merge — defaults → system → user → custom → env vars
- Per-field merge rules — cascade or overwrite, with wildcard support
- Struct tag merge defaults —
merge:"cascade"/merge:"overwrite"defines developer intent per field - Env var overrides — double-underscore separation (
PREFIX__KEY__SUBKEY) - Struct tags —
conf,default,merge,omitempty,envsep,raw
Quick Start
package main
import (
"fmt"
"git.merith.xyz/residual/config"
)
type AppConfig struct {
Port int `conf:"port" default:"8080" merge:"cascade"`
Host string `conf:"host" default:"localhost" merge:"overwrite"`
Debug bool `conf:"debug"`
}
func main() {
app := config.New("myapp", "residual")
app.Ext = "yaml" // or "toml", "json" (default: "toml")
// Build config from layers: defaults → system → user
// Each step applies its own residualconfig rules from the file.
cfg := config.Defaults[AppConfig]()
sysCfg, sysRules, _ := config.SystemRaw[AppConfig](app)
usrCfg, usrRules, _ := config.UserRaw[AppConfig](app)
cfg = config.MergeWithRules(cfg, sysCfg, sysRules)
cfg = config.MergeWithRules(cfg, usrCfg, usrRules)
// Env vars overlay — respects cascade/overwrite rules.
// Empty env vars on overwrite fields explicitly clear the value.
merged := config.MergeRules(sysRules, usrRules)
config.EnvWithRules(&cfg, app.Name, merged)
fmt.Printf("Port: %d, Host: %s\n", cfg.Port, cfg.Host)
}
App Configuration
// Default — TOML format
app := config.New("myapp", "residual")
// SystemPath: /etc/residual/myapp.toml
// UserPath: ~/.config/residual/myapp.toml
// Choose format
app.Ext = "yaml" // /etc/residual/myapp.yaml
app.Ext = "json" // /etc/residual/myapp.json
// Without group
app := config.New("myapp", "")
app.Ext = "yaml"
// SystemPath: /etc/myapp.yaml
// UserPath: ~/.config/myapp.yaml
The Ext field controls the file format. SystemPath() and UserPath() use it automatically.
Load Functions
// Tag defaults only (no files, no env)
cfg := config.Defaults[AppConfig]()
// From system config directory (format from app.Ext)
cfg, err := config.System[AppConfig](app)
// From user config directory (format from app.Ext)
cfg, err := config.User[AppConfig](app)
// From explicit file path (format auto-detected from extension)
cfg, err := config.Custom[AppConfig]("/path/to/config.yaml")
// Raw variants — also return ResidualConfig rules from the file
cfg, rules, err := config.SystemRaw[AppConfig](app)
cfg, rules, err := config.UserRaw[AppConfig](app)
cfg, rules, err := config.CustomRaw[AppConfig]("/path/to/config.yaml")
// From environment variables (double-underscore separation)
cfg := config.Env[AppConfig]("MYAPP")
// MYAPP__PORT=3000
// MYAPP__HOST=new.host
// From environment variables with cascade/overwrite rules
config.EnvWithRules(&cfg, "MYAPP", rules)
// Empty env vars on overwrite fields explicitly clear the value
System and User return the zero value (no error) when the config file does not exist. This is intentional — missing files are not errors; the caller merges layers and missing layers are transparent.
Custom loads from any explicit path and auto-detects format from the file extension (.toml, .yaml, .yml, .json).
SystemRaw, UserRaw, and CustomRaw return the decoded struct plus the *ResidualConfig rules from the file's residualconfig section. Use these with MergeWithRules for per-field merge control.
Merge Functions
// Cascade: zero fields in overlay are transparent — base values win
result := config.UseConfig(base, overlay)
// Overwrite: all fields in overlay win, even if zero
result := config.Overwrite(base, overlay)
// CascadeEnv: same as UseConfig, but makes env-merge intent explicit
result := config.CascadeEnv(base, overlay)
// With per-field rules from ResidualConfig
result := config.MergeWithRules(base, overlay, rules)
CascadeEnv is a semantic alias for UseConfig — it makes call sites reading "this is an env merge" clearer. Behavior is identical: non-zero fields in the overlay win; zero fields are transparent.
Struct Tags
| Tag | Purpose | Example |
|---|---|---|
conf:"name" |
Key name in config file / env path segment | conf:"port" |
conf:"-" |
Exclude field from load, write, and env | conf:"-" |
default:"value" |
Static default applied before any file or env | default:"8080" |
merge:"cascade" |
Default merge mode: zero overlay fields are transparent | merge:"cascade" |
merge:"overwrite" |
Default merge mode: overlay always wins, even if zero | merge:"overwrite" |
omitempty:"true" |
Omit from written config when zero and no default | omitempty:"true" |
envsep:"X" |
Override env slice separator (default \n) |
envsep:"," |
raw:"-" |
Exclude from raw parsing (internal use) | raw:"-" |
Fields tagged conf:"-" are decoded from files but then zeroed out. They are excluded from env overlay and write operations.
The omitempty tag marks fields that may be omitted from written config files when their value is zero and they have no default tag. This is a write-time hint — it does not affect loading behavior.
The merge tag defines the developer-intended default merge behavior for a field. This default is used as a fallback when no file rules mention the field. File rules (in residualconfig.loadoverrides) always take precedence over tag defaults.
Env Var Format
Double underscores separate path segments:
MYAPP__PORT=3000
MYAPP__HOST=new.host
MYAPP__DATABASE__HOST=db.example.com
MYAPP__DATABASE__PORT=5432
Env[T] — basic env overlay
Env[T](prefix) returns a zero-value struct with only defined env vars populated. Undefined vars leave the field at its zero value. Empty env vars are treated as unset — MYAPP__HOST="" is equivalent to not setting the variable at all.
cfg := config.Env[AppConfig]("MYAPP")
result := config.CascadeEnv(base, cfg)
EnvWithRules[T] — env overlay with cascade/overwrite rules
EnvWithRules(&cfg, prefix, rules) applies env vars to an existing config, respecting per-field merge rules. This enables explicit unset: an empty env var can clear a field when the field is in overwrite mode.
cfg := InitWithTemplate[AppConfig](app, defaults, tmpl)
sysCfg, sysRules, _ := config.SystemRaw[AppConfig](app)
usrCfg, usrRules, _ := config.UserRaw[AppConfig](app)
merged := config.MergeRules(sysRules, usrRules)
config.EnvWithRules(&cfg, app.Name, merged)
Behavior:
| Field mode | Env var set | Env var value | Result |
|---|---|---|---|
mergeOverwrite |
yes | non-empty | apply value |
mergeOverwrite |
yes | empty ("") |
set to zero (explicit unset) |
mergeCascade |
yes | non-empty | apply value |
mergeCascade |
yes | empty ("") |
skip (keep current) |
| any | no | — | skip (keep current) |
When rules is nil (no file loaded), behavior matches Env[T]: all defined env vars apply, empty vars are skipped.
Example — explicit unset via env var:
Given this struct and config:
type AppConfig struct {
Host string `conf:"host" merge:"overwrite"` // system takes priority
Port int `conf:"port" merge:"overwrite"` // system takes priority
User string `conf:"user" merge:"cascade"` // user takes priority
}
# Clear the host via env var (overwrite mode accepts empty values)
MYAPP__HOST=
# User cascade — empty env var skipped, config value survives
MYAPP__USER=
Per-Field Merge Rules
Users can define merge rules in the config file's reserved residualconfig section:
[residualconfig.loadoverrides]
cascade = ["port"] # cascade: zero in higher-priority → this wins
overwrite = ["host"] # overwrite: always wins, even if zero
port = 9090
host = "example.com"
How Rules Apply
Each merge step uses only the rules from its own file. Rules do not carry forward between layers:
cfg := config.Defaults[T]()
sysCfg, sysRules, _ := config.SystemRaw[T](app)
usrCfg, usrRules, _ := config.UserRaw[T](app)
// Step 1: system rules apply to system merge
cfg = config.MergeWithRules(cfg, sysCfg, sysRules)
// Step 2: user rules apply to user merge (system rules do NOT apply here)
cfg = config.MergeWithRules(cfg, usrCfg, usrRules)
This means each file controls only its own merge step. A system file's overwrite rule for host does not affect how the user merge handles host — that's determined by the user file's rules (or tag defaults).
MergeRules Helper
MergeRules combines two rule sets when you need a single set (e.g., for testing or custom cascades):
merged := config.MergeRules(baseRules, overlayRules)
Overlay wins on conflict. Use this sparingly — the recommended pattern is per-step rules as shown above.
Tag Defaults vs File Rules
The priority chain for determining a field's merge mode:
- File rules (
residualconfig.loadoverrides) — highest priority - Struct tag defaults (
merge:"cascade"/merge:"overwrite") — fallback - Library default — cascade (when no tag and no file rules)
When no file is loaded (missing file), tag defaults do not apply — the merge falls back to simple cascade. This prevents zero-value overlays from accidentally overwriting lower layers.
Wildcard Support
[residualconfig.loadoverrides]
cascade = ["*"] # blanket: everything uses cascade
overwrite = ["host"] # except host always overwrites
# OR per-section:
cascade = ["database.*"] # all database.* fields use cascade
| Pattern | Matches | Doesn't Match |
|---|---|---|
* |
everything | — |
host |
host |
database.host |
database.* |
database.host, database.port |
database.host.timeout |
database.*.timeout |
database.pool.timeout |
database.host |
If a field matches both cascade and overwrite, overwrite wins.
Write Functions
// Write to user config path (format from app.Ext)
err := config.WriteUser(app, cfg)
// Write to system config path (requires root, format from app.Ext)
err := config.WriteSystem(app, cfg)
// Write to explicit path (format from file extension)
err := config.WriteFile(cfg, "/path/to/config.yaml")
Write rules: non-zero fields are written. Zero fields with a default tag are written as the default. Zero fields without a default are omitted.
Template
// Render ${CONF__SECTION__KEY} placeholders from a defaults struct
rendered, err := config.RenderTemplate(tmplSrc, defaults)
Placeholder format: ${CONF__A__B} maps to the TOML path a.b (lowercased). Additional segments add deeper nesting: ${CONF__A__B__C} → a.b.c.
# Template source
shell = "${CONF__CONSOLE__SHELL}"
lines = ${CONF__CONSOLE__SCROLLBACK_LINES}
# With defaults{Console: {Shell: "/bin/bash", ScrollbackLines: 1000}}
# Renders to:
shell = "/bin/bash"
lines = 1000
The value is substituted as its plain string form. Template authors are responsible for quoting string values in the surrounding TOML context.
Load Cascade
struct zero value
↓ tag defaults (default:"value")
↓ system config file (System / SystemRaw) + system residualconfig rules
↓ user config file (User / UserRaw) + user residualconfig rules
↓ custom config file (Custom / CustomRaw) + custom residualconfig rules
↓ env vars (EnvWithRules — respects cascade/overwrite rules)
Each layer is loaded independently and merged with MergeWithRules. File rules apply only to their own merge step — they do not carry forward. Env vars are applied last with EnvWithRules, respecting cascade/overwrite rules for empty values.
Supported Formats
| Format | Extensions | Dependencies |
|---|---|---|
| TOML | .toml |
github.com/BurntSushi/toml |
| YAML | .yaml, .yml |
gopkg.in/yaml.v3 |
| JSON | .json |
encoding/json (stdlib) |
Format is determined by file extension. System/User/WriteUser/WriteSystem use app.Ext. Custom/WriteFile auto-detect from the path extension.
Known Limitations
- Conf tag values must not contain double quotes — they produce malformed struct tags in the shadow type decoder and will cause a runtime panic.
MergeWithRuleswildcards match against the leaf field name, not a full dotted path. Patterns likedatabase.*matchhost(the leaf), notdatabase.host(the dotted path). This is a known limitation; full dotted-path matching is deferred.- Map keys must be strings.
map[string]Tandmap[string]anyare supported;map[int]stringand other key types are not.
Validation
go build ./...
go test -count=1 ./...
go vet ./...
go doc .
Internal Tools
The cmd/ directory contains internal developer tools used for testing and validation. They are not part of the public API and may change without notice.
config-test— exercises the library with real files and env varsres-validate-config— CLI for validating config loading
Documentation
Additional docs in docs/:
docs/residual-overview.md— residual conventions reference