Trail Config
A Rust library for reading config files with path-based access, typed deserialization, environment overlays, deep merging, env variable interpolation, and hot reload support.
Features
- ๐ Simple path-based config value access
- ๐ง Customizable path separators (
/,::, etc.) - ๐ Environment-specific config files
- ๐ Environment variable interpolation with defaults (
${VAR},${VAR:-default}) - ๐ String formatting and interpolation
- โ
Comprehensive error handling with custom
ConfigErrortype - ๐ Type conversion for strings, numbers, booleans, and sequences
- ๐๏ธ Struct deserialization โ map the entire config or any subtree directly into a typed Rust struct
- ๐ Escape sequence support for keys containing separators
- ๐ Hot reload support for detecting configuration changes at runtime
- ๐ Deep merge support for layering environment-specific config overlays
- ๐ Auto-create config files from in-code defaults on first run
- ๐งต Thread-safe
ConfigHandlefor sharing config across threads - โก
config!macro for concise loading and merging - ๐ JSON and TOML support via optional feature flags
Quick Start
use Config;
// Load config.yaml file
let config = default;
// Get values with lenient API (returns empty/None on missing)
let port = config.str; // -> "8080"
let timeout = config.get_int; // -> Some(30)
// Or use strict API for explicit error handling
match config.str_strict
Loading Configuration
Trail Config exposes four constructors with a clear, symmetric design:
| Constructor | File required? | Use case |
|---|---|---|
Config::load_required(filename, sep, env) |
Yes โ errors if missing | Production: config must exist |
Config::load_optional(filename, sep, env) |
No โ returns empty config if missing | Optional or environment-specific files |
Config::load_or_create(filename, sep, env, defaults) |
No โ creates from defaults if missing | First-run config generation |
Config::default() |
No | Shorthand for load_optional("config.yaml", "/", None) |
Required config (production)
Use Config::load_required() when the configuration file must exist:
use Config;
let config = load_required?;
// Errors if file is missing, invalid YAML/JSON/TOML, or permission denied
Optional config
Use Config::load_optional() for custom filenames or separators when the file may not exist:
use Config;
// With custom separator
let config = load_optional?;
// With environment substitution
let config = load_optional?;
Default (shorthand)
Use Config::default() when config.yaml with / separator is acceptable and the file is optional:
let config = default; // Never panics, gracefully handles missing config.yaml
From a YAML string
Use Config::load_yaml() to load configuration directly from a string rather than a file. This is useful for tests, embedded defaults, or configs received over the network:
let config = load_yaml?;
From a JSON file or string (requires json feature)
Enable the json feature in your Cargo.toml:
[]
= { = "0.4", = ["json"] }
JSON files are auto-detected by extension:
use Config;
// Auto-detected by .json extension
let config = load_required?;
// Or load explicitly from a string
let config = load_json?;
// Mix YAML base with JSON overlay
let config = load_required?
.merge_required?;
From a TOML file or string (requires toml feature)
Enable the toml feature in your Cargo.toml:
[]
= { = "0.4", = ["toml"] }
TOML files are auto-detected by extension:
use Config;
// Auto-detected by .toml extension
let config = load_required?;
// Or load explicitly from a string
let config = load_toml?;
// Mix formats freely
let config = load_required?
.merge_required?;
Using the config! macro
The config! macro provides a concise syntax for loading and merging configs:
use config;
// Minimal
let config = config!?;
// With custom separator
let config = config!?;
// With environment
let config = config!?;
// With merges
let config = config!?;
// Full syntax
let config = config! ?;
API Overview
Trail Config organizes methods into two styles. Every method has both a lenient and a strict variant:
| Style | Returns | Behaviour on missing path |
|---|---|---|
Lenient โ get(), str(), get_int(), etc. |
Option<T> or empty default |
Returns None or "" / [] |
Strict โ get_strict(), str_strict(), get_int_strict(), etc. |
Result<T, ConfigError> |
Returns Err(PathNotFound) |
Both styles share the same path syntax and navigate nested config values using separators (default: /).
Reading values
| Method | Returns | Description |
|---|---|---|
get(path) |
Option<Value> |
Raw yaml_serde::Value |
get_strict(path) |
Result<Value, ConfigError> |
Raw value, errors if missing |
str(path) |
String |
String representation, empty if missing |
str_strict(path) |
Result<String, ConfigError> |
String, errors if missing |
list(path) |
Vec<String> |
Sequence as string vector, empty if missing |
list_strict(path) |
Result<Vec<String>, ConfigError> |
Sequence, errors if missing |
contains(path) |
bool |
Returns true if path exists |
Typed access
| Method | Returns | Description |
|---|---|---|
get_int(path) |
Option<i64> |
Integer value |
get_int_strict(path) |
Result<i64, ConfigError> |
Integer, errors if missing or wrong type |
get_float(path) |
Option<f64> |
Floating-point value |
get_float_strict(path) |
Result<f64, ConfigError> |
Float, errors if missing or wrong type |
get_bool(path) |
Option<bool> |
Boolean value |
get_bool_strict(path) |
Result<bool, ConfigError> |
Boolean, errors if missing or wrong type |
get_as<T>(path) |
Option<T> |
Deserialize subtree into typed struct |
get_as_strict<T>(path) |
Result<T, ConfigError> |
Deserialize subtree, errors if missing or type mismatch |
deserialize<T>() |
Option<T> |
Deserialize entire config into typed struct |
deserialize_strict<T>() |
Result<T, ConfigError> |
Deserialize entire config, errors on type mismatch |
Formatting
| Method | Returns | Description |
|---|---|---|
fmt(format, base, keys) |
String |
Format sibling values into a string, empty on error |
fmt_strict(format, base, keys) |
Result<String, ConfigError> |
Format, errors if any value is missing |
Metadata and hot reload
| Method | Returns | Description |
|---|---|---|
get_filename() |
&str |
Filename of the loaded config |
environment() |
Option<&str> |
Environment name used when loading |
reload() |
Result<(), ConfigError> |
Reload from current file |
reload_from(filename) |
Result<(), ConfigError> |
Load from a different file |
Error Handling
Trail Config uses a custom ConfigError enum with four variants:
use ConfigError;
// - IoError(io::Error) - File I/O errors (missing file, permission denied, etc.)
// - YamlError(String) - YAML parsing or deserialization errors
// - JsonError(String) - JSON parsing or conversion errors (requires `json` feature)
// - TomlError(String) - TOML parsing or conversion errors (requires `toml` feature)
// - PathNotFound(String) - Configuration path not found in document
// - FormatError(String) - String formatting or configuration errors
Handling load errors
use ;
match load_required
Handling strict method errors
use ;
let config = default;
match config.str_strict
match config.str_strict
match config.get_int_strict
Input validation
Trail Config validates inputs automatically and returns FormatError for invalid configurations:
| Input | Constraint | Error |
|---|---|---|
| Path separator | Cannot be empty | Returns FormatError |
File paths (load_required) |
Empty filename explicitly rejected | Returns IoError |
File paths (load_optional) |
Empty filename passed to OS | Returns IoError |
| Paths | Empty paths safely handled | Returns None or empty |
| Separators (leading/trailing) | Handled gracefully | No error |
| Filename templates | Must be valid format strings | Returns FormatError |
// Empty separator - error
let result = load_optional;
assert!; // FormatError
// load_required rejects empty filename upfront
let result = load_required;
assert!; // IoError (InvalidInput)
// Missing file with load_required - error
let result = load_required;
assert!; // IoError
// Missing file with load_optional - ok, returns empty config
let config = load_optional?;
assert!; // Graceful fallback
Typed Access
Convert config values to Rust primitives safely:
let config = default;
// Lenient - returns None on missing or type mismatch
let port = config.get_int;
let timeout = config.get_float;
let debug = config.get_bool;
if let Some = port
// Strict - returns error details
match config.get_int_strict
Example config (YAML):
app:
port: 8080
timeout: 30.5
debug: true
Struct Deserialization
Use deserialize / deserialize_strict to map the entire config into a typed Rust struct, or get_as / get_as_strict to deserialize a subtree at a specific path. Both approaches are more concise than reading fields one by one, and let the compiler verify you haven't missed any required fields.
Any struct that derives serde::Deserialize can be used:
use Deserialize;
use Config;
let config = load_required?;
// Deserialize the entire config at once
let full: FullConfig = config.deserialize_strict?;
// Or deserialize just a subtree
let db: DatabaseConfig = config.get_as_strict?; // Strict โ returns a descriptive error on failure
let db: = config.get_as; // Lenient โ returns None if path is missing or struct doesn't match
deserialize_strict returns YamlError if the config can't be deserialized into T. get_as_strict additionally returns PathNotFound if the path doesn't exist.
Sample YAML:
app:
port: 8080
debug: false
timeout: 30.0
database:
host: localhost
port: 5432
username: admin
password: secret
String Formatting
Use fmt() to combine multiple sibling config values into a formatted string in a single call:
// Instead of:
let host = config.str;
let port = config.str;
let connection = format!;
// You can write:
let connection = config.fmt;
The fmt() method takes a format string with {} placeholders, a base path to the parent node, and a slice of key names โ one per placeholder. It navigates to the base path, then extracts and formats the specified keys in order.
Multi-value formatting
// database:
// host: localhost
// port: 5432
// name: myapp_db
// username: admin
let db_url = config.fmt;
// Result: "postgresql://admin@localhost:5432/myapp_db"
Lenient vs strict
// Lenient - returns empty string if any value is missing
let connection = config.fmt;
// Strict - returns error if any value is missing
let connection = config.fmt_strict?;
Escape sequences in fmt base path
If a key in the base path contains the separator, escape it with \:
// sections:
// "db/redis": <- key contains a literal slash
// server: 127.0.0.1
// port: 6379
let connection = config.fmt;
// Result: "127.0.0.1:6379"
Escape Sequences
Keys containing the path separator can be accessed using escape sequences.
\<sep>โ include a literal separator in the key (e.g.\/for/,\::for::)\\โ include a literal backslash in the key- Works with any separator:
/,::,->, etc.
database:
"host/port": localhost:5432 # Key contains /
"user\name": admin\user # Key contains \
let config = load_yaml.unwrap;
// Access key containing separator (/)
let value = config.str; // -> "localhost:5432"
// Access key containing backslash (\)
let value = config.str; // -> "admin\user"
With a custom separator:
let config = load_yaml.unwrap;
// Path: a::b\::c::d navigates to keys ["a", "b::c", "d"]
let value = config.str;
Thread-Safe Shared Config
Use ConfigHandle to share a Config across threads and reload it at runtime without restarting. It wraps Config in an Arc<RwLock<...>> โ cloning the handle is cheap, and all clones refer to the same underlying config.
use ;
let handle = new;
// Cheap to clone โ share across threads
let handle2 = handle.clone;
// Convenience methods for common accessors
let port = handle.get_int;
let debug = handle.get_bool;
// Full Config access via read guard
let db: DatabaseConfig = handle.read.get_as_strict?;
// Reload from disk โ write-locks for the duration, re-applies all overlays
handle.reload?;
// All clones immediately see the updated values
Background reload example
use ;
use ;
let handle = new;
// Spawn a background thread to reload every 30 seconds
let reload_handle = handle.clone;
spawn;
// Main thread reads are never blocked except during the brief reload swap
loop
Hot Reload
Detect and apply configuration changes at runtime without restarting:
let mut config = load_required?
.merge_required?
.merge_optional?;
// Reloads base file and re-applies all overlays in order.
// Required overlays that are missing return an error;
// optional overlays that are missing are silently skipped.
// If reload fails, the existing configuration is preserved unchanged.
config.reload?;
// Or switch to a different config file (clears overlay chain)
config.reload_from?;
Server loop example
use Config;
use thread;
use Duration;
Thread Safety
Config is not Send + Sync on its own. Use ConfigHandle to share a config across threads โ it wraps Config in an Arc<RwLock<...>> so it can be cloned freely and reloaded at runtime.
use ;
let handle = new;
// Cheap to clone โ all clones share the same underlying config
let handle2 = handle.clone;
// Convenience methods for common accessors
let port = handle.str;
let debug = handle.get_bool;
// Full access via read guard
let host = handle.read.str_strict?;
// Reload from disk (re-applies all overlays), visible to all clones
handle.reload?;
Background reload loop
use ;
use ;
let handle = new;
// Share with the main application
let app_handle = handle.clone;
// Reload in the background every 5 seconds
spawn;
// Main thread reads are never blocked except during the brief reload swap
let port = app_handle.get_int.unwrap_or;
Merging Configs
Use merge_required / merge_optional to layer configs on top of each other. Values in the overlay take precedence over the base; nested mappings are merged recursively so sibling keys are preserved. Sequences are replaced wholesale. The base config's separator is preserved.
The overlay filenames are recorded so that reload() can re-read and re-apply them in order โ required overlays that are missing on reload return an error, optional overlays that are missing are silently skipped.
use Config;
let env = var.unwrap_or_else;
let mut config = load_required?
.merge_required?
.merge_optional?;
Given these files:
# config.yaml (base)
app:
port: 8080
debug: false
name: myapp
database:
host: localhost
port: 5432
# config.prod.yaml (overlay)
app:
debug: false
database:
host: prodserver
# config.local.yaml (optional personal overrides)
app:
debug: true
The merged result will be:
app:
port: 8080 # from base
debug: true # from config.local.yaml (last overlay wins)
name: myapp # from base
database:
host: prodserver # from config.prod.yaml
port: 5432 # from base โ sibling preserved
Environment Variable Interpolation
Trail Config resolves ${VAR} placeholders in string values at load time using environment variables. Placeholders can include a default value with ${VAR:-default}.
# config.yaml
database:
host: ${DB_HOST:-localhost}
port: 5432
password: ${DB_PASSWORD}
app:
url: ${APP_PROTO:-https}://${APP_DOMAIN}/api
use Config;
// If DB_HOST=prodserver and DB_PASSWORD=secret are set:
let config = load_required?;
assert_eq!;
assert_eq!;
assert_eq!;
Syntax
| Pattern | Behaviour |
|---|---|
${VAR} |
Replaced with the value of VAR. Error if not set. |
${VAR:-default} |
Replaced with the value of VAR, or default if not set. |
$VAR |
Not a placeholder โ left as-is. |
Resolution timing
Environment variables are resolved at load time and re-resolved on every reload() call. This means changes to environment variables are picked up when the config is reloaded.
Error handling
If a placeholder references an unset variable and no default is provided, loading returns a ConfigError::FormatError. Unclosed placeholders (${VAR) and empty variable names (${:-default}) also return errors.
Auto-Creating Config Files
Use load_or_create to handle first-run scenarios where no config file exists yet.
If the file is present its content is used as-is; if not, the provided default YAML
string is written to disk and returned as the active config. Either way the app gets
a fully usable config.
use Config;
const DEFAULTS: &str = r#"
app:
port: 8080
debug: false
database:
host: localhost
port: 5432
"#;
let config = load_or_create?;
On first run config.yaml is created with the contents of DEFAULTS. On subsequent
runs the file is loaded normally and DEFAULTS is ignored โ so users can edit the
file freely without their changes being overwritten.
The defaults string is written as-is, preserving formatting and any comments you include:
const DEFAULTS: &str = r#"
# Application settings
app:
port: 8080 # HTTP port
debug: false # Set to true for verbose logging
# Database connection
database:
host: localhost
port: 5432
"#;
Real-World Examples
Web server configuration
use Config;
let config = load_required?;
let host = config.str;
let port = config.get_int_strict?;
let ssl = config.get_bool.unwrap_or;
let workers = config.get_int.unwrap_or;
println!;
Environment-specific configuration
use Config;
use env;
let env = var.unwrap_or_else;
let config = load_required?
.merge_required?
.merge_optional?;
let db_url = config.str_strict?;
let log_level = config.str;
println!;
Database connection pooling
Using get_as_strict to deserialize the entire db section into a struct at once:
use Deserialize;
use Config;
let config = default;
let db: DbConfig = config.get_as_strict?;
let pool = create_pool?;
db:
host: localhost
port: 5432
username: admin
password: secret
pool_size: 20
timeout: 60.0
Feature flags
use Config;
let config = default;
if config.get_bool.unwrap_or
if config.get_bool.unwrap_or
let beta_features = config.list;
for feature in beta_features
Sample Configuration File
app:
name: MyApp
port: 8080
timeout: 30.5
debug: false
database:
host: localhost
port: 5432
name: myapp_db
username: admin
password: secret
pool_size: 10
server:
bind: 127.0.0.1
workers: 4
log_level: info
features:
analytics: true
profiling: false
beta:
- new_ui
- advanced_search
License
This project is licensed under the MIT License - see the LICENSE file for details