No description
  • Go 98.8%
  • Shell 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Merith-TK a59a735cb0
All checks were successful
CI / test (push) Successful in 25s
CI / lint (push) Successful in 24s
feat(config): pre-release polish — EnvWithRules, permissions, docs
- 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)
2026-09-21 08:42:36 -07:00
.github/workflows fix: remove dead code, make lint non-blocking 2026-09-20 11:18:30 -07:00
.opencode feat(config): cascade overrides — per-field merge rules integration 2026-09-21 08:01:43 -07:00
cmd core: shadow type decoder, conf-only struct support, enhanced write/template 2026-09-07 09:46:16 -07:00
core feat(config): pre-release polish — EnvWithRules, permissions, docs 2026-09-21 08:42:36 -07:00
docs feat(config): cascade overrides — per-field merge rules integration 2026-09-21 08:01:43 -07:00
hooks license: add MPL-2.0 to config library 2026-08-10 08:38:05 -07:00
testdata feat(config): cascade overrides — per-field merge rules integration 2026-09-21 08:01:43 -07:00
.gitignore license: add MPL-2.0 to config library 2026-08-10 08:38:05 -07:00
app.go license: add MPL-2.0 to config library 2026-08-10 08:38:05 -07:00
CHANGELOG.md feat(config): pre-release polish — EnvWithRules, permissions, docs 2026-09-21 08:42:36 -07:00
config.go feat(config): pre-release polish — EnvWithRules, permissions, docs 2026-09-21 08:42:36 -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 fix: replace golangci-lint with staticcheck, skip root-only test 2026-09-20 11:14:00 -07:00
merge_test.go license: add MPL-2.0 to config library 2026-08-10 08:38:05 -07:00
README.md feat(config): pre-release polish — EnvWithRules, permissions, docs 2026-09-21 08:42:36 -07:00
stubs.go feat(config): pre-release polish — EnvWithRules, permissions, docs 2026-09-21 08:42:36 -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
  • 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:

  1. File rules (residualconfig.loadoverrides) — highest priority
  2. Struct tag defaults (merge:"cascade" / merge:"overwrite") — fallback
  3. 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.
  • MergeWithRules wildcards match against the leaf field name, not a full dotted path. Patterns like database.* match host (the leaf), not database.host (the dotted path). This is a known limitation; full dotted-path matching is deferred.
  • 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-test — exercises the library with real files and env vars
  • res-validate-config — CLI for validating config loading

Documentation

Additional docs in docs/:

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