rigging

package module
v0.4.6 Latest Latest
Warning

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

Go to latest
Published: Dec 18, 2025 License: MIT Imports: 15 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"`
        Port     int    `conf:"default:5432,min:1024,max:65535"`
        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.Port is an int, guaranteed by compiler
// Observable: Track where Database.Host came from (file vs env)
// Policy-driven: Port validated to be within 1024-65535 range

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

Capture configuration snapshots for debugging across environments:

snapshot, _ := rigging.CreateSnapshot(cfg)
rigging.WriteSnapshot(snapshot, "config-{{timestamp}}.json")
// Creates: config-20240115-103000.json with secrets redacted
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

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 Custom sources Built-in No

* Rigging provides the Watch() API for custom configuration sources. Built-in file and environment sources don't support watching yet.

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

Documentation

  • Quick Start Guide - Get started with installation, basic usage, validation, and observability
  • Configuration Sources - Learn about environment variables, file sources, custom sources, and watch/reload
  • API Reference - Complete API documentation for all types, methods, and struct tags
  • Configuration Patterns - Best practices and design patterns for organizing your configuration
  • Examples - Complete working examples

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.

Reference

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
Example (PrefixVsName)

Example_prefixVsName demonstrates the difference between prefix and name tags.

- prefix: Adds a prefix to ALL nested fields (e.g., `conf:"prefix:database"` → "database.host", "database.port") - name: Overrides the key for a single field (e.g., `conf:"name:db.user"` → looks for exact key "db.user") - name tag takes precedence over prefix (ignores parent struct prefix)

package main

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

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

func main() {
	type DatabaseConfig struct {
		Host     string `conf:"required"`              // With prefix "database": looks for "database.host"
		Port     int    `conf:"default:5432"`          // With prefix "database": looks for "database.port"
		Username string `conf:"name:db.user,required"` // name tag: looks for exact key "db.user" (ignores prefix!)
	}

	type ServerConfig struct {
		Host string `conf:"required"`     // With prefix "server": looks for "server.host"
		Port int    `conf:"default:8080"` // With prefix "server": looks for "server.port"
	}

	type Config struct {
		AppName  string         `conf:"name:app.name,default:myapp"` // Looks for "app.name" (not "appname")
		Server   ServerConfig   `conf:"prefix:server"`               // Adds "server." prefix to nested fields
		Database DatabaseConfig `conf:"prefix:database"`             // Adds "database." prefix to nested fields
	}

	// Set environment variables
	os.Setenv("EXPVN_APP__NAME", "testapp")       // Matches app.name (__ becomes .)
	os.Setenv("EXPVN_SERVER__HOST", "localhost")  // Matches Server.Host (with prefix)
	os.Setenv("EXPVN_SERVER__PORT", "9000")       // Matches Server.Port (with prefix)
	os.Setenv("EXPVN_DATABASE__HOST", "db.local") // Matches Database.Host (with prefix)
	os.Setenv("EXPVN_DATABASE__PORT", "3306")     // Matches Database.Port (with prefix)
	os.Setenv("EXPVN_DB__USER", "admin")          // Matches Database.Username (name tag ignores prefix)
	defer func() {
		os.Unsetenv("EXPVN_APP__NAME")
		os.Unsetenv("EXPVN_SERVER__HOST")
		os.Unsetenv("EXPVN_SERVER__PORT")
		os.Unsetenv("EXPVN_DATABASE__HOST")
		os.Unsetenv("EXPVN_DATABASE__PORT")
		os.Unsetenv("EXPVN_DB__USER")
	}()

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

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

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

}
Output:

AppName: testapp
Server.Host: localhost
Server.Port: 9000
Database.Host: db.local
Database.Port: 3306
Database.Username: admin
Example (SnapshotRoundTrip)

Example_snapshotRoundTrip demonstrates the complete snapshot lifecycle: Create → Write → Read, showing round-trip consistency.

package main

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

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

func main() {
	type Config struct {
		Environment string `conf:"default:production"`
		Host        string `conf:"required"`
		Port        int    `conf:"default:443"`
		APIKey      string `conf:"required,secret"`
	}

	os.Setenv("EXRT_HOST", "api.example.com")
	os.Setenv("EXRT_APIKEY", "secret-api-key")
	defer func() {
		os.Unsetenv("EXRT_HOST")
		os.Unsetenv("EXRT_APIKEY")
	}()

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

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

	// Step 1: Create snapshot
	original, err := rigging.CreateSnapshot(cfg)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Step 1: Created snapshot")

	// Step 2: Write to disk
	tmpDir, err := os.MkdirTemp("", "roundtrip-example")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(tmpDir)

	path := tmpDir + "/config.json"
	if writeErr := rigging.WriteSnapshot(original, path); writeErr != nil {
		log.Fatal(writeErr)
	}
	fmt.Println("Step 2: Wrote snapshot to disk")

	// Step 3: Read back from disk
	restored, err := rigging.ReadSnapshot(path)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Step 3: Read snapshot from disk")

	// Verify round-trip consistency
	fmt.Printf("Version matches: %v\n", original.Version == restored.Version)
	fmt.Printf("Timestamp matches: %v\n", original.Timestamp.Equal(restored.Timestamp))
	fmt.Printf("Host matches: %v\n", original.Config["host"] == restored.Config["host"])
	fmt.Printf("Secret still redacted: %v\n", restored.Config["apikey"] == "***redacted***")

}
Output:

Step 1: Created snapshot
Step 2: Wrote snapshot to disk
Step 3: Read snapshot from disk
Version matches: true
Timestamp matches: true
Host matches: true
Secret still redacted: true
Example (UnderscoreNormalization)

Example_underscoreNormalization demonstrates how underscores in environment variables are normalized to match camelCase field names.

package main

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

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

func main() {
	type Config struct {
		MaxConnections int
		APIKey         string
	}

	// All these environment variable formats match the same fields:
	// - Single underscores are stripped for flexible matching
	// - Double underscores (__) create nested structures
	// - Everything is case-insensitive

	os.Setenv("EXNORM_MAX_CONNECTIONS", "100") // Underscores → matches MaxConnections
	os.Setenv("EXNORM_API_KEY", "secret-key")  // Underscores → matches APIKey
	defer func() {
		os.Unsetenv("EXNORM_MAX_CONNECTIONS")
		os.Unsetenv("EXNORM_API_KEY")
	}()

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

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

	fmt.Printf("MaxConnections: %d\n", cfg.MaxConnections)
	fmt.Printf("APIKey: %s\n", cfg.APIKey)

}
Output:

MaxConnections: 100
APIKey: secret-key

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.

View Source
const MaxSnapshotSize = 100 * 1024 * 1024

MaxSnapshotSize is the maximum allowed snapshot size (100MB).

View Source
const SnapshotVersion = "1.0"

SnapshotVersion is the current snapshot format version.

Variables

View Source
var (
	// ErrSnapshotTooLarge is returned when a snapshot exceeds MaxSnapshotSize.
	ErrSnapshotTooLarge = errors.New("rigging: snapshot exceeds 100MB size limit")

	// ErrNilConfig is returned when CreateSnapshot receives a nil config.
	ErrNilConfig = errors.New("rigging: config is nil")

	// ErrUnsupportedVersion is returned when reading a snapshot with unknown version.
	ErrUnsupportedVersion = errors.New("rigging: unsupported snapshot version")
)

Snapshot errors.

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)

func ExpandPath added in v0.4.6

func ExpandPath(template string) string

ExpandPath expands template variables using current time. For consistency with snapshot metadata, prefer WriteSnapshot which uses the snapshot's internal timestamp for expansion.

func ExpandPathWithTime added in v0.4.6

func ExpandPathWithTime(template string, t time.Time) string

ExpandPathWithTime expands template variables using the provided timestamp. Replaces all {{timestamp}} occurrences with the time formatted as 20060102-150405. Returns the path unchanged if no template variables are present.

func WriteSnapshot added in v0.4.6

func WriteSnapshot(snapshot *ConfigSnapshot, pathTemplate string) error

WriteSnapshot persists a snapshot to disk with atomic write semantics. Supports {{timestamp}} template variable in path - uses snapshot.Timestamp (not current time) to ensure filename matches internal metadata. Returns ErrSnapshotTooLarge if serialized size exceeds 100MB.

Example

ExampleWriteSnapshot demonstrates persisting a snapshot to disk. The path supports {{timestamp}} template variable which is expanded using the snapshot's internal timestamp for consistency.

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("EXWRITE_HOST", "localhost")
	defer os.Unsetenv("EXWRITE_HOST")

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

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

	// Create a snapshot
	snapshot, err := rigging.CreateSnapshot(cfg)
	if err != nil {
		log.Fatal(err)
	}

	// Create a temp directory for the example
	tmpDir, err := os.MkdirTemp("", "snapshot-example")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(tmpDir)

	// Write snapshot with timestamp template in path
	// The {{timestamp}} is replaced with the snapshot's timestamp (e.g., 20240115-103000)
	path := tmpDir + "/config-{{timestamp}}.json"
	err = rigging.WriteSnapshot(snapshot, path)
	if err != nil {
		log.Fatal(err)
	}

	// Verify the file was created
	expectedPath := rigging.ExpandPathWithTime(path, snapshot.Timestamp)
	if _, statErr := os.Stat(expectedPath); statErr == nil {
		fmt.Println("Snapshot written successfully")
	}

	// Read the file to show it's valid JSON
	readSnapshot, err := rigging.ReadSnapshot(expectedPath)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Read back version: %s\n", readSnapshot.Version)
	fmt.Printf("Read back host: %s\n", readSnapshot.Config["host"])

}
Output:

Snapshot written successfully
Read back version: 1.0
Read back host: localhost
Example (ErrorHandling)

ExampleWriteSnapshot_errorHandling demonstrates error handling when writing snapshots.

package main

import (
	"fmt"

	"github.com/Azhovan/rigging"
)

func main() {
	// Attempting to write a nil snapshot returns an error
	err := rigging.WriteSnapshot(nil, "/tmp/snapshot.json")
	if err != nil {
		fmt.Printf("Error: %v\n", err)
	}

}
Output:

Error: rigging: config is nil

Types

type ChangeEvent

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

ChangeEvent notifies of configuration changes.

type ConfigSnapshot added in v0.4.6

type ConfigSnapshot struct {
	// Version is the snapshot format version (currently "1.0")
	Version string `json:"version"`

	// Timestamp is when the snapshot was created
	Timestamp time.Time `json:"timestamp"`

	// Config contains flattened configuration values with secrets redacted.
	// Keys are dot-notation paths (e.g., "database.host").
	Config map[string]any `json:"config"`

	// Provenance tracks the source of each configuration field.
	Provenance []FieldProvenance `json:"provenance"`
}

ConfigSnapshot represents a point-in-time configuration capture.

func CreateSnapshot added in v0.4.6

func CreateSnapshot[T any](cfg *T, opts ...SnapshotOption) (*ConfigSnapshot, error)

CreateSnapshot captures the current configuration state. Returns a snapshot with flattened config, provenance, and metadata. Secrets are automatically redacted using existing provenance data. The snapshot's Timestamp is captured at creation time.

Example

ExampleCreateSnapshot demonstrates creating a configuration snapshot. Snapshots capture the effective configuration state at a point in time, including provenance information and automatic secret redaction.

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"`
		APIKey   string `conf:"required,secret"`
		LogLevel string `conf:"default:info"`
	}

	os.Setenv("EXSNAP_HOST", "api.example.com")
	os.Setenv("EXSNAP_APIKEY", "super-secret-key-12345")
	defer func() {
		os.Unsetenv("EXSNAP_HOST")
		os.Unsetenv("EXSNAP_APIKEY")
	}()

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

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

	// Create a snapshot of the loaded configuration
	snapshot, err := rigging.CreateSnapshot(cfg)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Snapshot version: %s\n", snapshot.Version)
	fmt.Printf("Host: %s\n", snapshot.Config["host"])
	fmt.Printf("Port: %v\n", snapshot.Config["port"])
	fmt.Printf("APIKey: %s\n", snapshot.Config["apikey"]) // Secret is redacted
	fmt.Printf("LogLevel: %s\n", snapshot.Config["loglevel"])

}
Output:

Snapshot version: 1.0
Host: api.example.com
Port: 8080
APIKey: ***redacted***
LogLevel: info
Example (WithExclusions)

ExampleCreateSnapshot_withExclusions demonstrates excluding specific fields from a snapshot using WithExcludeFields.

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"`
		Debug    bool   `conf:"default:false"`
		Internal string `conf:"default:internal-value"`
	}

	os.Setenv("EXSNAPEX_HOST", "localhost")
	defer os.Unsetenv("EXSNAPEX_HOST")

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

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

	// Create snapshot excluding specific fields
	snapshot, err := rigging.CreateSnapshot(cfg,
		rigging.WithExcludeFields("debug", "internal"),
	)
	if err != nil {
		log.Fatal(err)
	}

	// Print all keys in the snapshot config
	fmt.Printf("Host present: %v\n", snapshot.Config["host"] != nil)
	fmt.Printf("Port present: %v\n", snapshot.Config["port"] != nil)
	fmt.Printf("Debug present: %v\n", snapshot.Config["debug"] != nil)
	fmt.Printf("Internal present: %v\n", snapshot.Config["internal"] != nil)

}
Output:

Host present: true
Port present: true
Debug present: false
Internal present: false

func ReadSnapshot added in v0.4.6

func ReadSnapshot(path string) (*ConfigSnapshot, error)

ReadSnapshot loads a snapshot from disk. Returns ErrUnsupportedVersion if snapshot version is not supported. Returns appropriate errors for missing file or invalid JSON.

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 SnapshotOption added in v0.4.6

type SnapshotOption func(*snapshotConfig)

SnapshotOption configures snapshot creation behavior.

func WithExcludeFields added in v0.4.6

func WithExcludeFields(paths ...string) SnapshotOption

WithExcludeFields excludes specified field paths from the snapshot. Paths use dot notation (e.g., "database.password", "cache.redis.url").

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