rigging

package module
v0.4.3 Latest Latest
Warning

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

Go to latest
Published: Dec 1, 2025 License: MIT Imports: 10 Imported by: 0

README

Rigging

Go Version Go Reference License Go Report Card

A typed, observable, policy-driven configuration system for Go services.

Stop debugging configuration errors in production. Rigging gives you compile-time safety, runtime observability, and policy enforcement for your service configuration.

type Config struct {
    Database struct {
        Host     string `conf:"required"`
        Password string `conf:"required,secret"`
    } `conf:"prefix:database"`
}

cfg, err := rigging.NewLoader[Config]().
    WithSource(sourcefile.New("config.yaml", sourcefile.Options{})).
    WithSource(sourceenv.New(sourceenv.Options{Prefix: "APP_"})).
    Load(ctx)
// Type-safe: cfg.Database.Host is a string, not interface{}
// Observable: Know exactly where each value came from
// Policy-driven: Validation rules enforced at load time

Why Rigging?

Configuration management in production services faces several challenges:

  • Type Safety: String-based key access loses compile-time guarantees
  • Observability: Difficult to trace where configuration values originated
  • Validation: Business rules scattered throughout the codebase
  • Testing: Global state makes configuration hard to test
  • Precedence: Unclear which source wins when values conflict

Rigging addresses these through three core principles:

1. Typed Configuration

Define your configuration schema as Go structs. The compiler catches errors, your IDE provides autocomplete, and refactoring tools work correctly.

type Config struct {
    Database struct {
        Host string `conf:"required"`
        Port int    `conf:"default:5432"`
    } `conf:"prefix:database"`
}

cfg, err := loader.Load(ctx)
// cfg.Database.Port is an int, guaranteed by the compiler
// No runtime type assertions needed
2. Observable Configuration

Track the source of every configuration value. Know exactly where each value came from for debugging and compliance.

prov, _ := rigging.GetProvenance(cfg)
for _, field := range prov.Fields {
    log.Printf("%s from %s", field.FieldPath, field.SourceName)
}
// Output:
// Database.Host from file:config.yaml
// Database.Password from env:APP_DATABASE__PASSWORD
// Database.Port from default
3. Policy-Driven Validation

Enforce validation rules at startup. All configuration is validated before your application runs.

type Config struct {
    Environment string `conf:"required,oneof:prod,staging,dev"`
    Database struct {
        Port int `conf:"min:1024,max:65535"`
    } `conf:"prefix:database"`
}

// Custom cross-field validation
loader.WithValidator(rigging.ValidatorFunc[Config](func(ctx context.Context, cfg *Config) error {
    if cfg.Environment == "prod" && cfg.Database.Host == "localhost" {
        return errors.New("production cannot use localhost")
    }
    return nil
}))

cfg, err := loader.Load(ctx)
// If we reach here, all validation passed

Installation

# Core library (zero dependencies)
go get github.com/Azhovan/rigging

# File support (YAML/JSON/TOML)
go get github.com/Azhovan/rigging/sourcefile

# Environment variables
go get github.com/Azhovan/rigging/sourceenv

Quick Start

Basic Usage
package main

import (
    "context"
    "log"
    
    "github.com/Azhovan/rigging"
    "github.com/Azhovan/rigging/sourcefile"
    "github.com/Azhovan/rigging/sourceenv"
)

type Config struct {
    Server struct {
        Port int    `conf:"default:8080"`
        Host string `conf:"default:0.0.0.0"`
    } `conf:"prefix:server"`
    
    Database struct {
        Host     string `conf:"required"`
        Port     int    `conf:"default:5432"`
        Password string `conf:"required,secret"`
    } `conf:"prefix:database"`
}

func main() {
    loader := rigging.NewLoader[Config]().
        WithSource(sourcefile.New("config.yaml", sourcefile.Options{})).
        WithSource(sourceenv.New(sourceenv.Options{Prefix: "APP_"}))
    
    cfg, err := loader.Load(context.Background())
    if err != nil {
        log.Fatal(err)
    }
    
    // Use your configuration
    log.Printf("Starting server on %s:%d", cfg.Server.Host, cfg.Server.Port)
}
Multi-Source Configuration

Sources are processed in order. Later sources override earlier ones.

loader := rigging.NewLoader[Config]().
    WithSource(sourcefile.New("defaults.yaml", sourcefile.Options{})).  // Base configuration
    WithSource(sourcefile.New("config.yaml", sourcefile.Options{})).    // Environment-specific
    WithSource(sourceenv.New(sourceenv.Options{Prefix: "APP_"}))        // Runtime overrides
Validation

Tag-based validation:

type Config struct {
    Port        int    `conf:"required,min:1024,max:65535"`
    Environment string `conf:"required,oneof:prod,staging,dev"`
    Timeout     time.Duration `conf:"default:30s"`
}

Custom validation:

loader.WithValidator(rigging.ValidatorFunc[Config](func(ctx context.Context, cfg *Config) error {
    if cfg.Environment == "prod" && cfg.Database.Host == "localhost" {
        return errors.New("production cannot use localhost")
    }
    return nil
}))
Observability

Track configuration sources:

cfg, _ := loader.Load(ctx)

prov, _ := rigging.GetProvenance(cfg)
for _, field := range prov.Fields {
    log.Printf("%s from %s", field.FieldPath, field.SourceName)
}

Dump configuration safely:

// Secrets are automatically redacted
rigging.DumpEffective(os.Stdout, cfg, rigging.WithSources())

// Output:
// server.host: "0.0.0.0" (source: file:config.yaml)
// server.port: 8080 (source: default)
// database.host: "localhost" (source: file:config.yaml)
// database.port: 5432 (source: default)
// database.password: "***redacted***" (source: env:APP_DATABASE__PASSWORD)
Optional Fields

Distinguish "not set" from "zero value":

type Config struct {
    Timeout rigging.Optional[time.Duration]
}

cfg, _ := loader.Load(ctx)

if timeout, ok := cfg.Timeout.Get(); ok {
    // Value was explicitly set
    client.SetTimeout(timeout)
} else {
    // Value not set, use computed default
    client.SetTimeout(computeDefault())
}

Core Concepts

Struct Tags

Control binding and validation with struct tags:

type Config struct {
    // Required field
    ApiKey string `conf:"required"`
    
    // Default value
    Port int `conf:"default:8080"`
    
    // Validation constraints
    MaxConns int `conf:"min:1,max:100"`
    
    // Allowed values
    Environment string `conf:"oneof:prod,staging,dev"`
    
    // Secret (auto-redacted)
    Password string `conf:"secret"`
    
    // Nested with prefix
    Database DatabaseConfig `conf:"prefix:database"`
}
Source Precedence

Sources are processed in order, later sources override earlier ones:

loader := rigging.NewLoader[Config]().
    WithSource(source1).  // Base layer
    WithSource(source2).  // Overrides source1
    WithSource(source3)   // Overrides source2

Common pattern:

  1. Defaults (hardcoded or file)
  2. Environment-specific file (dev.yaml, prod.yaml)
  3. Environment variables (for secrets and overrides)
Validation Order
  1. Type conversion: String → target type
  2. Tag validation: required, min, max, oneof
  3. Custom validators: Your business rules

All errors are collected and returned together.

Comparison with Other Libraries

Feature Rigging Viper envconfig
Type safety Compile-time Runtime Compile-time
Multi-source Explicit order Implicit Env only
Provenance Full tracking No No
Validation Tags + custom Manual Tags only
Secret redaction Automatic Manual Manual
Global state None Singleton None
Watch/reload API ready* Built-in No

* loader.Watch() is implemented. Built-in sources return ErrWatchNotSupported. Implement Source.Watch() in custom sources to enable hot-reload.

Configuration Sources

Environment Variables
source := sourceenv.New(sourceenv.Options{
    Prefix:        "APP_",  // Only load APP_* variables
    CaseSensitive: false,   // Prefix matching is case-insensitive (default)
})

// Maps environment variables to struct fields:
// APP_DATABASE__HOST → Database.Host
// APP_SERVER__PORT → Server.Port

Prefix Matching:

  • By default (CaseSensitive: false), prefix matching is case-insensitive
  • APP_, app_, and App_ all match when prefix is "APP_"
  • Set CaseSensitive: true for exact case matching
  • Keys are always normalized to lowercase after prefix stripping
// Case-insensitive (default) - matches all variations
sourceenv.New(sourceenv.Options{
    Prefix:        "APP_",
    CaseSensitive: false,
})
// Matches: APP_HOST, app_host, App_Host

// Case-sensitive - exact match only
sourceenv.New(sourceenv.Options{
    Prefix:        "APP_",
    CaseSensitive: true,
})
// Matches: APP_HOST only
// Ignores: app_host, App_Host
Files (YAML/JSON/TOML)
source := sourcefile.New("config.yaml", sourcefile.Options{
    Required: true,  // Error if file missing
})

// Auto-detects format from extension
// Flattens nested structures to dot-separated keys
Custom Sources

Implement the Source interface:

type Source interface {
    Load(ctx context.Context) (map[string]any, error)
    Watch(ctx context.Context) (<-chan ChangeEvent, error)
    Name() string // Returns human-readable identifier
}

// Example: Consul KV store
type ConsulSource struct {
    client *consul.Client
}

func (s *ConsulSource) Load(ctx context.Context) (map[string]any, error) {
    // Fetch from Consul
}

func (s *ConsulSource) Name() string {
    return "consul:kv"
}

Enhanced Provenance (Optional):

Implement SourceWithKeys to provide detailed source attribution:

type SourceWithKeys interface {
    Source
    LoadWithKeys(ctx context.Context) (data map[string]any, originalKeys map[string]string, err error)
}

func (s *ConsulSource) LoadWithKeys(ctx context.Context) (map[string]any, map[string]string, error) {
    data := make(map[string]any)
    originalKeys := make(map[string]string)
    
    // Load from Consul
    data["database.host"] = "localhost"
    originalKeys["database.host"] = "config/database/host" // Original Consul key
    
    return data, originalKeys, nil
}

This enables detailed provenance like consul:kv:config/database/host for non-file sources (environment variables, remote stores, etc.). For file sources, just the source name is sufficient.

Watch and Reload

The Watch API allows monitoring sources for changes and reloading configuration automatically:

snapshots, errors, err := loader.Watch(ctx)
if err != nil {
    log.Fatal(err)
}

go func() {
    for {
        select {
        case snapshot := <-snapshots:
            log.Printf("Config reloaded: v%d", snapshot.Version)
            applyNewConfig(snapshot.Config)
            
        case err := <-errors:
            log.Printf("Reload failed: %v", err)
            // Previous config still valid
        }
    }
}()

Note: Built-in sources (sourcefile, sourceenv) return ErrWatchNotSupported. To use watch with custom sources:

type MySource struct{}

func (s *MySource) Watch(ctx context.Context) (<-chan rigging.ChangeEvent, error) {
    ch := make(chan rigging.ChangeEvent)
    go func() {
        // Emit events when config changes
        ch <- rigging.ChangeEvent{At: time.Now(), Cause: "updated"}
    }()
    return ch, nil
}

Strict Mode

Catch typos and deprecated keys:

loader.Strict(true)  // Fail on unknown keys (default)
loader.Strict(false) // Ignore unknown keys

Error Handling

All validation errors include field paths and codes:

cfg, err := loader.Load(ctx)
if err != nil {
    if valErr, ok := err.(*rigging.ValidationError); ok {
        for _, fe := range valErr.FieldErrors {
            log.Printf("%s: %s (code: %s)", 
                fe.FieldPath, fe.Message, fe.Code)
        }
    }
}

Configuration Patterns

Organize with Nested Structs
// Good: Clear schema
type Config struct {
    Server   ServerConfig   `conf:"prefix:server"`
    Database DatabaseConfig `conf:"prefix:database"`
}

// Avoid: Flat structure
type Config struct {
    ServerPort int
    ServerHost string
    DatabaseHost string
    DatabasePort int
    // ... 50 more fields
}
Field Naming

Use idiomatic Go names - keys are automatically normalized:

type Config struct {
    MaxConnections int           // Matches: maxconnections
    APIKey         string         // Matches: apikey
    RetryTimeout   time.Duration  // Matches: retrytimeout
}

Key normalization: All keys are fully lowercased for matching. Field name MaxConnections automatically matches config key maxconnections, MAXCONNECTIONS, or max_connections (after normalization). Use name: tag only when you need a different key path:

type Config struct {
    MaxConnections int `conf:"name:max.connections"` // Matches: max.connections
}

### Handling Secrets

```go
// Good: Secrets marked
type Config struct {
    Password string `conf:"secret"`
    ApiKey   string `conf:"secret"`
}
Startup Validation
// ✓ Good: Validate at startup
func main() {
    cfg, err := loader.Load(ctx)
    if err != nil {
        log.Fatal(err)  // Fail fast
    }
    
    // Config guaranteed valid
    startServer(cfg)
}
Production Logging
// ✓ Good: Log configuration sources at startup
cfg, _ := loader.Load(ctx)
prov, _ := rigging.GetProvenance(cfg)

log.Info("Configuration loaded:")
for _, field := range prov.Fields {
    if !field.Secret {
        log.Infof("  %s from %s", field.FieldPath, field.SourceName)
    }
}

API Reference

Core Types
Loader[T]

The main entry point for loading configuration.

loader := rigging.NewLoader[Config]()

Methods:

  • WithSource(src Source) *Loader[T] - Add a configuration source
  • WithValidator(v Validator[T]) *Loader[T] - Add a custom validator
  • Strict(strict bool) *Loader[T] - Enable/disable strict mode
  • Load(ctx context.Context) (*T, error) - Load and validate configuration
  • Watch(ctx context.Context) (<-chan Snapshot[T], <-chan error, error) - Watch for changes
Source

Interface for configuration sources.

type Source interface {
    Load(ctx context.Context) (map[string]any, error)
    Watch(ctx context.Context) (<-chan ChangeEvent, error)
}

Built-in sources:

  • sourcefile.New(path string, opts sourcefile.Options) - YAML/JSON/TOML files
  • sourceenv.New(opts sourceenv.Options) - Environment variables
Optional[T]

Distinguish "not set" from "zero value".

type Optional[T any] struct {
    Value T
    Set   bool
}

Methods:

  • Get() (T, bool) - Returns value and whether it was set
  • OrDefault(defaultVal T) T - Returns value or default
Validator[T]

Interface for custom validation.

type Validator[T any] interface {
    Validate(ctx context.Context, cfg *T) error
}

Helper:

  • ValidatorFunc[T](func(ctx context.Context, cfg *T) error) - Function adapter
Observability
GetProvenance

Track where configuration values came from.

func GetProvenance[T any](cfg *T) (*Provenance, bool)

Returns provenance metadata with field-level source information.

type Provenance struct {
    Fields []FieldProvenance
}

type FieldProvenance struct {
    FieldPath  string // e.g., "Database.Host"
    KeyPath    string // e.g., "database.host"
    SourceName string // e.g., "file:config.yaml" or "env:APP_DATABASE__PASSWORD"
    Secret     bool   // true if marked as secret
}
DumpEffective

Safely dump configuration with secret redaction.

func DumpEffective[T any](w io.Writer, cfg *T, opts ...DumpOption) error

Options:

  • WithSources() - Include source attribution
  • AsJSON() - Output as JSON instead of text
  • WithIndent(indent string) - Set JSON indentation

Examples:

// Text format
rigging.DumpEffective(os.Stdout, cfg)

// With source attribution
rigging.DumpEffective(os.Stdout, cfg, rigging.WithSources())

// JSON format
rigging.DumpEffective(os.Stdout, cfg, rigging.AsJSON())

// JSON with custom indent
rigging.DumpEffective(os.Stdout, cfg, 
    rigging.AsJSON(), 
    rigging.WithIndent("    "))
Error Types
ValidationError

Aggregates all validation failures.

type ValidationError struct {
    FieldErrors []FieldError
}
FieldError

Represents a single field validation failure.

type FieldError struct {
    FieldPath string // e.g., "Database.Port"
    Code      string // e.g., "required", "min", "max"
    Message   string // Human-readable error
}

Standard error codes:

  • required - Field is required but not provided
  • min - Value below minimum
  • max - Value exceeds maximum
  • oneof - Value not in allowed set
  • invalid_type - Type conversion failed
  • unknown_key - Configuration key doesn't map to any field (strict mode)
Struct Tags

Configure binding and validation with the conf tag:

Tag Description Example
required Field must have a value conf:"required"
default:X Default value if not provided conf:"default:8080"
min:N Minimum value (numeric) or length (string) conf:"min:1024"
max:N Maximum value (numeric) or length (string) conf:"max:65535"
oneof:a,b,c Value must be one of the options conf:"oneof:prod,staging,dev"
secret Mark field for redaction conf:"secret"
prefix:path Prefix for nested struct fields conf:"prefix:database"
name:path Override derived key path conf:"name:custom.path"

Combining tags:

type Config struct {
    Port     int    `conf:"default:8080,min:1024,max:65535"`
    Env      string `conf:"required,oneof:prod,staging,dev"`
    Password string `conf:"required,secret"`
}

Tag precedence:

  • name: overrides all key derivation (ignores prefix: and field name)
  • prefix: applies to nested struct fields
  • Without name:, keys are derived from field names (lowercased first letter)
type Config struct {
    Database struct {
        Host string              // Key: database.host (prefix applied)
        Port int `conf:"name:db.port"` // Key: db.port (name overrides prefix)
    } `conf:"prefix:database"`
}
Watch and Reload
Snapshot[T]

Represents a loaded configuration with metadata.

type Snapshot[T any] struct {
    Config   *T        // The loaded configuration
    Version  int64     // Incremented on each reload
    LoadedAt time.Time // When loaded
    Source   string    // What triggered the load
}
ChangeEvent

Notification of configuration change.

type ChangeEvent struct {
    At    time.Time // When the change occurred
    Cause string    // Description of the change
}

Examples

See the examples directory for complete working examples:

Documentation

FAQ

Q: Why not just use Viper?
A: Viper uses map[string]interface{} which loses type safety. Rigging gives you compile-time guarantees and provenance tracking.

Q: Can I use this with existing config files?
A: Yes! Rigging supports YAML, JSON, and TOML files. Just define a struct that matches your file structure.

Q: How do I handle secrets?
A: Mark fields with secret tag and load from environment variables. Secrets are automatically redacted in dumps.

Q: Does Rigging support hot-reload?
A: The loader.Watch() API is implemented and ready to use. However, built-in sources (sourcefile, sourceenv) don't emit change events yet. You can implement custom sources with watch support, or wait for file watching (planned via fsnotify).

Q: Is this production-ready?
A: Rigging is designed for production use with comprehensive error handling, validation, and observability. The API is currently v0.x - expect minor breaking changes as we incorporate feedback from early adopters.

License

MIT License - see LICENSE file for details.

Contributing

Contributions welcome! See CONTRIBUTING.md for guidelines.

Documentation

Overview

Package rigging provides type-safe configuration management with validation and provenance tracking.

Quick Start:

type Config struct {
    Port int    `conf:"default:8080,min:1024"`
    Host string `conf:"required"`
}

loader := rigging.NewLoader[Config]().
    WithSource(sourcefile.New("config.yaml", sourcefile.Options{})).
    WithSource(sourceenv.New(sourceenv.Options{Prefix: "APP_"}))

cfg, err := loader.Load(context.Background())

Tag directives: env:VAR, default:val, required, min:N, max:N, oneof:a,b,c, secret, prefix:path, name:path

See example_test.go and README.md for detailed usage.

Example

Example demonstrates basic configuration loading from multiple sources.

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	// Define configuration structure
	type Config struct {
		Environment string `conf:"default:dev,oneof:prod,staging,dev"`
		Port        int    `conf:"default:8080,min:1024,max:65535"`
		Database    struct {
			Host     string `conf:"required"`
			Port     int    `conf:"default:5432"`
			User     string `conf:"required"`
			Password string `conf:"required,secret"`
		} `conf:"prefix:database"`
	}

	// Set up environment variables for this example
	os.Setenv("EXAMPLE_DATABASE__HOST", "localhost")
	os.Setenv("EXAMPLE_DATABASE__USER", "testuser")
	os.Setenv("EXAMPLE_DATABASE__PASSWORD", "testpass")
	defer func() {
		os.Unsetenv("EXAMPLE_DATABASE__HOST")
		os.Unsetenv("EXAMPLE_DATABASE__USER")
		os.Unsetenv("EXAMPLE_DATABASE__PASSWORD")
	}()

	// Create loader with environment source (using prefix to avoid conflicts)
	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXAMPLE_"})).
		Strict(true)

	// Load configuration
	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Environment: %s\n", cfg.Environment)
	fmt.Printf("Port: %d\n", cfg.Port)
	fmt.Printf("Database Host: %s\n", cfg.Database.Host)
	fmt.Printf("Database User: %s\n", cfg.Database.User)

}
Output:

Environment: dev
Port: 8080
Database Host: localhost
Database User: testuser
Example (EnvCaseSensitive)

Example_envCaseSensitive demonstrates case-sensitive prefix matching.

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		Host string `conf:"required"`
		Port int    `conf:"required"`
	}

	// Set environment variables with different cases
	os.Setenv("APP_HOST", "prod.example.com")
	os.Setenv("APP_PORT", "8080")
	os.Setenv("app_host", "dev.example.com") // lowercase prefix
	os.Setenv("app_port", "9090")            // lowercase prefix
	defer func() {
		os.Unsetenv("APP_HOST")
		os.Unsetenv("APP_PORT")
		os.Unsetenv("app_host")
		os.Unsetenv("app_port")
	}()

	// Case-insensitive (default) - matches all variations
	// Both APP_* and app_* are loaded, later ones override
	loaderInsensitive := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{
			Prefix:        "APP_",
			CaseSensitive: false, // default
		}))

	cfg, err := loaderInsensitive.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Case-insensitive: Host=%s, Port=%d\n", cfg.Host, cfg.Port)

	// Case-sensitive - only exact match (APP_* only)
	loaderSensitive := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{
			Prefix:        "APP_",
			CaseSensitive: true,
		}))

	cfg2, err := loaderSensitive.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Case-sensitive: Host=%s, Port=%d\n", cfg2.Host, cfg2.Port)

}
Output:

Case-insensitive: Host=dev.example.com, Port=9090
Case-sensitive: Host=prod.example.com, Port=8080

Index

Examples

Constants

View Source
const (
	ErrCodeRequired    = "required"     // Field is required but not provided
	ErrCodeMin         = "min"          // Value is below minimum constraint
	ErrCodeMax         = "max"          // Value exceeds maximum constraint
	ErrCodeOneOf       = "oneof"        // Value is not in the allowed set
	ErrCodeInvalidType = "invalid_type" // Type conversion failed
	ErrCodeUnknownKey  = "unknown_key"  // Configuration key doesn't map to any field (strict mode)
)

Error codes for validation failures.

Variables

View Source
var ErrWatchNotSupported = errors.New("rigging: watch not supported by this source")

ErrWatchNotSupported is returned when watching is not supported.

Functions

func DumpEffective

func DumpEffective[T any](w io.Writer, cfg *T, opts ...DumpOption) error

DumpEffective writes configuration with automatic secret redaction. Supports text or JSON format. Use WithSources(), AsJSON(), WithIndent() options.

Example

ExampleDumpEffective demonstrates dumping configuration with secret redaction.

package main

import (
	"context"
	"log"
	"os"
	"time"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		APIKey   string        `conf:"secret,required"`
		Endpoint string        `conf:"required"`
		Timeout  time.Duration `conf:"default:30s"`
	}

	os.Setenv("EXDMPEFF_APIKEY", "super-secret-key")
	os.Setenv("EXDMPEFF_ENDPOINT", "https://api.example.com")
	defer func() {
		os.Unsetenv("EXDMPEFF_APIKEY")
		os.Unsetenv("EXDMPEFF_ENDPOINT")
	}()

	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXDMPEFF_"}))

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	// Dump configuration (secrets will be redacted)
	rigging.DumpEffective(os.Stdout, cfg)

}
Output:

apikey: ***redacted***
endpoint: "https://api.example.com"
timeout: 30s
Example (AsJSON)

ExampleDumpEffective_asJSON demonstrates JSON output format.

package main

import (
	"context"
	"log"
	"os"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		Environment string `conf:"default:dev"`
		Port        int    `conf:"default:8080"`
	}

	os.Setenv("EXJSON_ENVIRONMENT", "production")
	defer os.Unsetenv("EXJSON_ENVIRONMENT")

	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXJSON_"}))

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	// Dump as JSON with source attribution
	rigging.DumpEffective(os.Stdout, cfg, rigging.AsJSON(), rigging.WithSources())

}
Output:

{
  "environment": {
    "source": "env:EXJSON_ENVIRONMENT",
    "value": "production"
  },
  "port": {
    "source": "default",
    "value": 8080
  }
}
Example (WithSources)

ExampleDumpEffective_withSources demonstrates dumping with source attribution.

package main

import (
	"context"
	"log"
	"os"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		Port int    `conf:"default:8080"`
		Host string `conf:"default:localhost"`
	}

	os.Setenv("EXDUMP_PORT", "9090")
	defer os.Unsetenv("EXDUMP_PORT")

	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXDUMP_"}))

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	// Dump with source information
	rigging.DumpEffective(os.Stdout, cfg, rigging.WithSources())

}
Output:

port: 9090 (source: env:EXDUMP_PORT)
host: "localhost" (source: default)

Types

type ChangeEvent

type ChangeEvent struct {
	At    time.Time
	Cause string // Description (e.g., "file-changed")
}

ChangeEvent notifies of configuration changes.

type DumpOption

type DumpOption func(*dumpConfig)

DumpOption configures dump behavior.

func AsJSON

func AsJSON() DumpOption

AsJSON outputs configuration as JSON. Secrets are still redacted.

func WithIndent

func WithIndent(indent string) DumpOption

WithIndent sets JSON indentation (default: " "). No effect for text output.

func WithSources

func WithSources() DumpOption

WithSources includes source attribution in output.

type FieldError

type FieldError struct {
	FieldPath string // Dot notation (e.g., "Database.Host")
	Code      string // Error code (e.g., "required", "min")
	Message   string // Human-readable description
}

FieldError represents a single field validation failure.

type FieldProvenance

type FieldProvenance struct {
	FieldPath  string // Dot notation (e.g., "Database.Host")
	KeyPath    string // Normalized key (e.g., "database.host")
	SourceName string // Source identifier (e.g., "env:APP_PORT")
	Secret     bool   // Whether field is secret
}

FieldProvenance describes where a field's value came from.

type Loader

type Loader[T any] struct {
	// contains filtered or unexported fields
}

Loader loads and validates configuration from multiple sources. Sources are processed in order (later override earlier). Supports tag-based and custom validation. Thread-safe for reads, not for concurrent configuration changes.

func NewLoader

func NewLoader[T any]() *Loader[T]

NewLoader creates a Loader with no sources/validators and strict mode enabled.

func (*Loader[T]) Load

func (l *Loader[T]) Load(ctx context.Context) (*T, error)

Load loads, merges, binds, and validates configuration from all sources. Returns populated config or ValidationError with all field errors.

Example

ExampleLoader_Load demonstrates loading configuration with validation.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		APIKey     string        `conf:"required,secret"`
		Timeout    time.Duration `conf:"default:30s"`
		MaxRetries int           `conf:"default:3,min:1,max:10"`
	}

	os.Setenv("EXLOAD_APIKEY", "test-key-12345")
	defer os.Unsetenv("EXLOAD_APIKEY")

	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXLOAD_"}))

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Timeout: %v\n", cfg.Timeout)
	fmt.Printf("MaxRetries: %d\n", cfg.MaxRetries)
	fmt.Printf("APIKey: %s\n", cfg.APIKey)

}
Output:

Timeout: 30s
MaxRetries: 3
APIKey: test-key-12345

func (*Loader[T]) Strict

func (l *Loader[T]) Strict(strict bool) *Loader[T]

Strict controls whether unknown keys cause errors. Default: true.

Example

ExampleLoader_Strict demonstrates strict mode behavior.

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		Host string `conf:"required"`
		Port int    `conf:"default:8080"`
	}

	// Set an unknown configuration key
	os.Setenv("EXSTRICT_HOST", "localhost")
	os.Setenv("EXSTRICT_UNKNOWNKEY", "some-value")
	defer func() {
		os.Unsetenv("EXSTRICT_HOST")
		os.Unsetenv("EXSTRICT_UNKNOWNKEY")
	}()

	// Strict mode enabled (default)
	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXSTRICT_"})).
		Strict(true)

	_, err := loader.Load(context.Background())
	if err != nil {
		fmt.Printf("Strict mode error detected\n")
	}

	// Strict mode disabled
	loader = rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXSTRICT_"})).
		Strict(false)

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Host: %s (strict mode disabled)\n", cfg.Host)

}
Output:

Strict mode error detected
Host: localhost (strict mode disabled)

func (*Loader[T]) Watch

func (l *Loader[T]) Watch(ctx context.Context) (<-chan Snapshot[T], <-chan error, error)

Watch monitors sources for changes and auto-reloads configuration. Returns: snapshots channel, errors channel, initial load error. Changes are debounced (100ms). Built-in sources don't support watching yet.

Example

ExampleLoader_Watch demonstrates configuration watching. Built-in sources (sourceenv, sourcefile) don't support watching yet. Custom sources can implement Watch() to enable hot-reload.

package main

import (
	"context"
	"fmt"

	"github.com/Azhovan/rigging"
)

// staticSource is a custom source that provides static configuration.
// This demonstrates how to implement the Source interface.
type staticSource struct {
	data map[string]any
}

// Load implements the Source interface for staticSource.
func (s *staticSource) Load(ctx context.Context) (map[string]any, error) {
	return s.data, nil
}

// Watch implements the Source interface for staticSource.
func (s *staticSource) Watch(ctx context.Context) (<-chan rigging.ChangeEvent, error) {
	return nil, rigging.ErrWatchNotSupported
}

// Name implements the Source interface for staticSource.
func (s *staticSource) Name() string {
	return "static"
}

func main() {
	type Config struct {
		Host string `conf:"required"`
		Port int    `conf:"default:8080"`
	}

	source := &staticSource{
		data: map[string]any{
			"host": "localhost",
			"port": 8080,
		},
	}

	loader := rigging.NewLoader[Config]().
		WithSource(source)

	// Watch starts monitoring and returns channels
	snapshots, errors, err := loader.Watch(context.Background())
	if err != nil {
		fmt.Printf("Watch failed: %v\n", err)
		return
	}

	// Receive initial snapshot
	snapshot := <-snapshots
	fmt.Printf("Initial config loaded (version %d)\n", snapshot.Version)

	// In a real application, you would monitor snapshots and errors
	// in a goroutine for configuration updates
	_ = errors

}
Output:

Initial config loaded (version 1)

func (*Loader[T]) WithSource

func (l *Loader[T]) WithSource(src Source) *Loader[T]

WithSource adds a source. Sources are processed in order (later override earlier).

func (*Loader[T]) WithValidator

func (l *Loader[T]) WithValidator(v Validator[T]) *Loader[T]

WithValidator adds a custom validator (executed after tag-based validation).

Example

ExampleLoader_WithValidator demonstrates custom validation.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		Environment string `conf:"default:dev"`
		DebugMode   bool   `conf:"default:false"`
	}

	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXVAL_"})).
		WithValidator(rigging.ValidatorFunc[Config](func(ctx context.Context, cfg *Config) error {
			// Cross-field validation: debug mode not allowed in production
			if cfg.Environment == "prod" && cfg.DebugMode {
				return &rigging.ValidationError{
					FieldErrors: []rigging.FieldError{{
						FieldPath: "DebugMode",
						Code:      "invalid_prod_debug",
						Message:   "debug mode cannot be enabled in production",
					}},
				}
			}
			return nil
		}))

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Environment: %s\n", cfg.Environment)
	fmt.Printf("DebugMode: %t\n", cfg.DebugMode)

}
Output:

Environment: dev
DebugMode: false

type Optional

type Optional[T any] struct {
	Value T
	Set   bool
}

Optional distinguishes "not set" from "zero value".

Example

ExampleOptional demonstrates using Optional fields.

package main

import (
	"fmt"
	"time"

	"github.com/Azhovan/rigging"
)

func main() {
	type Config struct {
		Timeout    rigging.Optional[time.Duration]
		MaxRetries rigging.Optional[int]
	}

	cfg := &Config{}

	// Set Timeout but not MaxRetries
	cfg.Timeout = rigging.Optional[time.Duration]{
		Value: 30 * time.Second,
		Set:   true,
	}

	// Check if Timeout was set
	if timeout, ok := cfg.Timeout.Get(); ok {
		fmt.Printf("Timeout is set to: %v\n", timeout)
	}

	// Check if MaxRetries was set
	if _, ok := cfg.MaxRetries.Get(); !ok {
		fmt.Println("MaxRetries was not set")
	}

	// Use OrDefault for fallback values
	maxRetries := cfg.MaxRetries.OrDefault(3)
	fmt.Printf("MaxRetries (with default): %d\n", maxRetries)

}
Output:

Timeout is set to: 30s
MaxRetries was not set
MaxRetries (with default): 3

func (Optional[T]) Get

func (o Optional[T]) Get() (T, bool)

Get returns the wrapped value and whether it was set.

func (Optional[T]) OrDefault

func (o Optional[T]) OrDefault(defaultVal T) T

OrDefault returns the wrapped value or the provided default.

type Provenance

type Provenance struct {
	Fields []FieldProvenance
}

Provenance contains source information for configuration fields.

func GetProvenance

func GetProvenance[T any](cfg *T) (*Provenance, bool)

GetProvenance returns provenance metadata for a loaded configuration. Thread-safe.

Example

ExampleGetProvenance demonstrates querying configuration provenance.

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		Host string `conf:"required"`
		Port int    `conf:"default:8080"`
	}

	os.Setenv("EXPROV_HOST", "example.com")
	defer os.Unsetenv("EXPROV_HOST")

	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXPROV_"}))

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	// Query provenance
	prov, ok := rigging.GetProvenance(cfg)
	if ok {
		for _, field := range prov.Fields {
			fmt.Printf("%s from %s\n", field.FieldPath, field.SourceName)
		}
	}

}
Output:

Host from env:EXPROV_HOST
Port from default

type Snapshot

type Snapshot[T any] struct {
	Config   *T
	Version  int64 // Increments on reload (starts at 1)
	LoadedAt time.Time
	Source   string // What triggered the load
}

Snapshot represents a configuration version emitted by Watch().

type Source

type Source interface {
	// Load returns configuration as a flat map. Missing optional sources should return empty map.
	Load(ctx context.Context) (map[string]any, error)

	// Watch emits ChangeEvent when configuration changes. Returns ErrWatchNotSupported if not supported.
	Watch(ctx context.Context) (<-chan ChangeEvent, error)

	// Name returns a human-readable identifier for this source (e.g., "env:API_", "file:config.yaml").
	Name() string
}

Source provides configuration data from backends (env vars, files, remote stores). Keys must be normalized to lowercase dot-separated paths (e.g., "database.host").

Example

ExampleSource demonstrates implementing a custom source.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/Azhovan/rigging"
)

// staticSource is a custom source that provides static configuration.
// This demonstrates how to implement the Source interface.
type staticSource struct {
	data map[string]any
}

// Load implements the Source interface for staticSource.
func (s *staticSource) Load(ctx context.Context) (map[string]any, error) {
	return s.data, nil
}

// Watch implements the Source interface for staticSource.
func (s *staticSource) Watch(ctx context.Context) (<-chan rigging.ChangeEvent, error) {
	return nil, rigging.ErrWatchNotSupported
}

// Name implements the Source interface for staticSource.
func (s *staticSource) Name() string {
	return "static"
}

func main() {
	// Create a custom source with static data
	source := &staticSource{
		data: map[string]any{
			"host": "localhost",
			"port": 8080,
		},
	}

	type Config struct {
		Host string `conf:"required"`
		Port int    `conf:"required"`
	}

	loader := rigging.NewLoader[Config]().
		WithSource(source)

	cfg, err := loader.Load(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Host: %s, Port: %d\n", cfg.Host, cfg.Port)

}
Output:

Host: localhost, Port: 8080

type SourceWithKeys added in v0.4.2

type SourceWithKeys interface {
	Source
	// LoadWithKeys returns configuration with original keys mapped to normalized keys.
	// The returned map has normalized keys, and originalKeys maps normalized -> original.
	LoadWithKeys(ctx context.Context) (data map[string]any, originalKeys map[string]string, err error)
}

SourceWithKeys is an optional interface that sources can implement to provide original key information for better provenance tracking.

type ValidationError

type ValidationError struct {
	FieldErrors []FieldError
}

ValidationError aggregates field-level validation failures.

Example

ExampleValidationError demonstrates handling validation errors.

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/Azhovan/rigging"
	"github.com/Azhovan/rigging/sourceenv"
)

func main() {
	type Config struct {
		Port int    `conf:"required,min:1024,max:65535"`
		Env  string `conf:"required,oneof:prod,staging,dev"`
	}

	// Set invalid values
	os.Setenv("EXVERR_PORT", "80")        // Below minimum
	os.Setenv("EXVERR_ENV", "production") // Not in oneof list
	defer func() {
		os.Unsetenv("EXVERR_PORT")
		os.Unsetenv("EXVERR_ENV")
	}()

	loader := rigging.NewLoader[Config]().
		WithSource(sourceenv.New(sourceenv.Options{Prefix: "EXVERR_"}))

	_, err := loader.Load(context.Background())
	if err != nil {
		if valErr, ok := err.(*rigging.ValidationError); ok {
			fmt.Printf("Validation failed with %d errors\n", len(valErr.FieldErrors))
		}
	}

}
Output:

Validation failed with 2 errors

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error formats validation errors as a multi-line message.

type Validator

type Validator[T any] interface {
	// Validate checks configuration. Return *ValidationError for field-level errors.
	Validate(ctx context.Context, cfg *T) error
}

Validator performs custom validation after tag-based validation. Use for cross-field, semantic, or external validation.

type ValidatorFunc

type ValidatorFunc[T any] func(ctx context.Context, cfg *T) error

ValidatorFunc is a function adapter for Validator interface.

func (ValidatorFunc[T]) Validate

func (f ValidatorFunc[T]) Validate(ctx context.Context, cfg *T) error

Directories

Path Synopsis
examples
basic command
internal
Package sourceenv loads configuration from environment variables.
Package sourceenv loads configuration from environment variables.
Package sourcefile loads configuration from YAML, JSON, or TOML files.
Package sourcefile loads configuration from YAML, JSON, or TOML files.

Jump to

Keyboard shortcuts

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