No description
- Go 97.2%
- Shell 2.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- Add LICENSE file with full MPL-2.0 text - Add MPL-2.0 header to all 18 .go files - Add pre-commit hook (hooks/pre-commit) to enforce header checks - Add setup script (hooks/setup.sh) to install the hook - Fix .gitignore to not exclude cmd/ source directory |
||
| .opencode | ||
| cmd/res-validate-config | ||
| core | ||
| docs | ||
| hooks | ||
| .gitignore | ||
| app.go | ||
| 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
- Cascading merge — defaults → system → user → env vars
- Per-field merge rules — cascade or overwrite, with wildcard support
- Env var overrides — double-underscore separation (
PREFIX__KEY__SUBKEY) - Struct tags —
conf,default,optional,envsep,raw - TOML support — YAML/JSON not yet implemented
Quick Start
package main
import (
"fmt"
"git.merith.xyz/residual/config"
)
type AppConfig struct {
Port int `conf:"port" default:"8080"`
Host string `conf:"host" default:"localhost"`
Debug bool `conf:"debug"`
}
func main() {
// Create app instance
app := config.New("myapp", "residual")
// Load each layer
cfg := config.Defaults[AppConfig]()
sysCfg, _ := config.System[AppConfig](app)
usrCfg, _ := config.User[AppConfig](app)
cfg = config.UseConfig(cfg, sysCfg)
cfg = config.UseConfig(cfg, usrCfg)
fmt.Printf("Port: %d, Host: %s\n", cfg.Port, cfg.Host)
}
App Configuration
// Default directories
app := config.New("myapp", "residual")
// SystemPath: /etc/residual/myapp.toml
// UserPath: ~/.config/residual/myapp.toml
// Custom directories
app := config.NewWithOptions("myapp", "residual", "/custom/etc", "/custom/config")
// SystemPath: /custom/etc/residual/myapp.toml
// UserPath: /custom/config/residual/myapp.toml
// Without group
app := config.New("myapp", "")
// SystemPath: /etc/myapp.toml
// UserPath: ~/.config/myapp.toml
Load Functions
// Tag defaults only (no files, no env)
cfg := app.Defaults[AppConfig]()
// From system config directory
cfg, err := app.System[AppConfig]()
// From user config directory
cfg, err := app.User[AppConfig]()
// From explicit file path
cfg, err := config.ReadFile[AppConfig]("/path/to/config.toml")
// From environment variables (double-underscore separation)
cfg := config.Env[AppConfig]("MYAPP")
// MYAPP__PORT=3000
// MYAPP__HOST=new.host
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)
// With per-field rules from ResidualConfig
result := config.MergeWithRules(base, overlay, rules)
Struct Tags
| Tag | Purpose | Example |
|---|---|---|
conf:"name" |
Key name in config file / env path segment | conf:"port" |
default:"value" |
Static default applied before any file or env | default:"8080" |
optional:"true" |
Field may be absent everywhere without error | optional:"true" |
envsep:"X" |
Override env slice separator (default \n) |
envsep:"," |
raw:"-" |
Exclude from raw parsing (internal use) | raw:"-" |
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
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"
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 |
Write Functions
// Write to user config path
err := config.WriteUser(app, cfg)
// Write to system config path (requires root)
err := config.WriteSystem(app, cfg)
// Write to explicit path
err := config.WriteFile(cfg, "/path/to/config.toml")
Load Cascade
struct zero value
↓ tag defaults (default:"value")
↓ system config file (System function)
↓ user config file (User function)
↓ env vars (Env function — always overwrite)
Validation
GOWORK=off go build ./...
GOWORK=off go test -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go doc .
Documentation
Additional docs in docs/:
docs/AUDIT.md— audit findings and resolution statusdocs/handoff.md— original design handoffdocs/residual-overview.md— residual conventions reference