# lockedenv
> Ergonomic, type-safe, freeze-on-load environment variable management for Rust 🦀.
**Read once, parse immediately, freeze forever.**
[](https://crates.io/crates/lockedenv) [](https://docs.rs/lockedenv) [](LICENSE-MIT) [](LICENSE-APACHE)
> **v0.3**: `env_struct!` (named structs), `check!`/`try_check!` (collect all errors), decimal `Duration` (`"1.5h"`), `with_hint` on all error variants.
Environment variables are often a source of subtle bugs: they are read multiple times across the codebase, treated as untyped `String`s, and can silently fail if mutated at runtime. Testing them natively with `std::env::set_var` is unsafe in parallel contexts.
`lockedenv` solves this cleanly: define a struct layout via a macro, enforce type-safe parsing at startup, and pass the generated, immutable struct to your application.
## Quickstart
Add `lockedenv` to your `Cargo.toml`:
```toml
[dependencies]
lockedenv = "0.3"
```
Use the `load!` macro to define and parse your configuration:
```rust
fn main() {
let config = lockedenv::load! {
PORT: u16,
DATABASE_URL: String,
DEBUG: bool = false,
TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30),
SENTRY_DSN: Option<String>,
};
// The generated 'config' struct implements `Clone` and `Debug`
println!("Listening on port {} in debug mode: {}", config.PORT, config.DEBUG);
}
```
If a required variable is missing or cannot be parsed, `load!` **panics with a clear message** describing the variable name, the value found, and a hint on how to fix it. Your application cannot boot into an invalid state.
## Core Features
- **Safe:** Eliminates repeated `std::env::var` calls. Validates everything on startup.
- **Type-safe:** Built-in parsers for standard library types (`u16`, `bool`, `IpAddr`, `std::time::Duration`, etc.) and seamlessly extensible via `FromEnvStr`.
- **Zero-Boilerplate Default & Optional values:** Naturally handles `fallback = defaults` and `Option<T>` for transparent absences.
- **Thread-safe testing:** The `from_map!` macro allows you to inject HashMaps into the parser, avoiding the deprecation and threading issues of `std::env::set_var`.
- **Hygienic:** Generates an isolated, anonymous struct ensuring no namespace pollution.
## The Macro Family
`lockedenv` provides variants for every need:
```rust
// 1. Panics on missing/bad config — recommended for services that must not boot broken
let config = lockedenv::load! { PORT: u16, DS_URL: String };
// 2. Returns Result<_, EnvLockError> to handle or propagate failures
let config = lockedenv::try_load! { PORT: u16 }?;
// 3. Named struct — the config type has a real name and can be used in signatures
lockedenv::env_struct! {
pub struct AppConfig {
PORT: u16,
HOST: String,
DEBUG: bool = false,
}
}
fn start(cfg: AppConfig) { /* ... */ }
fn main() { start(AppConfig::load()); }
// 4. Collect ALL errors before failing — show every missing/bad var at once
match lockedenv::try_check! { PORT: u16, HOST: String, DB: String } {
Ok(cfg) => { /* use cfg */ }
Err(errs) => {
for e in &errs { eprintln!("{e}"); }
std::process::exit(1);
}
}
// Or panic with the full list in one shot:
let config = lockedenv::check! { PORT: u16, HOST: String, DB: String };
```
### Named Structs with `env_struct!`
When your config struct needs to outlive a single expression — to be stored, returned from a function, or named as a type — use `env_struct!`. It generates a named struct with `load()`, `try_load()`, `from_map()`, and `try_from_map()` associated functions:
```rust
lockedenv::env_struct! {
pub struct ServiceConfig {
prefix = "SVC_", // reads SVC_HOST, SVC_PORT, …
HOST: String,
PORT: u16 = 8080,
TOKEN: lockedenv::Secret<String>,
LABEL: Option<String>,
}
}
// Can appear in function signatures, struct fields, type aliases:
fn load_config() -> ServiceConfig {
ServiceConfig::load()
}
// In tests, inject a HashMap instead of touching the real environment:
let m = std::collections::HashMap::from([
("SVC_HOST".into(), "localhost".into()),
("SVC_TOKEN".into(), "secret".into()),
]);
let cfg = ServiceConfig::from_map(&m);
assert_eq!(cfg.HOST, "localhost");
```
### Collect All Errors with `check!` / `try_check!`
`load!` stops at the first error. `check!` and `try_check!` try every field and report all problems:
```rust
// Panics with a list of ALL errors, not just the first:
let config = lockedenv::check! { HOST: String, PORT: u16, DB: String };
// Panic message: "3 configuration error(s):
// - EnvLockError: missing required variable
// variable: HOST …
// - EnvLockError: missing required variable
// variable: PORT …
// …"
// Or handle the error list yourself:
if let Err(errors) = lockedenv::try_check! { HOST: String, PORT: u16, DB: String } {
for e in &errors { eprintln!("{e}"); }
std::process::exit(1);
}
```
### Thread-Safe Testing
In tests, mutating the global environment is an anti-pattern. All macros accept `map:` for HashMap injection:
```rust
#[test]
fn test_config_parsing() {
let map = std::collections::HashMap::from([
("PORT".into(), "8080".into())
]);
let config = lockedenv::from_map! { map: map, PORT: u16 };
assert_eq!(config.PORT, 8080);
// Same works for check! and env_struct!:
let result = lockedenv::try_check! { map: map, PORT: u16 };
assert!(result.is_ok());
}
```
## Supported Types (Zero extra dependencies)
| `String`, `char` | `"value"`, `'a'` | |
| Integer primitives | `8080`, `-20` | Native bounds checked |
| Floating point | `"3.14"` | |
| `bool` | `"true"`, `"1"`, `"yes"`, `"false"` | Case-insensitive |
| `std::path::PathBuf` | `"/etc/hosts"` | Does not check disk presence |
| `IpAddr`, `SocketAddr` | `"127.0.0.1"`, `"0.0.0.0:8080"` | |
| `std::time::Duration` | `"30s"`, `"1.5h"`, `"0.5s"`, `"1h30m"` | Units: `h`, `m`, `s`, `ms`; integer and decimal segments; compound allowed |
| `Vec<T>` | `"a,b,c"`, `"80,443"` | Comma-separated; empty segments (leading/trailing/double commas) are silently ignored |
| `lockedenv::Secret<T>` | "password" | Redacts value in `Debug` and `Serialize` logs |
| `Option<T>` | `None` if absent or empty | An absent key **and** an empty string (`VAR=""`) both produce `None` |
You can add support for your own types by simply implementing `lockedenv::parse::FromEnvStr`.
```rust
use lockedenv::parse::FromEnvStr;
struct Retries(u8);
impl FromEnvStr for Retries {
type Err = String;
fn from_env_str(s: &str) -> Result<Self, Self::Err> {
let n: u8 = s.parse().map_err(|e| format!("{}", e))?;
if n > 10 {
return Err("max 10 retries".into());
}
Ok(Retries(n))
}
}
```
### Naming Convention
Field names in the macro match the real environment variable name exactly, including case. By convention variables are `UPPER_SNAKE_CASE` and are accessed the same way on the generated struct:
```rust
let config = lockedenv::load! { DATABASE_URL: String, MAX_CONN: u32 };
println!("{} (max {})", config.DATABASE_URL, config.MAX_CONN);
```
When wrapping in a typed application struct, map at the boundary:
```rust
struct AppConfig { db_url: String, max_conn: u32 }
impl AppConfig {
fn from_env() -> Self {
let raw = lockedenv::load! { DATABASE_URL: String, MAX_CONN: u32 = 10 };
Self { db_url: raw.DATABASE_URL, max_conn: raw.MAX_CONN }
}
}
```
### Custom Error Hints
`EnvLockError::with_hint` attaches a human-readable hint to **any** error variant — `Missing`, `Parse`, or `Dotenv`. The hint is shown in `Display` output after the primary message:
```rust
use lockedenv::{parse::FromEnvStr, EnvLockError};
struct Port(u16);
impl FromEnvStr for Port {
type Err = String;
fn from_env_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<u16>().map(Port).map_err(|_| "not a valid port (0–65535)".into())
}
}
// Attach a hint to a parse error:
let e = EnvLockError::parse_error("TIMEOUT".into(), "5min".into(), "unknown unit")
.with_hint("use 5m or 5s instead");
// → "expected type: unknown unit\n hint: use 5m or 5s instead"
// Attach a hint to a missing-variable error:
let e = EnvLockError::missing("DATABASE_URL".into())
.with_hint("see .env.example for the expected format");
// → "missing required variable\n …\n hint: see .env.example for the expected format"
```
## Optional Features
Extend `lockedenv` by enabling features in `Cargo.toml`.
| `dotenv` | Unlocks `load_dotenv!("path", { ... })` macros using [`dotenvy`](https://crates.io/crates/dotenvy). |
| `serde` | Automatically derives `Serialize` and `Deserialize` on your generated configuration struct. Great for debug logging / dumping config state. |
| `watch` | Provides `lockedenv::watch!` for async, background-thread interval drift detection. Generates a listener delta without heavy file watchers. |
| `url-type` | Connects directly to the [`url`](https://crates.io/crates/url) crate for strong `url::Url` typing. |
| `tracing` | Automatically logs the loaded configuration struct (with redacted secrets) at `INFO` level using the [`tracing`](https://crates.io/crates/tracing) crate upon successful load. |
### Prefixes & Secrets
If your environment variables share a common prefix, declare it once at the macro level:
```rust
// Reads APP_PORT and APP_TOKEN from the environment
let config = lockedenv::load! {
prefix = "APP_",
PORT: u16,
TOKEN: lockedenv::Secret<String>,
};
```
`Secret<T>` wraps any type and redacts its value in `Debug` output and `serde` serialization — useful when logging the config state at startup:
```rust
// Printing the config is always safe:
println!("{:?}", config); // { PORT: 8080, TOKEN: Secret([REDACTED]) }
// Access the real value when needed:
let token: &str = config.TOKEN.as_ref(); // AsRef<String>
let owned: String = config.TOKEN.clone().into_inner();
let s: lockedenv::Secret<String> = String::from("raw").into(); // From<T>
```
### Feature Showcase: Watcher
Ideal for environments (like K8s or Docker) where external factors could unexpectedly orchestrate config shifts at runtime. Note that dropping the handle stops the watcher cleanly.
```rust
// Requires: lockedenv = { version = "0.1", features = ["watch"] }
let config = lockedenv::load! { TARGET_URL: String };
// Checks every 5 seconds; only reads the listed keys on each tick.
let _handle = lockedenv::watch!(
keys = ["TARGET_URL"],
interval_secs = 5,
on_drift = |key, old, new| {
eprintln!("Drift Alert: {} shifted from {} to {}", key, old, new);
}
);
```
---
## License
MIT OR Apache-2.0. See [LICENSE-MIT](LICENSE-MIT) and [LICENSE-APACHE](LICENSE-APACHE).
---
made with Rust 🦀