# Premortem Project Documentation
This document contains Premortem-specific documentation for Claude. General development guidelines are in `~/.claude/CLAUDE.md`.
## Overview
Premortem is a configuration library that performs a premortem on your app's config—finding all the ways it could die from bad config before it ever runs. It uses stillwater's functional patterns for error accumulation and composable validation.
## Error Handling
### Core Rules
- **Production code**: Never use `unwrap()` or `panic!()` - use Result types and `?` operator
- **Test code**: May use `unwrap()` and `panic!()` for test failures
- **Static patterns**: Compile-time constants (like regex) may use `expect()`
### Error Types
- Configuration: `ConfigError`, `ConfigErrors` (NonEmptyVec-based)
- Validation: `ConfigValidation<T>` (alias for `Validation<T, ConfigErrors>`)
- Source errors: `SourceErrorKind`
### Error Accumulation
Premortem uses stillwater's `Validation` type to collect ALL configuration errors, not just the first:
```rust
use premortem::{ConfigError, ConfigErrors, ConfigValidation};
use stillwater::Validation;
fn validate_config(config: &AppConfig) -> ConfigValidation<()> {
let mut errors = Vec::new();
if config.port == 0 {
errors.push(ConfigError::ValidationError {
path: "port".to_string(),
source_location: None,
value: Some("0".to_string()),
message: "port must be non-zero".to_string(),
});
}
if config.host.is_empty() {
errors.push(ConfigError::ValidationError {
path: "host".to_string(),
source_location: None,
value: Some("".to_string()),
message: "host cannot be empty".to_string(),
});
}
match ConfigErrors::from_vec(errors) {
Some(errs) => Validation::Failure(errs),
None => Validation::Success(()),
}
}
```
**Benefits**: Users see ALL config problems at once, not one at a time.
## Architecture
### Pure Core, Imperative Shell
Premortem follows stillwater's architectural pattern:
- **Pure Core**: Value merging, deserialization, validation are pure functions
- **Imperative Shell**: I/O operations use the `ConfigEnv` trait for dependency injection
```
src/
├── config.rs # Config and ConfigBuilder (builder pattern)
├── error.rs # ConfigError, ConfigErrors, ConfigValidation
├── value.rs # Value enum for intermediate representation
├── source.rs # Source trait and ConfigValues container
├── env.rs # ConfigEnv trait and MockEnv for testing
└── validate.rs # Validate trait for custom validation
```
### ConfigEnv Trait (Testable I/O)
All I/O is abstracted through the `ConfigEnv` trait:
```rust
use premortem::{Config, ConfigEnv, MockEnv, RealEnv};
// Production: uses real file system and environment
let config = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.build(); // Uses RealEnv internally
// Testing: uses mock file system and environment
let env = MockEnv::new()
.with_file("config.toml", r#"host = "localhost"\nport = 8080"#)
.with_env("APP_DEBUG", "true");
let config = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.build_with_env(&env);
```
## Config Builder Pattern
### Basic Usage
```rust
use premortem::{Config, Validate, ConfigValidation};
use serde::Deserialize;
use stillwater::Validation;
#[derive(Debug, Deserialize)]
struct AppConfig {
host: String,
port: u16,
}
impl Validate for AppConfig {
fn validate(&self) -> ConfigValidation<()> {
if self.port > 0 {
Validation::Success(())
} else {
Validation::fail_with(ConfigError::ValidationError {
path: "port".to_string(),
source_location: None,
value: Some(self.port.to_string()),
message: "port must be positive".to_string(),
})
}
}
}
let config = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.source(Env::new().prefix("APP"))
.build()?;
```
### Source Layering
Sources are applied in order, with later sources overriding earlier ones:
```rust
// 1. Base defaults
// 2. File config overrides defaults
// 3. Environment variables override file config
let config = Config::<AppConfig>::builder()
.source(Defaults::new())
.source(Toml::file("config.toml"))
.source(Env::new().prefix("APP"))
.build()?;
```
## Error Types Reference
### ConfigError Variants
| `SourceError` | A configuration source failed to load |
| `ParseError` | A field failed to parse to expected type |
| `MissingField` | A required field is missing |
| `ValidationError` | A validation rule failed |
| `CrossFieldError` | Cross-field validation failed |
| `UnknownField` | An unknown field was found |
| `NoSources` | No sources were provided to the builder |
### SourceErrorKind Variants
| `NotFound` | Source file was not found |
| `IoError` | Source file could not be read |
| `ParseError` | Source content could not be parsed |
| `ConnectionError` | Remote source failed to connect |
| `Other` | Other source-specific error |
## Feature Flags
```toml
[features]
default = ["toml", "derive"]
toml = [] # TOML file support
json = [] # JSON file support
yaml = [] # YAML file support (not yet implemented, see specs/001)
watch = [] # File watching for hot reload
remote = [] # Remote configuration sources (not yet implemented, see specs/002)
derive = [] # Derive macro for Validate trait
full = ["toml", "json", "yaml", "watch", "remote", "derive"]
```
## Testing Patterns
### MockEnv for Unit Tests
```rust
#[test]
fn test_config_loading() {
let env = MockEnv::new()
.with_file("config.toml", r#"
[database]
host = "localhost"
port = 5432
"#)
.with_env("APP_DATABASE_HOST", "prod-db.example.com");
let config = Config::<DbConfig>::builder()
.source(Toml::file("config.toml"))
.source(Env::new().prefix("APP"))
.build_with_env(&env);
assert!(config.is_ok());
let config = config.unwrap();
// Environment override wins
assert_eq!(config.database.host, "prod-db.example.com");
}
```
### Testing Validation
```rust
#[test]
fn test_validation_accumulates_errors() {
let env = MockEnv::new()
.with_file("config.toml", r#"
host = ""
port = 0
"#);
let result = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.build_with_env(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
// Both validation errors should be present
assert_eq!(errors.len(), 2);
}
```
### Testing Error Scenarios
```rust
#[test]
fn test_permission_denied() {
let env = MockEnv::new()
.with_unreadable_file("secrets.toml");
let result = Config::<AppConfig>::builder()
.source(Toml::file("secrets.toml"))
.build_with_env(&env);
assert!(result.is_err());
// Check for SourceError with IoError kind
}
#[test]
fn test_missing_file() {
let env = MockEnv::new()
.with_missing_file("config.toml");
let result = Config::<AppConfig>::builder()
.source(Toml::file("config.toml"))
.build_with_env(&env);
assert!(result.is_err());
// Check for SourceError with NotFound kind
}
```
## Stillwater Integration
Premortem uses these stillwater types:
| `Validation<T, E>` | Error accumulation for config errors |
| `NonEmptyVec<T>` | Guaranteed non-empty error lists |
| `Semigroup` | Combining errors from multiple sources |
### Re-exports
For convenience, premortem re-exports commonly used stillwater types:
```rust
pub use stillwater::{NonEmptyVec, Semigroup, Validation};
```
## Validation with Predicates
Premortem integrates with stillwater 0.13's composable predicates for elegant, reusable validation logic.
### Basic Usage
Use `validate_with_predicate()` for simple predicate-based validation with custom error messages:
```rust
use premortem::prelude::*;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ServerConfig {
host: String,
port: u16,
}
impl Validate for ServerConfig {
fn validate(&self) -> ConfigValidation<()> {
let validations = vec![
validate_with_predicate(
&self.host,
"host",
not_empty(),
"host cannot be empty"
),
validate_with_predicate(
&self.port,
"port",
between(1, 65535),
"port must be between 1 and 65535"
),
];
Validation::all_vec(validations).map(|_| ())
}
}
```
### Composition Patterns
Predicates compose elegantly using `.and()`, `.or()`, and `.not()`:
```rust
use premortem::prelude::*;
// Combine multiple predicates
let username_valid = not_empty()
.and(len_between(3, 20))
.and(starts_with(|c: char| c.is_alphabetic()));
validate_with_predicate(
&username,
"username",
username_valid,
"username must be 3-20 chars, non-empty, starting with letter"
)
```
### Available Predicates
#### String Predicates
| `not_empty()` | String must not be empty |
| `len_min(n)` | Minimum length |
| `len_max(n)` | Maximum length |
| `len_eq(n)` | Exact length |
| `len_between(min, max)` | Length in range (inclusive) |
| `starts_with(s)` | Starts with string/char/predicate |
| `ends_with(s)` | Ends with string/char/predicate |
| `contains(s)` | Contains substring |
| `is_alphabetic()` | All characters alphabetic |
| `is_alphanumeric()` | All characters alphanumeric |
| `is_numeric()` | All characters numeric |
#### Numeric Predicates
| `gt(n)` | Greater than |
| `ge(n)` | Greater than or equal |
| `lt(n)` | Less than |
| `le(n)` | Less than or equal |
| `eq(n)` | Equal to |
| `ne(n)` | Not equal to |
| `between(min, max)` | In range (inclusive) |
| `positive()` | Greater than zero |
| `negative()` | Less than zero |
| `non_negative()` | Greater than or equal to zero |
#### Collection Predicates
| `has_len(n)` | Exact collection length |
| `has_min_len(n)` | Minimum collection length |
| `has_max_len(n)` | Maximum collection length |
| `is_empty()` | Collection is empty |
| `is_not_empty()` | Collection is not empty |
| `all(predicate)` | All elements satisfy predicate |
| `any(predicate)` | Any element satisfies predicate |
| `contains_element(value)` | Collection contains value |
### Integration with validate_field
Convert predicates to validators using `from_predicate()`:
```rust
use premortem::prelude::*;
use premortem::validate::from_predicate;
impl Validate for AppConfig {
fn validate(&self) -> ConfigValidation<()> {
// Mix predicates with traditional validators
let username_pred = from_predicate(
not_empty().and(len_between(3, 20))
);
validate_field(&self.username, "username", &[&username_pred])
}
}
```
### Advanced: Complex Composition
Build sophisticated validation rules by composing predicates:
```rust
use premortem::prelude::*;
// Password validation: 8-64 chars, contains digit and letter
let password_pred = len_between(8, 64)
.and(any(|c: char| c.is_numeric()))
.and(any(|c: char| c.is_alphabetic()));
validate_with_predicate(
&password,
"password",
password_pred,
"password must be 8-64 chars with letters and numbers"
)
```
### Benefits of Predicates
- **Composable**: Use `.and()`, `.or()`, `.not()` to build complex rules
- **Reusable**: Define once, use across multiple fields
- **Type-safe**: Compile-time guarantees
- **Zero-cost**: No runtime overhead
- **Tested**: Stillwater predicates are thoroughly tested with property-based tests
## Environment Variable Validation
Premortem provides ergonomic patterns for marking environment variables as required at the source level, with error accumulation for all missing variables.
### Required Environment Variables
Use `.require()` or `.require_all()` on the `Env` source to declare required environment variables:
```rust
use premortem::prelude::*;
// Mark individual variables as required
let config = Config::<AppConfig>::builder()
.source(
Env::prefix("APP_")
.require("JWT_SECRET")
.require("DATABASE_URL")
)
.build()?;
// Or mark multiple variables at once
let config = Config::<AppConfig>::builder()
.source(
Env::prefix("APP_")
.require_all(&["JWT_SECRET", "DATABASE_URL", "API_KEY"])
)
.build()?;
```
**Key features**:
- Variable names specified WITHOUT prefix (prefix is added automatically)
- Missing variables cause `load()` to fail with accumulated errors
- ALL missing variables reported at once (not fail-fast)
- Error messages include full environment variable name with prefix
### Source-Level vs Value-Level Validation
Premortem separates two concerns:
1. **Source-level validation** (presence checking): Done during `Source::load()`
- Use `.require()` on the `Env` source
- Checks if environment variables exist
- Fails before deserialization if missing
2. **Value-level validation** (constraints): Done after deserialization
- Use `#[validate(...)]` attributes or `impl Validate`
- Validates the parsed values meet constraints
- Runs only if all required variables are present
```rust
#[derive(Debug, Deserialize, DeriveValidate)]
struct AppConfig {
// Source-level: APP_JWT_SECRET must exist
// Value-level: must be at least 32 characters
#[validate(min_length(32))]
jwt_secret: String,
// Source-level: APP_DATABASE_URL must exist
// No value-level validation
database_url: String,
// Source-level: APP_PORT must exist
// Value-level: must be in valid port range
#[validate(range(1..=65535))]
port: u16,
}
let config = Config::<AppConfig>::builder()
.source(
Env::prefix("APP_")
.require_all(&["JWT_SECRET", "DATABASE_URL", "PORT"])
)
.build()?;
```
### Error Messages
When required environment variables are missing, you get clear error messages:
```
Configuration error:
[env:APP_JWT_SECRET] Missing required field: jwt.secret
[env:APP_DATABASE_URL] Missing required field: database.url
[env:APP_API_KEY] Missing required field: api.key
```
All missing variables are reported together, making it easy to fix multiple issues at once.
### Migration from Manual Validation
**Before** (90+ lines of imperative code):
```rust
impl Config {
pub fn load<E: ConfigEnv>(env: &E) -> Result<Self, ConfigError> {
let database_url = env
.get_env("APP_DATABASE_URL")
.ok_or_else(|| ConfigError("DATABASE_URL is required".to_string()))?;
let jwt_secret = env
.get_env("APP_JWT_SECRET")
.ok_or_else(|| ConfigError("JWT_SECRET is required".to_string()))?;
if jwt_secret.len() < 32 {
return Err(ConfigError("JWT_SECRET must be at least 32 characters".to_string()));
}
let github_client_id = env
.get_env("APP_GITHUB_CLIENT_ID")
.ok_or_else(|| ConfigError("GITHUB_CLIENT_ID is required".to_string()))?;
// ... 70+ more lines of repetitive code
}
}
```
**After** (~15 lines of declarative code):
```rust
#[derive(Debug, Deserialize, DeriveValidate)]
struct Config {
database_url: String,
#[validate(min_length(32))]
jwt_secret: String,
github_client_id: String,
}
let config = Config::<Config>::builder()
.source(
Env::prefix("APP_")
.require_all(&["DATABASE_URL", "JWT_SECRET", "GITHUB_CLIENT_ID"])
)
.build()?;
```
### Best Practices
1. **Use `.require_all()` for multiple variables**: More concise than chaining `.require()` calls
2. **Separate presence from validation**:
- Source-level: Does the variable exist?
- Value-level: Does the value meet constraints?
3. **Test with MockEnv**:
```rust
#[test]
fn test_missing_required_vars() {
let env = MockEnv::new()
.with_env("APP_JWT_SECRET", "secret");
let result = Config::<AppConfig>::builder()
.source(Env::prefix("APP_").require_all(&["JWT_SECRET", "DATABASE_URL"]))
.build_with_env(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1); }
```
4. **Clear error accumulation**: All missing variables are reported at once, helping users fix all issues in one go
## Common Patterns
### Custom Validation
```rust
impl Validate for DatabaseConfig {
fn validate(&self) -> ConfigValidation<()> {
use stillwater::Validation;
let port_valid = if self.port > 0 && self.port < 65536 {
Validation::Success(())
} else {
Validation::Failure(ConfigErrors::single(
ConfigError::ValidationError { /* ... */ }
))
};
let host_valid = if !self.host.is_empty() {
Validation::Success(())
} else {
Validation::Failure(ConfigErrors::single(
ConfigError::ValidationError { /* ... */ }
))
};
// Combine validations - accumulates ALL errors
port_valid.combine_with(host_valid, |_, _| ())
}
}
```
### Source Location Tracking
```rust
// Track where config values came from
let loc = SourceLocation::new("config.toml")
.with_line(10)
.with_column(5);
// For environment variables
let loc = SourceLocation::env("APP_HOST");
// In error messages: "[config.toml:10:5] 'port': expected integer"
```
### Error Grouping
```rust
use premortem::group_by_source;
let errors: ConfigErrors = /* ... */;
let grouped = group_by_source(&errors);
for (source, errs) in grouped {
println!("Errors in {}:", source);
for err in errs {
println!(" {}", err);
}
}
```
## Development Commands
```bash
# Run tests
cargo test
# Run tests with all features
cargo test --all-features
# Check formatting
cargo fmt --check
# Run clippy
cargo clippy --all-features
# Build docs
cargo doc --all-features --no-deps
```
## Module Dependencies
```
stillwater (functional patterns)
↓
premortem
├── error.rs (uses Validation, NonEmptyVec, Semigroup)
├── config.rs (uses error, source, validate, env)
├── source.rs (uses value, error, env)
├── value.rs (standalone)
├── validate.rs (uses error)
└── env.rs (standalone)
```