No description
  • Go 96.9%
  • Shell 3.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
merith-tk 176b339d24
Some checks failed
CI / lint (push) Failing after 16s
CI / test (push) Successful in 30s
feat(config): inherit/override/pin merge model, ordered chains, demo consolidation
Hard-rename the merge model (pre-1.0, sole consumer) and add an
absolute, table-only pin directive:

- cascade -> inherit, overwrite -> override across tags
  (merge:"inherit"/"override"), preferences, docs, tests, fixtures
- New pin mode: a pin entry keeps the upper layer's value against
  every later layer and env. Pins accumulate along the chain
  (Cascade strip-and-accumulate protocol); the only way to change a
  pinned value is to remove the pin from the layer that sets it.
  merge:"pin" struct tags are parsed and ignored by design.
- ResidualConfig -> Reconfig, LoadOverrides -> Preferences;
  on-disk [residualconfig.loadoverrides] -> [reconfig.preferences]
  ([reconf] accepted as a short alias, keys case-insensitive).
- New Cascade/Layer/AccumulatedPins API merges any ordered chain:
  each layer's inherit/override governs its own step while pins
  persist. InitWithTemplate now runs through Cascade.
- Precedence pin > override > inherit (file rules > tags > default);
  MergeRules unions pins so they survive into env overlays.

Correctness fixes included in the same pass:

- Inherit merge is deep: nested structs merge recursively instead
  of replacing wholesale; dotted-path wildcards (database.*) work.
- Env is the biggest stick: non-empty vars always win except on
  pinned fields; empty only clears override fields.
- loadFromPathRaw/buildShadowType return errors instead of panicking
  (bad conf tags, TextUnmarshaler/time.Time assigned atomically);
  conf:"-" clearing recurses; residual section lookup hardened.
- Full int/uint width + slice type coverage for env/default/write
  (fixes []int64/[]uint panics); WriteFile omits zero-without-default
  at all depths and never emits null; WriteSystem uses os.Geteuid.
- InitWithTemplate(+Env) propagates corrupt-file errors; new
  InitWithTemplateEnv opt-in; SetEnvErrorHandler surfaces bad env.

Tooling/docs:

- Consolidate cmd/config-test + cmd/res-validate-config into
  cmd/config-demo (show with repeatable --layer, validate,
  selftest) plus ./demo.sh guided walkthrough (8 scenarios).
- README/config.go/CHANGELOG/overviews rewritten in ordered-layer
  terms; residual system/user chains documented as one example.
2026-10-14 19:48:34 -07:00
.github/workflows fix: remove dead code, make lint non-blocking 2026-09-20 11:18:30 -07:00
.opencode update harness configs 2026-10-12 10:27:43 -07:00
cmd/config-demo feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
core feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
docs feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
hooks license: add MPL-2.0 to config library 2026-08-10 08:38:05 -07:00
testdata feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
.gitattributes update harness configs 2026-10-12 10:27:43 -07:00
.gitignore feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
app.go license: add MPL-2.0 to config library 2026-08-10 08:38:05 -07:00
CHANGELOG.md feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
config.go feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
demo.sh feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
go.mod fix: go.mod deps, mkdir perms, README limitations and internal tools note 2026-09-20 08:35:39 -07:00
go.sum fix: go.mod deps, mkdir perms, README limitations and internal tools note 2026-09-20 08:35:39 -07:00
LICENSE license: add MPL-2.0 to config library 2026-08-10 08:38:05 -07:00
load_test.go feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
merge_test.go feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
README.md feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
residual-overview.md feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
stubs.go feat(config): inherit/override/pin merge model, ordered chains, demo consolidation 2026-10-14 19:48:34 -07:00
write_test.go test: move inline test data to committed testdata/ fixtures 2026-09-19 18:06:13 -07:00

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
  • Ordered layer chains — merge any number of sources, lowest priority first
  • Per-field preferences — inherit, override, or pin, with wildcard support
  • Struct tag merge defaults — merge:"inherit" / merge:"override" 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:"inherit"`
    Host  string `conf:"host" default:"localhost" merge:"override"`
    Debug bool   `conf:"debug"`
}

func main() {
    app := config.New("myapp", "residual")
    app.Ext = "yaml"  // or "toml", "json" (default: "toml")

    // The library chains any ordered list of layers, lowest priority first.
    // Each file carries its own preferences table; pins recorded by earlier
    // layers lock later ones. (System/User paths below are just one app's
    // path convention — any CustomRaw files work as layers.)
    cfg := config.Defaults[AppConfig]()
    sysCfg, sysRules, _ := config.SystemRaw[AppConfig](app)
    usrCfg, usrRules, _ := config.UserRaw[AppConfig](app)
    cfg = config.Cascade(cfg,
        config.Layer[AppConfig]{Name: "system", Cfg: sysCfg, Rules: sysRules},
        config.Layer[AppConfig]{Name: "user", Cfg: usrCfg, Rules: usrRules})

    // Env vars overlay — biggest stick for non-empty values (except pinned).
    // Empty env vars on override 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 Reconfig preferences 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 preferences (pins lock env out)
config.EnvWithRules(&cfg, "MYAPP", rules)
// Empty env vars on override 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 *Reconfig preferences from the file's reconfig section. Use these with MergeWithRules for per-field merge control, or bundle them as config.Layer values for config.Cascade.

Merge Functions

// Inherit (deep): zero fields in overlay are transparent — base values win.
// Nested structs merge recursively; *struct overlays merge when both non-nil.
result := config.UseConfig(base, overlay)

// Override: 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 preferences from Reconfig (dotted paths, wildcards)
result := config.MergeWithRules(base, overlay, rules)

// An ordered chain of any length: pins accumulate, inherit/override stay per-step
result = config.Cascade(defaults,
    config.Layer[T]{Name: "base", Cfg: baseCfg, Rules: baseRules},
    config.Layer[T]{Name: "top", Cfg: topCfg, Rules: topRules})

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:"inherit" Default merge mode: zero overlay fields are transparent merge:"inherit"
merge:"override" Default merge mode: overlay always wins, even if zero merge:"override"
merge:"pin" NOT honored as a tag — pins come only from a layer's preferences table n/a
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 reconfig.preferences) 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 preferences for empty vars (pins lock env out)

EnvWithRules(&cfg, prefix, rules) applies env vars to an existing config. Env is the biggest stick when enabled: any set, non-empty env var wins over all file layers regardless of file rules — except on pinned fields, which env can never move. Empty handling respects per-field merge mode — this enables explicit unset: an empty env var can clear a field when the field is in override mode.

cfg, _ := config.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)

// Or in one call (env applied last):
cfg, _ = config.InitWithTemplateEnv(app, defaults, tmpl, app.Name, merged)

Behavior (env is biggest stick for non-empty values):

Field mode Env var set Env var value Result
override yes non-empty apply value
override yes empty ("") set to zero (explicit unset)
inherit yes non-empty apply value
inherit yes empty ("") skip (keep current)
pin yes any skip (locked — remove the pin to change it)
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.

Nested structs deep-merge and *struct fields are allocated on demand when a prefixed env var exists (e.g. APP__DATABASE__HOST works for Database *DB). Supported env kinds: string, all int/uint widths, float, bool, slices thereof (via envsep), and encoding.TextUnmarshaler types. Unsupported kinds and unparsable values are skipped; set config.SetEnvErrorHandler to observe them.

Example — explicit unset via env var:

Given this struct and config:

type AppConfig struct {
    Host  string `conf:"host" merge:"override"` // child always wins
    Port  int    `conf:"port" merge:"override"` // child always wins
    User  string `conf:"user" merge:"inherit"`   // child inherits when unset
}
# Clear the host via env var (override mode accepts empty values)
MYAPP__HOST=

# Inherit — empty env var skipped, config value survives
MYAPP__USER=

Per-Field Merge Rules

Users can define merge rules in the config file's reserved reconfig section:

[reconfig.preferences]
inherit = ["port"]   # child inherits parent when unset (zero transparent)
override = ["host"]  # child always wins, even when zero
pin = ["token"]      # parent always wins — locked, no bypass except removing the pin

port = 9090
host = "example.com"

How Rules Apply

Prefer config.Cascade for chains: each layer's own inherit/override entries govern only its own merge step, while pins recorded by earlier layers are re-asserted at every later step (including env). For manual two-way merges:

cfg := config.Defaults[T]()
lowerCfg, lowerRules, _ := config.CustomRaw[T]("/etc/myapp.toml")
upperCfg, upperRules, _ := config.CustomRaw[T]("/home/user/.config/myapp.toml")

// Step 1: lower rules apply to the lower merge (its pins do NOT block itself)
cfg = config.MergeWithRules(cfg, lowerCfg, lowerRules)
// Step 2: upper rules apply to the upper merge (lower rules do NOT apply here)
cfg = config.MergeWithRules(cfg, upperCfg, upperRules)

This means each file controls only its own merge step — except pins, which are sticky by design. A lower file's override rule for host does not affect how the upper merge handles host — that's determined by the upper file's rules (or tag defaults). But a lower file's pin for host locks host against the upper merge and env alike.

MergeRules Helper

MergeRules combines two rule sets when you need a single set (e.g., for env overlays or custom chains):

merged := config.MergeRules(baseRules, overlayRules)

A field pinned in either input stays pinned (pins cannot be bypassed). For the rest, overlay wins on conflict. The recommended pattern for file chains is config.Cascade, which handles pin accumulation for you.

Tag Defaults vs File Rules

The priority chain for determining a field's merge mode:

  1. File rules (reconfig.preferences) — highest priority; pin beats override beats inherit
  2. Struct tag defaults (merge:"inherit" / merge:"override") — fallback (tags cannot pin)
  3. Library default — inherit (when no tag and no file rules)

When rules are nil (missing layer), tag defaults do not apply — the merge falls back to simple inherit. This prevents zero-value overlays from accidentally overwriting lower layers.

Wildcard Support

[reconfig.preferences]
inherit = ["*"]      # blanket: everything inherits
override = ["host"]  # except host always overrides

# OR per-section:
inherit = ["database.*"]  # all database.* fields inherit
pin = ["database.host"]   # except database.host is locked to the upper value
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 several lists, pin wins over override, and override wins over inherit.

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 — top-level and nested agree. Nil pointers/maps/slices are omitted (never null).

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.

Layer Chains

The library merges any ordered list of layers, lowest priority first:

tag defaults (default:"value")
    ↓ layer 1 file + its own preferences
    ↓ layer 2 file + its own preferences (+ pins accumulated from layer 1)
    ↓ ... any number of layers
    ↓ env vars (EnvWithRules — biggest stick for non-empty, except pinned)

Each layer is loaded independently (missing layers are transparent, not errors). A layer's own inherit/override entries govern only its own merge step — they do not carry forward. Pins are the exception: pins recorded by earlier layers are re-asserted at every later step, including env. The only way to change a pinned value is to remove the pin from the layer that sets it.

config.Cascade implements this protocol; InitWithTemplate is Residual's two-source convenience chain on top of it (it does NOT read env — use InitWithTemplateEnv when the app opts into env overrides). Env vars are applied last: non-empty values always win except on pinned fields; empty values only clear override-mode fields.

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 — loading returns config: invalid conf tag ... must not contain a double quote instead of decoding.
  • Map keys must be strings. map[string]T and map[string]any are supported; map[int]string and 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-demo — consolidated demo + validation CLI (show prints layer states, validate checks --expect k=v, selftest runs a quick binary check; replaces the former config-test and res-validate-config)

Run the guided walkthrough with:

./demo.sh

Documentation

Additional docs in docs/:

  • docs/residual-overview.md — residual conventions reference