Linear issue: DVFLW-272 Date: 2026-03-18
Add a configuration system using Viper that loads settings from mantle.yaml, with environment variable and CLI flag overrides.
mantle.yamlsupports Postgres connection string, API listen address, log level- CLI flags override config file values
- Env vars (e.g.,
MANTLE_DATABASE_URL) override both config file and defaults - Config file path configurable via
--configflag
internal/
config/
config.go # Config struct, Load() function, Viper setup
config_test.go # Tests for loading, env overrides, flag overrides, defaults
type Config struct {
Database DatabaseConfig `mapstructure:"database"`
API APIConfig `mapstructure:"api"`
Log LogConfig `mapstructure:"log"`
}
type DatabaseConfig struct {
URL string `mapstructure:"url"`
}
type APIConfig struct {
Address string `mapstructure:"address"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
}| Key | Default | Env Var |
|---|---|---|
database.url |
postgres://localhost:5432/mantle?sslmode=disable |
MANTLE_DATABASE_URL |
api.address |
:8080 |
MANTLE_API_ADDRESS |
log.level |
info |
MANTLE_LOG_LEVEL |
database:
url: "postgres://localhost:5432/mantle?sslmode=disable"
api:
address: ":8080"
log:
level: "info"Load(configPath string) (*Config, error) — takes the --config flag value.
Steps:
- Set defaults for all keys
- If
configPathis provided, read that file; otherwise search current directory formantle.yaml - Set
MANTLE_env prefix viaSetEnvPrefix("MANTLE") - Explicitly bind env vars with
BindEnvfor each key (notAutomaticEnv, which cannot reliably resolve nested keys with underscore delimiters):viper.BindEnv("database.url", "MANTLE_DATABASE_URL")viper.BindEnv("api.address", "MANTLE_API_ADDRESS")viper.BindEnv("log.level", "MANTLE_LOG_LEVEL")
- Unmarshal into
Configstruct and return
- If
--configis provided and the file does not exist: hard error (user explicitly asked for this file) - If
--configis not provided and nomantle.yamlin current directory: silent fallback to defaults (config file is optional)
Load() returns an error only for YAML parse failures and type mismatches. It does not perform semantic validation (e.g., whether database.url is a valid Postgres connection string). Semantic validation belongs in the consuming code.
Viper's built-in precedence (highest to lowest):
- CLI flags (bound to Viper keys via
BindPFlag) - Environment variables (
MANTLE_prefix, explicitBindEnvcalls) - Config file (
mantle.yaml) - Defaults
Add these persistent flags on the root command:
--database-url— binds to Viper keydatabase.url--api-address— binds to Viper keyapi.address--log-level— binds to Viper keylog.level
Flags are bound to Viper keys via viper.BindPFlag() inside Load(), which receives the *cobra.Command to access its flags. Updated signature:
Load(cmd *cobra.Command) (*Config, error) — reads --config flag from cmd, binds other flags to Viper.
In internal/cli/root.go:
- Add
--database-url,--api-address,--log-levelas persistent flags PersistentPreRunEon the root command callsconfig.Load(cmd)which reads--config, binds flags, and loads config- The resulting
*Configis stored on the command's context viacontext.WithValue
Defined in internal/config/:
type contextKey struct{}
// WithContext returns a new context with the config attached.
func WithContext(ctx context.Context, cfg *Config) context.Context {
return context.WithValue(ctx, contextKey{}, cfg)
}
// FromContext retrieves the config from context. Returns nil if not set.
func FromContext(ctx context.Context) *Config {
cfg, _ := ctx.Value(contextKey{}).(*Config)
return cfg
}The context key type is unexported, preventing collisions. Helper functions are in the config package so subcommands depend on config (not the other way around).
github.com/spf13/viper— config loading, env var binding, YAML parsing (must be added to go.mod)
- Semantic validation of config values (belongs in consuming code)
- Hot reload / file watching
- Config fields beyond database.url, api.address, log.level (added by future phases)