config

package
v0.18.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 19 Imported by: 0

README

Config

Robust configuration management wrapping spf13/viper with type safety and interface-based testability.

Features:

  • Mockable Containable interface
  • Automatic environment variable mapping
  • Built-in observer pattern for file watching
  • Support for YAML, embedded assets, and more

For detailed documentation, usage examples, and testing strategies, see the Config Component Documentation.

Documentation

Overview

Package config provides configuration loading, merging, and access via the Containable interface backed by Viper.

Configurations can be loaded from multiple sources — local files, embedded assets, environment variables, and command-line flags — and merged with deterministic precedence. Factory functions include NewFilesContainer, LoadFilesContainer, NewReaderContainer, and NewContainerFromViper.

Type-safe accessors (GetString, GetInt, GetBool, GetDuration, GetTime, etc.) are exposed through Containable. For advanced use cases, [Containable.GetViper] provides direct access to the underlying Viper instance as an intentional power-user escape hatch.

Hot-reload is supported on file-backed containers via the Observable interface. The container owns an fsnotify watcher over every configured file; on a change it re-reads and re-merges all files into a candidate, validates the candidate against the schema (if any), and only on success swaps the live config and notifies registered observers. Unparseable or invalid reloads are rejected fail-closed, keeping the last-known-good values. The debounce window is configurable via WithReloadDebounce; call Container.Close to stop the watcher.

Index

Examples

Constants

View Source
const DefaultReloadDebounce = 250 * time.Millisecond

DefaultReloadDebounce is the default coalescing window applied to the burst of filesystem events a single save produces before a hot-reload is performed. The relatively generous default tolerates slow or networked filesystems.

Variables

View Source
var (
	ErrNoFilesFound = errors.Newf("no configuration files found please run init, or provide a config file using the --config flag")

	// ErrConfigFileNotFound is returned by LoadFilesContainer when the first
	// configured file does not exist. Callers can branch on this with
	// errors.Is to fall back to defaults (e.g. on first run).
	ErrConfigFileNotFound = errors.Newf("config file not found")
)

Functions

func LoadEnv

func LoadEnv(fs afero.Fs, log logger.Logger)

LoadEnv loads environment variables from a .env file if it exists. A nil log is tolerated — it falls back to a no-op logger so callers that have not yet wired logging cannot trigger a nil-pointer panic.

func ValidateStruct added in v0.4.0

func ValidateStruct[T any](cfg Containable, opts ...SchemaOption) error

ValidateStruct validates cfg against the schema derived from T, returning a formatted error if any rule fails or nil if the configuration is valid.

It takes the Containable interface, so callers do not need to type-assert Props.Config down to the concrete *Container. It is the recommended way to validate a command or feature's config slice:

if err := config.ValidateStruct[MyConfig](props.Config); err != nil {
	return err
}

Schema options such as WithStrictMode may be passed through.

Types

type Containable

type Containable interface {
	Get(key string) any
	GetBool(key string) bool
	GetInt(key string) int
	GetFloat(key string) float64
	GetString(key string) string
	GetTime(key string) time.Time
	GetDuration(key string) time.Duration
	// GetViper returns the underlying Viper instance for advanced operations
	// not exposed by the Containable interface. This is an intentional escape
	// hatch for power users who need Viper's full API (e.g., MergeConfig,
	// BindPFlag, or direct access to config file watching).
	//
	// Prefer the typed accessor methods (GetString, GetInt, etc.) for standard
	// configuration reads. Use GetViper only when the Containable interface
	// does not cover your use case.
	GetViper() *viper.Viper
	// BindPFlag binds a single pflag to the given configuration key. Once
	// bound, the flag's value participates in Viper's precedence resolution,
	// sitting above environment variables and file config. Callers should only
	// bind flags the user explicitly changed (flag.Changed) so that a flag left
	// at its default does not clobber configured values.
	BindPFlag(key string, flag *pflag.Flag) error
	Has(key string) bool
	IsSet(key string) bool
	Set(key string, value any)
	WriteConfigAs(dest string) error
	Sub(key string) Containable
	AddObserver(o Observable)
	AddObserverFunc(f func(Containable) error)
	// OnReloadError registers a callback invoked whenever a hot-reload is
	// rejected (candidate build or schema validation failed) and the
	// last-known-good config is retained. It is never called for a
	// successful reload; observers handle that case.
	OnReloadError(f func(error))
	ToJSON() string
	Dump(w io.Writer)
	// Validate checks the container's current values against the provided schema.
	// Returns a ValidationResult; callers should check result.Valid().
	Validate(schema *Schema) *ValidationResult
}

Containable is the primary configuration interface. It provides typed accessors, observation for hot-reload, and schema validation.

func Load

func Load(paths []string, fs afero.Fs, allowEmptyConfig bool, opts ...ContainerOption) (Containable, error)

Load reads configuration from the first available file in paths. Returns ErrNoFilesFound if no files exist and allowEmptyConfig is false.

func LoadEmbed

func LoadEmbed(paths []string, assets fs.FS, opts ...ContainerOption) (Containable, error)

LoadEmbed reads configuration from embedded filesystem assets and merges them.

func LoadFilesContainer

func LoadFilesContainer(fs afero.Fs, opts ...ContainerOption) (Containable, error)

LoadFilesContainer loads configuration from files and returns a Containable. Config files are specified via WithConfigFiles. It returns ErrConfigFileNotFound if the first file specified does not exist; it never returns a nil Containable with a nil error.

func LoadFilesContainerWithSchema

func LoadFilesContainerWithSchema(fs afero.Fs, schema *Schema, opts ...ContainerOption) (Containable, error)

LoadFilesContainerWithSchema loads config files and validates against the schema. Returns an error wrapping all validation errors if the config is invalid. The schema can also be provided via WithSchema; if both are present, the option takes precedence.

type Container

type Container struct {
	ID string
	// contains filtered or unexported fields
}

Container container for configuration.

Sub-containers returned by Container.Sub track two Viper instances:

  • viper (the "structural view") points at the sub-tree and is used by Write/Dump/ToJSON/Validate operations so those continue to scope correctly to the sub-path.
  • root + prefix together identify the original container; every Get/Set/Has/IsSet call is routed through root.viper with the key qualified by the accumulated prefix. This is what keeps Viper's AutomaticEnv + prefix-aware env binding alive across Sub() calls — `cfg.Sub("github").GetString("auth.value")` now honours `<TOOL_PREFIX>_GITHUB_AUTH_VALUE`, which a Viper-native Sub would silently drop.

func NewContainerFromViper

func NewContainerFromViper(l logger.Logger, v *viper.Viper) *Container

NewContainerFromViper creates a new Container from an existing Viper instance. If l is nil, a no-op logger is used.

func NewFilesContainer

func NewFilesContainer(fs afero.Fs, opts ...ContainerOption) *Container

NewFilesContainer initialises a configuration container to read files from the FS. Config files are specified via WithConfigFiles.

func NewReaderContainer

func NewReaderContainer(fs afero.Fs, opts ...ContainerOption) *Container

NewReaderContainer initialises a configuration container to read config from io.Readers. Readers are specified via WithConfigReaders; format via WithConfigFormat.

Example
package main

import (
	"fmt"
	"strings"

	"github.com/spf13/afero"

	"gitlab.com/phpboyscout/go-tool-base/pkg/config"
	"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
)

func main() {
	l := logger.NewNoop()
	yaml := `
log:
  level: debug
server:
  port: 8080
`
	cfg := config.NewReaderContainer(afero.NewMemMapFs(), config.WithLogger(l), config.WithConfigFormat("yaml"), config.WithConfigReaders(strings.NewReader(yaml)))

	fmt.Println("Level:", cfg.GetString("log.level"))
	fmt.Println("Port:", cfg.GetInt("server.port"))
}
Output:
Level: debug
Port: 8080

func (*Container) AddObserver

func (c *Container) AddObserver(o Observable)

AddObserver attach observer to trigger on config update.

func (*Container) AddObserverFunc

func (c *Container) AddObserverFunc(f func(Containable) error)

AddObserverFunc attach function to trigger on config update.

func (*Container) BindPFlag added in v0.17.0

func (c *Container) BindPFlag(key string, flag *pflag.Flag) error

BindPFlag binds a single pflag to the given configuration key.

Binding is routed through the same Viper instance used for value resolution (the root container's Viper on sub-containers), and the key is qualified with the sub-container's prefix, so a bound flag is visible to the typed Get methods at Viper's documented precedence (flag > env > file).

Bind only flags the user explicitly changed (flag.Changed); binding a flag at its default value would clobber configured values — Viper's classic default-clobber footgun.

func (*Container) Close added in v0.17.0

func (c *Container) Close() error

Close stops the hot-reload watcher and releases its resources. It is safe to call on containers that are not watching and safe to call more than once.

func (*Container) Dump

func (c *Container) Dump(w io.Writer)

func (*Container) Get

func (c *Container) Get(key string) any

Get interface value from config.

func (*Container) GetBool

func (c *Container) GetBool(key string) bool

GetBool get Bool value from config.

func (*Container) GetDuration

func (c *Container) GetDuration(key string) time.Duration

GetDuration get duration value from config.

func (*Container) GetFloat

func (c *Container) GetFloat(key string) float64

GetFloat get Float value from config.

func (*Container) GetInt

func (c *Container) GetInt(key string) int

GetInt get Bool value from config.

func (*Container) GetObservers

func (c *Container) GetObservers() []Observable

GetObservers retrieve all currently attached Observers.

func (*Container) GetString

func (c *Container) GetString(key string) string

GetString get string value from config.

func (*Container) GetTime

func (c *Container) GetTime(key string) time.Time

GetTime get time value from config.

func (*Container) GetViper

func (c *Container) GetViper() *viper.Viper

GetViper retrieves the underlying Viper configuration. On sub-containers this returns the sub-tree structural view; use this only for bulk reads that do not need env binding (the full env-aware resolution pipeline is only available via the typed Get methods).

func (*Container) Has

func (c *Container) Has(key string) bool

Has reports whether the given key is present in the loaded file configuration. It is backed by Viper's InConfig, which inspects only keys read from a config file — env vars picked up by AutomaticEnv, flag bindings, and Set overrides are not counted. Use IsSet for a presence check that spans file, env, and flag sources.

func (*Container) IsSet

func (c *Container) IsSet(key string) bool

IsSet checks if the key has been set (file, env, or flag).

func (*Container) OnReloadError added in v0.17.0

func (c *Container) OnReloadError(f func(error))

OnReloadError registers a callback invoked whenever a hot-reload is rejected: the candidate failed to build (fail-closed partial-merge, parse error, or a missing primary file) or failed schema validation, so the live config was NOT swapped and the last-known-good config is retained. The callback is never invoked for a successful reload (observers are notified of that change instead). Callbacks run in registration order on the watcher goroutine, after the container has logged the error; a slow callback delays subsequent reloads, so expensive work should be offloaded.

func (*Container) Set

func (c *Container) Set(key string, value any)

Set sets the value for the given key.

func (*Container) SetSchema

func (c *Container) SetSchema(schema *Schema)

SetSchema attaches a validation schema to the container. When set, hot-reload will validate a candidate config before swapping it in; a candidate that fails validation is rejected and the last-known-good config is retained.

func (*Container) Sub

func (c *Container) Sub(key string) Containable

Sub returns a view over a subtree of the parent configuration.

Unlike Viper's native Sub — which constructs a fresh Viper instance that drops the parent's AutomaticEnv + prefix settings — this Sub returns a view that delegates every Get/Set/Has/IsSet call back to the root container's Viper with a fully-qualified key path. That means env-aware resolution (including prefix-aware env vars like `<TOOL>_GITHUB_AUTH_VALUE`) continues to apply no matter how many Sub() layers a caller walks through.

The returned view's `viper` field is the Viper-native sub-tree used only by operations that legitimately need a scoped view: WriteConfigAs, Dump, ToJSON, and Validate.

Returns nil when key is not present anywhere in the config hierarchy, matching Viper's Sub semantics.

func (*Container) ToJSON

func (c *Container) ToJSON() string

Dump return config as json string.

func (*Container) Validate

func (c *Container) Validate(schema *Schema) *ValidationResult

Validate checks the current configuration against the provided schema. Returns a ValidationResult; callers should check result.Valid().

Example
package main

import (
	"fmt"
	"strings"

	"github.com/spf13/afero"

	"gitlab.com/phpboyscout/go-tool-base/pkg/config"
	"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
)

func main() {
	type AppConfig struct {
		Log struct {
			Level string `config:"log.level" enum:"debug,info,warn,error"`
		}
	}

	l := logger.NewNoop()
	cfg := config.NewReaderContainer(afero.NewMemMapFs(), config.WithLogger(l), config.WithConfigFormat("yaml"), config.WithConfigReaders(strings.NewReader("log:\n  level: verbose\n")))

	schema, _ := config.NewSchema(config.WithStructSchema(AppConfig{}))

	result := cfg.Validate(schema)
	if !result.Valid() {
		fmt.Println(result.Error())
	}
}

func (*Container) WriteConfigAs

func (c *Container) WriteConfigAs(dest string) error

WriteConfigAs writes the current configuration to the given path.

type ContainerOption

type ContainerOption func(*containerOptions)

ContainerOption configures optional behavior for config containers.

func WithConfigFiles

func WithConfigFiles(files ...string) ContainerOption

WithConfigFiles specifies one or more config file paths to load. The first file is treated as the primary config; subsequent files are merged in order.

func WithConfigFormat

func WithConfigFormat(format string) ContainerOption

WithConfigFormat sets the config format (e.g. "yaml", "json") for reader-based config loading.

func WithConfigReaders

func WithConfigReaders(readers ...io.Reader) ContainerOption

WithConfigReaders provides one or more io.Readers as config sources. The first reader is the primary config; subsequent readers are merged. Requires WithConfigFormat to be set.

func WithEnvPrefix

func WithEnvPrefix(prefix string) ContainerOption

WithEnvPrefix sets the environment variable prefix for automatic env binding. When set to "GTB", the config key "ai.provider" resolves from the environment variable "GTB_AI_PROVIDER". An empty string disables prefixing (the default, preserving backward compatibility).

func WithLogger

func WithLogger(l logger.Logger) ContainerOption

WithLogger sets the logger for the config container. When not provided, a no-op logger is used.

func WithReloadDebounce added in v0.17.0

func WithReloadDebounce(d time.Duration) ContainerOption

WithReloadDebounce sets the coalescing window for hot-reload events. A single file save typically emits a burst of filesystem events (write, rename, chmod); the container waits for the burst to settle for the given duration before performing a reload. Values <= 0 fall back to DefaultReloadDebounce.

func WithSchema

func WithSchema(schema *Schema) ContainerOption

WithSchema attaches a validation schema to the container.

type FieldSchema

type FieldSchema struct {
	// Type is the expected Go type: "string", "int", "float64", "bool", "duration".
	Type string
	// Required indicates the field must be present and non-zero.
	Required bool
	// Description is used in validation error messages.
	Description string
	// Default is the default value for documentation and error hints only.
	// The validation layer does not inject defaults — use embedded assets for that.
	Default any
	// Enum restricts the field to a set of allowed values.
	Enum []any
}

FieldSchema describes a single configuration field.

type Observable

type Observable interface {
	Run(Containable) error
}

Observable is the interface for config change observers. Implementations receive the updated config when a watched config file changes and return an error if they could not apply the new configuration. The returned error is logged by the container; it never blocks subsequent reloads.

type Observer

type Observer struct {
	// contains filtered or unexported fields
}

Observer is a simple Observable that wraps a handler function.

func (Observer) Run

func (o Observer) Run(c Containable) error

Run invokes the observer's handler with the updated config.

type Schema

type Schema struct {
	// contains filtered or unexported fields
}

Schema defines the expected structure and constraints for configuration values.

func NewSchema

func NewSchema(opts ...SchemaOption) (*Schema, error)

NewSchema creates a Schema from the provided options.

Example
package main

import (
	"fmt"

	"gitlab.com/phpboyscout/go-tool-base/pkg/config"
)

func main() {
	type AppConfig struct {
		Server struct {
			Port int    `config:"server.port" default:"8080"`
			Host string `config:"server.host"`
		}
		Log struct {
			Level string `config:"log.level" enum:"debug,info,warn,error" default:"info"`
		}
		Github struct {
			Token string `config:"github.token" validate:"required"`
		}
	}

	schema, err := config.NewSchema(config.WithStructSchema(AppConfig{}))
	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	_ = schema // Use with container.Validate(schema)
}

func SchemaOf added in v0.4.0

func SchemaOf[T any](opts ...SchemaOption) (*Schema, error)

SchemaOf returns a Schema derived from the struct tags of T. For the option-free call the result is cached per type, so repeated calls for the same T do not re-reflect. When opts are supplied (for example WithStrictMode) the schema is built fresh and not cached, since options can change the result.

T must be a struct; see WithStructSchema for the supported tags.

func (*Schema) Fields

func (s *Schema) Fields() map[string]FieldSchema

Fields returns the schema field definitions.

type SchemaOption

type SchemaOption func(*schemaConfig)

SchemaOption configures schema construction.

func WithStrictMode

func WithStrictMode() SchemaOption

WithStrictMode treats unknown keys as errors instead of warnings.

func WithStructSchema

func WithStructSchema(v any) SchemaOption

WithStructSchema derives a schema from a tagged Go struct. Supported tags: `config:"key" validate:"required" enum:"a,b,c" default:"value"`.

type ValidationError

type ValidationError struct {
	// Key is the dot-separated config key.
	Key string
	// Message is a human-readable description of the failure.
	Message string
	// Hint is an actionable fix suggestion.
	Hint string
}

ValidationError contains details about a single validation failure.

func (ValidationError) String

func (e ValidationError) String() string

type ValidationResult

type ValidationResult struct {
	Errors   []ValidationError
	Warnings []ValidationError
}

ValidationResult holds the outcome of schema validation.

func (*ValidationResult) Error

func (r *ValidationResult) Error() string

Error returns a formatted multi-line error string, or empty string if valid.

func (*ValidationResult) Valid

func (r *ValidationResult) Valid() bool

Valid returns true if no errors were found. Warnings do not affect validity.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL