# `ev_lib::settings` — cookbook
End-to-end recipes for the `settings` feature, including the org's sops/age
secrets workflow (which is deliberately **outside** the library). For the API
summary and the parity table, see [`README.md`](./README.md). The TS mirror is
[`@evinvest/settings`](../../../ts/settings).
- [The model](#the-model)
- [Declare settings](#declare-settings)
- [The contract](#the-contract)
- [Secrets: the sops boundary](#secrets-the-sops-boundary)
- [Presets — the canonical names](#presets--the-canonical-names)
- [Testing](#testing)
- [Migrating from `v_utils` LiveSettings](#migrating-from-v_utils-livesettings)
- [Gotchas](#gotchas)
## The model
Three layers, only the first two in this library:
1. **Declaration** — `settings!` generates the struct, `from_env`/`from_source`,
`var_names`, and a secret-redacting `Debug`.
2. **Parsing** — [`FromEnvValue`](./value.rs) implementations define how one
string becomes one typed value; the helpers in [`mod.rs`](./mod.rs) define
lookup (empty = unset), defaults, and error aggregation.
3. **Injection** — *who puts the variables into the environment* is not the
library's business: direnv + sops in dev, `sops exec-env` ad hoc,
`SOPS_AGE_KEY` in CI, sops-nix on hosts. Apps stay sops-unaware.
## Declare settings
One `settings.rs` per service, one call to `from_env` at startup, fail fast:
```rust
ev_lib::settings! {
pub struct AppSettings, prefix = "CABINET" {
bind: std::net::SocketAddr = "127.0.0.1:8080",
session_redis_url: String,
#[secret]
banking_issuance_token: String,
#[env("POSTHOG_KEY")]
posthog_key: Option<String>,
}
}
let settings = AppSettings::from_env().unwrap_or_else(|error| {
// one message listing EVERY problem — fix the whole list in one edit
eprintln!("{error}");
std::process::exit(78); // EX_CONFIG
});
```
Custom types parse via their `FromStr`:
```rust
ev_lib::settings_via_from_str!(MyPlane); // MyPlane: FromStr
ev_lib::settings! {
pub struct PlaneSettings {
plane: MyPlane,
}
}
```
`var_names()` makes the `.env.example` write itself:
```rust
for var in AppSettings::var_names() {
println!("{var}=");
}
```
## The contract
Shared with `@evinvest/settings` and pinned by mirrored test vectors
(`tests.rs` `mod contract` ↔ `test/contract.node.test.ts`):
| naming | SHOUTY field name; `prefix = "APP"` → `APP_…`; `#[env("NAME")]` is the final name (prefix not applied) |
| required | default; `Option<T>` opts out; `= "literal"` defaults (literal parsed by the same rules, only when unset) |
| empty string | **unset** — `VAR=` behaves exactly like no `VAR` |
| `bool` | `true`/`false`/`1`/`0`, ASCII case-insensitive, no trimming |
| lists | split on `,`, trim items, drop empty items (`"a, b ,,c"` → `a`,`b`,`c`) |
| scalars | **not** trimmed — `" 8080"` is not a number |
| errors | aggregate: one error lists every missing/invalid var; declaration order |
| secrets | `#[secret]` — `Debug` prints `***`; errors never show the value (a bad *default* is shown — it lives in source code) |
## Secrets: the sops boundary
The library reads env vars; [sops](https://github.com/getsops/sops) (with
[age](https://age-encryption.org) keys) is how the *values* travel — encrypted
in git, decrypted only at the boundary. Apps never link a sops library.
**One-time, per human (and one for CI):**
```sh
age-keygen -o ~/.config/sops/age/keys.txt # prints: Public key: age1…
```
**Per repo:** commit `.sops.yaml` listing the recipients, and the encrypted
env file (never the plaintext one):
```yaml
# .sops.yaml
creation_rules:
- path_regex: (^|/)secrets/.*\.enc\.env$
age: >-
age1exampledeveloperkey…,
age1examplecikey…
```
```sh
# --filename-override matches the *.enc.env creation rule while reading the
# plaintext file (rules select on the input path otherwise)
sops encrypt --filename-override secrets/dev.enc.env secrets/dev.env > secrets/dev.enc.env && rm secrets/dev.env
sops edit secrets/dev.enc.env # $EDITOR on plaintext, re-encrypts on save
```
**Dev shell (direnv):** decrypt into the direnv environment on entry —
```sh
# .envrc
use flake
```
— or keep the shell clean and inject per command (nothing lands on disk, the
parent shell never sees the values):
```sh
sops exec-env secrets/dev.enc.env 'cargo run'
```
**CI (GitHub Actions):** the age *private* key is the one real secret; store it
as `SOPS_AGE_KEY` and everything else stays in git:
```yaml
- uses: nhedger/setup-sops@v2
- run: sops exec-env secrets/dev.enc.env 'cargo test'
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
```
**Key hygiene:** onboarding = add the recipient to `.sops.yaml` +
`sops updatekeys secrets/*.enc.env`. Offboarding = remove the recipient +
`updatekeys` + `sops rotate -i` **and rotate the underlying credentials** — git
history is forever, an ex-recipient could already decrypt every old revision.
Values containing `$` are mangled by the direnv pattern (direnv#1278) — prefer
`sops exec-env` for those.
## Presets — the canonical names
The org-wide fix for `POSTHOG_KEY` vs `POSTHOG_API_KEY` vs
`NEXT_PUBLIC_POSTHOG_KEY`: the shared names are declared **once**, here.
```rust
use ev_lib::settings::presets::{AppEnv, Posthog, Sentry};
let posthog = Posthog::from_env()?; // POSTHOG_KEY / POSTHOG_HOST, both optional
let sentry = Sentry::from_env()?; // SENTRY_DSN, optional
let app_env = AppEnv::from_env()?; // APP_ENV, defaults to "development"
```
Load presets next to your app struct; don't re-declare their variables. The TS
package additionally ships the `NEXT_PUBLIC_*` client variants — a browser
bundler concern with no Rust equivalent.
## Testing
`from_source` takes any `FnMut(&str) -> Option<String>`, so tests never touch
the process environment:
```rust
let map = std::collections::HashMap::from([
("CABINET_SESSION_REDIS_URL".to_string(), "redis://localhost:6379".to_string()),
("CABINET_BANKING_ISSUANCE_TOKEN".to_string(), "test-token".to_string()),
]);
let settings = AppSettings::from_source(|var| map.get(var).cloned())?;
```
Assert on aggregate failures through the typed error, not string matching:
```rust
use ev_lib::settings::FieldErrorKind;