lockedenv 0.3.0

Type-safe, freeze-on-load environment variable management
Documentation
# lockedenv

> Ergonomic, type-safe, freeze-on-load environment variable management for Rust 🦀.

**Read once, parse immediately, freeze forever.**

[![crates.io](https://img.shields.io/crates/v/lockedenv.svg)](https://crates.io/crates/lockedenv) [![docs.rs](https://img.shields.io/docsrs/lockedenv)](https://docs.rs/lockedenv) [![license-MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE-MIT) [![license-Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](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)

| Rust Type | Syntax Example | Notes |
| --- | --- | --- |
| `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`.

| Feature | Description |
| --- | --- |
| `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 🦀