# tier
`tier` is a Rust configuration library for typed, layered application config.
It is designed for projects that want one `serde` config type fed by code
defaults, TOML files, environment variables, and CLI overrides, without
falling back to untyped value trees.
By default, `tier` only enables TOML file support. `derive`, `clap`, `schema`,
`watch`, `json`, and `yaml` are opt-in features.
Use `tier` when you want:
1. a Rust config library built around `serde` types
2. predictable layered config from defaults, files, env, and CLI
3. source tracing and validation instead of silent config drift
4. optional schema, docs, and reload support without a heavy default feature set
## Feature Flags
- `toml`: TOML file parsing and commented TOML examples
- `derive`: `#[derive(TierConfig)]` metadata generation
- `clap`: reusable config flags and diagnostics commands
- `schema`: JSON Schema, env docs, and machine-readable reports
- `watch`: native filesystem watcher backend
- `json`: JSON file parsing
- `yaml`: YAML file parsing
## Feature Map
- Loading: `ConfigLoader`, `FileSource`, `EnvSource`, `ArgsSource`
- Metadata: `ConfigMetadata`, `FieldMetadata`, `TierConfig`
- Diagnostics: `ConfigReport`, `doctor()`, `explain()`, `audit_report()`
- Schema and docs: `json_schema_*`, `annotated_json_schema_*`, `config_example_*`, `EnvDocOptions`
- Reload: `ReloadHandle`, `PollingWatcher`, `NativeWatcher`
## Input Semantics
- Env values and `--set key=value` overrides are string-first inputs
- Primitive targets such as `bool`, integers, floats, and `Option<T>` are coerced during deserialization
- Use explicit JSON syntax for arrays, objects, or quoted strings when you need structured inline values
## Checked Paths
For refactor-heavy code, `tier` exposes checked path macros that still feed the
same runtime APIs:
```rust
use serde::{Deserialize, Serialize};
use tier::{ConfigMetadata, FieldMetadata};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AppConfig {
proxy: ProxyConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProxyConfig {
no_proxy: Vec<String>,
}
let metadata = ConfigMetadata::from_fields([
FieldMetadata::new(tier::path!(AppConfig.proxy.no_proxy)),
]);
assert!(metadata.field("proxy.no_proxy").is_some());
```
## Typed Patches
If your application already has typed override structs, derive `TierPatch` and
apply them directly instead of maintaining a serializable shadow layer.
```rust,no_run
# #[cfg(feature = "derive")] {
# fn main() -> Result<(), tier::ConfigError> {
use serde::{Deserialize, Serialize};
use tier::{ConfigLoader, Patch, TierPatch};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AppConfig {
port: u16,
token: Option<String>,
}
#[derive(Debug, Default, TierPatch)]
struct CliPatch {
port: Option<u16>,
#[tier(path_expr = tier::path!(AppConfig.token))]
token: Patch<Option<String>>,
}
let loaded = ConfigLoader::new(AppConfig {
port: 3000,
token: Some("default".to_owned()),
})
.patch(
"typed-cli",
&CliPatch {
port: Some(8080),
token: Patch::set(None),
},
)?
.load()?;
assert_eq!(loaded.port, 8080);
assert_eq!(loaded.token, None);
# Ok(())
# }
# }
```
With both `derive` and `clap`, the same typed CLI struct can be applied as the
last layer through `ConfigLoader::clap_overrides(&parsed_cli)`, and
`path_expr = tier::path!(...)` keeps CLI-to-config mappings checked during
refactors.
## Structured Env Values
`tier` can decode common operational env formats without forcing applications
to pre-normalize them into JSON.
```rust,no_run
# fn main() -> Result<(), tier::ConfigError> {
use serde::{Deserialize, Serialize};
use tier::{ConfigLoader, EnvDecoder, EnvSource};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct AppConfig {
no_proxy: Vec<String>,
}
let loaded = ConfigLoader::new(AppConfig { no_proxy: Vec::new() })
.env_decoder("no_proxy", EnvDecoder::Csv)
.env(EnvSource::from_pairs([(
"APP__NO_PROXY",
"localhost,127.0.0.1,.internal.example.com",
)]).prefix("APP"))
.load()?;
assert_eq!(
loaded.no_proxy,
vec![
"localhost".to_owned(),
"127.0.0.1".to_owned(),
".internal.example.com".to_owned(),
]
);
# Ok(())
# }
```
You can also bridge standard operational env names directly:
```rust,no_run
# fn main() -> Result<(), tier::ConfigError> {
use serde::{Deserialize, Serialize};
use tier::{ConfigLoader, EnvDecoder, EnvSource};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct AppConfig {
proxy: ProxyConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct ProxyConfig {
url: Option<String>,
no_proxy: Vec<String>,
}
let loaded = ConfigLoader::new(AppConfig::default())
.env(
EnvSource::from_pairs([
("HTTP_PROXY", "http://proxy.internal:8080"),
("NO_PROXY", "localhost,.svc.internal"),
])
.with_fallback("HTTP_PROXY", "proxy.url")
.with_fallback_decoder("NO_PROXY", "proxy.no_proxy", EnvDecoder::Csv),
)
.load()?;
assert_eq!(loaded.proxy.url.as_deref(), Some("http://proxy.internal:8080"));
assert_eq!(loaded.proxy.no_proxy, vec!["localhost", ".svc.internal"]);
# Ok(())
# }
```
For formats that are not covered by the built-in `EnvDecoder` variants, use
`ConfigLoader::env_decoder_with(...)` to keep application-specific parsing in
the config layer instead of pre-normalizing env vars outside `tier`.
## Leaf Enums
Leaf enum fields can opt out of nested metadata requirements with
`#[tier(leaf)]` on the containing field:
```rust
# #[cfg(feature = "derive")] {
use serde::{Deserialize, Serialize};
use tier::TierConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
enum Backend {
Memory,
Redis,
}
#[derive(Debug, Clone, Serialize, Deserialize, TierConfig)]
struct AppConfig {
#[tier(env = "APP_BACKEND", leaf)]
backend: Backend,
}
# }
```
## Quick Start
The smallest useful setup is defaults plus a TOML file:
```rust,no_run
use serde::{Deserialize, Serialize};
use tier::ConfigLoader;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AppConfig {
port: u16,
}
impl Default for AppConfig {
fn default() -> Self {
Self { port: 3000 }
}
}
let loaded = ConfigLoader::new(AppConfig::default())
.file("config/app.toml")
.load()?;
assert!(loaded.report().doctor().contains("Sources:"));
# Ok::<(), tier::ConfigError>(())
```
## Examples
The crate ships with focused examples under [`examples/`](./examples):
- `basic.rs`: defaults + env + CLI layering
- `manual-metadata.rs`: explicit `ConfigMetadata`
- `derive.rs`: derive metadata and declarative validation
- `schema.rs`: schema, env docs, and commented TOML examples
- `clap.rs`: embedding `TierCli`
- `reload.rs`: polling reload
- `application.rs`: a fuller application setup
## Core Types
- `ConfigLoader<T>` builds a deterministic pipeline from defaults, files, env,
CLI, and custom layers.
- `ConfigMetadata` carries env names, aliases, secrets, examples, merge rules,
and declared validations.
- `LoadedConfig<T>` returns the final typed value with a `ConfigReport`.
- `ReloadHandle<T>` reuses the same loader closure for polling or native file
watching.
## Highlights
- Typed loading with deterministic merge order and unknown field governance
- Metadata-driven env mapping, secret handling, and validation
- Field-level tracing, doctor output, and machine-readable audit/report data
- Optional schema/docs export, commented TOML examples, `clap`, and reload
## Example
```rust,no_run
# #[cfg(feature = "derive")] {
use serde::{Deserialize, Serialize};
use tier::{ArgsSource, ConfigLoader, EnvSource, Secret, TierConfig, ValidationErrors};
#[derive(Debug, Clone, Serialize, Deserialize, TierConfig)]
struct AppConfig {
server: ServerConfig,
db: DbConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, TierConfig)]
struct ServerConfig {
#[tier(
env = "APP_SERVER_HOST",
doc = "IP address or hostname to bind",
example = "0.0.0.0"
)]
host: String,
#[tier(deprecated = "use server.bind_port instead")]
port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, TierConfig)]
struct DbConfig {
password: Secret<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 3000,
},
db: DbConfig {
password: Secret::new("secret".into()),
},
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let loaded = ConfigLoader::new(AppConfig::default())
.derive_metadata()
.file("config/default.toml")
.optional_file("config/{profile}.toml")
.env(EnvSource::prefixed("APP"))
.args(ArgsSource::from_env())
.profile("prod")
.validator("port-range", |config| {
if config.server.port == 0 {
return Err(ValidationErrors::from_message(
"server.port",
"port must be greater than zero",
));
}
Ok(())
})
.load()?;
println!("{}", loaded.report().doctor());
Ok(())
}
# }
```
`derive_metadata()` applies metadata generated by `TierConfig`, including env
names, aliases, secret handling, `serde(default)` awareness, merge strategies,
declared validation rules, env docs, and deprecation warnings.
## Declarative Validation
`tier` supports metadata-driven field and cross-field validation alongside
custom validator hooks. Declared rules feed the loader, schema annotations,
env docs, and commented TOML examples from the same metadata source.
```rust
# #[cfg(feature = "derive")] {
use serde::{Deserialize, Serialize};
use tier::{ConfigError, ConfigLoader, TierConfig};
#[derive(Debug, Clone, Serialize, Deserialize, TierConfig)]
struct AppConfig {
#[tier(non_empty, min_length = 3)]
service_name: String,
#[tier(min = 1, max = 65535)]
port: u16,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
service_name: "api".to_owned(),
port: 8080,
}
}
}
let error = ConfigLoader::new(AppConfig::default())
.derive_metadata()
.args(tier::ArgsSource::from_args([
"app",
"--set",
r#"service_name="""#,
"--set",
"port=0",
]))
.load()
.expect_err("declared validation must fail");
assert!(matches!(error, ConfigError::DeclaredValidation { .. }));
# }
```
## Reload
`tier` always includes `ReloadHandle` and a polling watcher. With `watch`
enabled, it also exposes a native filesystem watcher. Reloads can emit
structured diffs and events, and watchers can either keep running or stop
after a failed reload.
```rust,no_run
# #[cfg(all(feature = "toml", feature = "watch"))] {
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tier::{ConfigError, ConfigLoader, ReloadEvent, ReloadHandle};
#[derive(Clone, Serialize, Deserialize)]
struct AppConfig {
port: u16,
}
impl Default for AppConfig {
fn default() -> Self {
Self { port: 3000 }
}
}
fn main() -> Result<(), ConfigError> {
let handle =
ReloadHandle::new(|| ConfigLoader::new(AppConfig::default()).file("app.toml").load())?;
let _events = handle.subscribe();
let _summary = handle.reload_detailed()?;
let watcher = handle.start_native(["app.toml"], Duration::from_millis(100))?;
watcher.stop();
Ok(())
}
# }
```
## Schema Export
With `schema` enabled, `tier` can export a JSON Schema for a config type:
```rust
# #[cfg(feature = "schema")] {
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tier::json_schema_pretty;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct AppConfig {
port: u16,
}
let schema = json_schema_pretty::<AppConfig>();
assert!(schema.contains("\"type\": \"object\""));
# }
```
## Clap Integration
With `clap` enabled, `tier` provides a reusable config flag group:
```rust
# #[cfg(feature = "clap")] {
use clap::Parser;
use tier::TierCli;
#[derive(Debug, Parser)]
struct AppCli {
#[command(flatten)]
config: TierCli,
}
let cli = AppCli::parse_from(["app", "--validate-config"]);
assert!(matches!(cli.config.command(), tier::TierCliCommand::ValidateConfig));
# #[cfg(feature = "schema")] {
let example = AppCli::parse_from(["app", "--print-config-example"]);
assert!(matches!(
example.config.command(),
tier::TierCliCommand::PrintConfigExample
));
# }
# }
```
## Environment Variable Docs
With `schema` enabled, `tier` can generate environment variable docs and
annotated JSON Schema. When `toml` is also enabled, it can render a commented
TOML example configuration:
```rust
# #[cfg(all(feature = "schema", feature = "derive", feature = "toml"))] {
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tier::{
EnvDocOptions, TierConfig, annotated_json_schema_pretty, config_example_toml, env_docs_json,
env_docs_markdown,
};
#[derive(Debug, Serialize, Deserialize, JsonSchema, TierConfig)]
struct AppConfig {
server: ServerConfig,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, TierConfig)]
struct ServerConfig {
#[tier(
env = "APP_SERVER_PORT",
doc = "Port used for incoming traffic",
example = "8080"
)]
port: u16,
}
let docs = env_docs_markdown::<AppConfig>(&EnvDocOptions::prefixed("APP"));
assert!(docs.contains("APP_SERVER_PORT"));
let docs_json = env_docs_json::<AppConfig>(&EnvDocOptions::prefixed("APP"));
assert!(docs_json.is_array());
let schema = annotated_json_schema_pretty::<AppConfig>();
assert!(schema.contains("\"x-tier-env\""));
let example = config_example_toml::<AppConfig>();
assert!(example.contains("[server]"));
# }
```
## Secrets
`tier::Secret<T>` is a strong typed wrapper for sensitive values. It redacts
`Debug` and `Display` output, and with the `schema` feature it marks fields as
`writeOnly` so the loader can auto-discover secret paths.
```rust
use serde::{Deserialize, Serialize};
use tier::Secret;
#[derive(Debug, Serialize, Deserialize)]
struct DbConfig {
password: Secret<String>,
}
let password = Secret::new("super-secret".to_owned());
assert_eq!(format!("{password}"), "***redacted***");
```