The Problem: Death by a Thousand Configuration Errors#
You deploy your application, and it crashes. Missing database host. You fix it, redeploy. Invalid port value. Fix, redeploy. Pool size must be positive. Fix, redeploy.
$ ./myapp
Error: missing field `database.host`
$ ./myapp # fixed it, try again
Error: invalid port value
$ ./myapp # fixed that too
Error: pool_size must be positive
# Three deaths to find three problems
This is the postmortem experience - discovering problems one at a time, after they’ve already killed your application.
What if you could perform a premortem instead? Find all the ways your configuration would fail before the application ever runs?
$ ./myapp
Configuration errors (3):
[config.toml:8] missing required field 'database.host'
[env:APP_PORT] value "abc" is not a valid integer
[config.toml:10] 'pool_size' value -5 must be >= 1
One run. All errors. Know how your app would die before it does.
Two Philosophies#
Premortem and Figment are both excellent Rust configuration libraries, but they’re designed with fundamentally different priorities.
| Aspect | Premortem | Figment |
|---|---|---|
| Philosophy | Find ALL config problems before runtime | Flexible configuration with profile support |
| Error Handling | Guaranteed error accumulation | Serde-based (fail-fast deserialization) |
| Validation | Built-in validators + custom Validate trait | Via serde deserializers |
| Testing | Virtual filesystem (MockEnv) | Real temp directories (Jail) |
| Ecosystem | Standalone, uses stillwater for FP patterns | Powers the Rocket framework |
Error Handling: The Core Difference#
This is where the libraries diverge most significantly.
Figment: Fail-Fast with Provider Composition#
Figment uses serde’s deserialization, which stops at the first error:
use figment::{Figment, providers::{Toml, Env}};
let figment = Figment::new()
.merge(Toml::file("config.toml"))
.merge(Env::prefixed("APP_"));
match figment.extract::<Config>() {
Ok(config) => { /* use config */ }
Err(e) => {
// Can iterate, but serde stops at first error
for error in e {
println!("Error: {}", error);
}
}
}
When your TOML file has three problems, you’ll discover them one deployment at a time.
Premortem: Guaranteed Error Accumulation#
Premortem uses stillwater’s Validation type to guarantee ALL errors are collected:
use premortem::{Config, Validate, ConfigValidation};
use stillwater::Validation;
#[derive(Debug, Deserialize)]
struct DatabaseConfig {
host: String,
port: u16,
pool_size: u32,
}
impl Validate for DatabaseConfig {
fn validate(&self) -> ConfigValidation<()> {
// ALL validations run, ALL errors collected
Validation::all((
validate_field(&self.host, "host", &[&NonEmpty]),
validate_field(&self.port, "port", &[&Range(1..=65535)]),
validate_field(&self.pool_size, "pool_size", &[&Range(1..=100)]),
))
.map(|_| ())
}
}
// Result: If host is empty, port is 0, and pool_size is 200,
// you get 3 errors in one response, not 3 separate runs
The Validation::all combinator runs every check regardless of earlier failures, collecting all errors into a single response.
Built-in Validation#
Premortem: Extensive Validator Library#
Premortem ships with validators for common scenarios:
use premortem::validate::validators::*;
// String validators
NonEmpty // value.is_empty() == false
MinLength(n) // value.len() >= n
MaxLength(n) // value.len() <= n
Pattern(regex) // regex.is_match(value)
Email // RFC 5322-like validation
Url // URL format validation
// Numeric validators
Range(range) // value in range
Positive // value > 0
NonZero // value != 0
// Collection validators
NonEmptyCollection // !collection.is_empty()
MinItems(n) // collection.len() >= n
Each(validator) // validate each item
// Path validators
FileExists // path.is_file()
DirExists // path.is_dir()
ParentExists // path.parent().is_dir()
Figment: Via Serde Deserializers#
Figment relies on custom serde deserializers for validation:
#[derive(Deserialize)]
struct Config {
#[serde(deserialize_with = "validate_port")]
port: u16,
}
fn validate_port<'de, D>(deserializer: D) -> Result<u16, D::Error>
where
D: Deserializer<'de>,
{
let port = u16::deserialize(deserializer)?;
if port == 0 {
return Err(serde::de::Error::custom("port cannot be 0"));
}
Ok(port)
}
This works, but requires more boilerplate and doesn’t accumulate errors across fields.
Cross-Field Validation#
Real-world configuration often has fields that depend on each other.
Premortem: First-Class Support#
impl Validate for DateRange {
fn validate(&self) -> ConfigValidation<()> {
let field_validation = Validation::all((
validate_field(&self.start_date, "start_date", &[&NonEmpty]),
validate_field(&self.end_date, "end_date", &[&NonEmpty]),
));
let cross_field = if self.start_date > self.end_date {
Validation::Failure(ConfigErrors::single(ConfigError::CrossFieldError {
fields: vec!["start_date".into(), "end_date".into()],
message: "start_date must be before end_date".into(),
source_location: None,
}))
} else {
Validation::Success(())
};
field_validation.and(cross_field).map(|_| ())
}
}
Figment: Post-Extraction Validation#
With Figment, cross-field validation happens after extraction:
let config: Config = figment.extract()?;
// Manual validation after the fact
if config.start_date > config.end_date {
return Err(anyhow!("start_date must be before end_date"));
}
This means cross-field errors are discovered separately from field-level errors.
Profile Support#
Figment: First-Class Profiles#
Figment excels at profile-based configuration:
use figment::{Figment, Profile};
use figment::providers::{Toml, Env, Serialized};
let figment = Figment::new()
.merge(Serialized::defaults(Config::default()))
.merge(Toml::file("config.toml").nested()) // Top-level keys are profiles
.merge(Env::prefixed("APP_"))
.select(Profile::from_env_or("APP_PROFILE", "development"));
# config.toml
[default]
port = 8080
[development]
debug = true
[production]
debug = false
Built-in profiles like Profile::Default and Profile::Global provide powerful inheritance patterns.
Premortem: Manual Profile Handling#
Premortem doesn’t have built-in profiles, but they’re easy to implement:
let profile = std::env::var("APP_PROFILE").unwrap_or("development".into());
let config = Config::<AppConfig>::builder()
.source(Defaults::from(AppConfig::default()))
.source(Toml::file("config.toml"))
.source(Toml::file(format!("config.{}.toml", profile)))
.source(Env::prefix("APP_"))
.build()?;
Merge Strategies#
Figment: Four Strategies#
Figment offers fine-grained control over how sources combine:
| Strategy | Behavior | Arrays |
|---|---|---|
merge | Later values win | Replace |
join | Earlier values win | Replace |
admerge | Later values win | Concatenate |
adjoin | Earlier values win | Concatenate |
let figment = Figment::new()
.join(Serialized::defaults(Config::default())) // Lower priority
.merge(Toml::file("config.toml")) // Higher priority
.admerge(Env::prefixed("APP_")); // Env + array concat
Premortem: Simple Layered Override#
Premortem uses straightforward layered overriding - later sources win:
let config = Config::<AppConfig>::builder()
.source(Defaults::from(AppConfig::default())) // Base
.source(Toml::file("config.toml")) // Override defaults
.source(Env::prefix("APP_")) // Override file
.build()?;
Less flexible, but also less cognitive overhead.
Testing#
Both libraries prioritize testability, but take different approaches.
Premortem: Virtual Filesystem#
Premortem uses dependency injection with a MockEnv:
use premortem::{Config, MockEnv};
#[test]
fn test_config_loading() {
let env = MockEnv::new()
.with_file("config.toml", r#"
host = "localhost"
port = 8080
"#)
.with_env("APP_HOST", "testhost");
let config = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.source(Env::prefix("APP_"))
.build_with_env(&env)
.unwrap();
assert_eq!(config.host, "testhost");
}
#[test]
fn test_file_not_found() {
let env = MockEnv::new()
.with_missing_file("config.toml");
let result = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.build_with_env(&env);
assert!(matches!(
result.unwrap_err().first(),
ConfigError::SourceError { kind: SourceErrorKind::NotFound { .. }, .. }
));
}
No actual files created. Fast tests. Easy to simulate edge cases like permission errors.
Figment: Real Temp Directories#
Figment’s Jail creates actual temporary files:
use figment::{Figment, Jail};
#[test]
fn test_config_loading() {
Jail::expect_with(|jail| {
jail.create_file("config.toml", r#"
host = "localhost"
port = 8080
"#)?;
jail.set_env("APP_HOST", "testhost");
let config: Config = Figment::new()
.merge(Toml::file("config.toml"))
.merge(Env::prefixed("APP_"))
.extract()?;
assert_eq!(config.host, "testhost");
Ok(())
});
}
Real filesystem operations, automatically cleaned up.
Advanced Features#
Premortem: Value Tracing#
Debug where each value came from:
let traced = Config::<AppConfig>::builder()
.source(Defaults::from(defaults))
.source(Toml::file("config.toml"))
.source(Env::prefix("APP_"))
.build_traced()?;
// Check where a value came from
if let Some(trace) = traced.trace("database.host") {
println!("Final value: {:?}", trace.final_value);
println!("Source: {}", trace.final_value.source);
// See override history
for entry in &trace.history {
println!(" {} -> {:?}", entry.source, entry.value);
}
}
// Generate debug report
println!("{}", traced.trace_report());
Premortem: Hot Reload#
#[cfg(feature = "watch")]
let (config, watcher) = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.source(Env::prefix("APP_"))
.build_watched()?;
watcher.on_change(|event| {
match event {
ConfigEvent::Reloaded { old, new } => {
println!("Config reloaded!");
}
ConfigEvent::ReloadFailed { errors } => {
eprintln!("Reload failed: {:?}", errors);
}
}
});
Figment: Magic Values#
Context-aware types that know where they came from:
use figment::value::magic::RelativePathBuf;
#[derive(Deserialize)]
struct Config {
// Resolved relative to the config file's directory
cert_path: RelativePathBuf,
}
// If config.toml is at /etc/app/config.toml and contains:
// cert_path = "certs/server.pem"
//
// The resolved path will be /etc/app/certs/server.pem
Feature Comparison Summary#
| Feature | Premortem | Figment |
|---|---|---|
| Error accumulation (all errors) | Yes | Partial |
| Built-in validators | Extensive | Via serde |
| Custom validation trait | Validate | Deserializers |
| Cross-field validation | Yes | Manual |
| Profiles | Manual | First-class |
| Merge strategies | Simple override | 4 strategies |
| Array concatenation | No | ad- strategies |
| Value tracing | build_traced() | Metadata only |
| Hot reload | build_watched() | No |
| Dependency injection | ConfigEnv | No |
| Mock testing | MockEnv | Jail |
| Magic values | No | RelativePathBuf |
| TOML/JSON/YAML | Yes | Yes |
When to Choose Each#
Choose Premortem If:#
- Complete error reporting is critical - You need ALL configuration errors upfront, not one at a time
- Rich validation rules - You need built-in validators for strings, numbers, paths, collections
- Cross-field validation - You need to validate relationships between fields
- Functional programming style - You prefer stillwater’s
Validationtype - Testable I/O - You want virtual filesystem for fast unit tests
- Hot reload - You need runtime configuration updates with change notifications
- Debug tracing - You need to know exactly where each value came from and its override history
Choose Figment If:#
- Profile support - You need built-in dev/staging/production profiles
- Flexible merging - You need fine-grained control over how sources combine
- Array concatenation - You need to merge arrays from multiple sources
- Rocket integration - You’re already using the Rocket web framework
- Magic values - You need context-aware types like
RelativePathBuf - Composable providers - You want to build complex provider hierarchies
- Established ecosystem - You prefer a more mature, widely-used library
Basic Usage Comparison#
Premortem#
use premortem::{Config, Validate};
use premortem::sources::{Toml, Env, Defaults};
#[derive(Debug, Deserialize, Validate)]
struct AppConfig {
#[validate(non_empty)]
host: String,
#[validate(range(1..=65535))]
port: u16,
}
fn main() -> Result<(), ConfigErrors> {
let config = Config::<AppConfig>::builder()
.source(Defaults::from(AppConfig::default()))
.source(Toml::file("config.toml"))
.source(Env::prefix("APP_"))
.build()?;
println!("Server: {}:{}", config.host, config.port);
Ok(())
}
Figment#
use figment::{Figment, providers::{Format, Toml, Env, Serialized}};
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
struct AppConfig {
host: String,
port: u16,
}
fn main() -> Result<(), figment::Error> {
let config: AppConfig = Figment::new()
.merge(Serialized::defaults(AppConfig::default()))
.merge(Toml::file("config.toml"))
.merge(Env::prefixed("APP_"))
.extract()?;
println!("Server: {}:{}", config.host, config.port);
Ok(())
}
The Bottom Line#
Both libraries are well-designed and production-ready. The choice comes down to your priorities:
If configuration errors in production have caused you pain - multiple deployments to fix multiple problems, unclear error messages, difficulty debugging where values came from - premortem is designed specifically to solve those problems.
If you need sophisticated profile handling, flexible merge strategies, or are already in the Rocket ecosystem - figment is mature, well-documented, and battle-tested.
You can even use both: figment for its profile and merge capabilities, with premortem’s validation layer on top.
Try Premortem#
cargo add premortem
[dependencies]
premortem = { version = "0.3", features = ["toml", "json"] }
Run the error demo to see error accumulation in action:
git clone https://github.com/iepathos/premortem
cd premortem
cargo run --example error-demo
Documentation: docs.rs/premortem Repository: github.com/iepathos/premortem
Premortem is open source under the MIT license. Know how your app will die before it does.