Envro
Env vars for Rust: parse .env files, load them into std::env, validate with a composable rule set, and optionally derive a typed Config.
Features
- Parse a
.envfile to aHashMap— no side effects on the process. - Load a
.envfile into the process environment with an explicit override policy. - Validate env vars against a
Schemaof composable rules. Validation is fully decoupled from loading: it works on anyHashMap, on a.envfile, or on the live process environment. - Derive a typed
Configwith#[derive(Envro)]— field types are the coerce targets;#[envro(...)]attrs are the rules. Values still load at runtime. - Small
.envdialect to support comments, multiline values, quotes and so on.
Getting started
use env;
use ;
Loading .env files
Types
EnvroVars— alias forHashMap<String, String>EnvroError— errors from envroEnvroError::File— cannot read the fileEnvroError::Parse— invalid line (missing=, empty name, unclosed quote, duplicate key, …)EnvroError::Validation— validation issues (see Validation)
load_dotenv(path) -> Result<EnvroVars, EnvroError>
Parses a .env file and returns the variables as a map. Does not change process environment variables. Duplicate keys in the file are rejected.
use env;
use *;
let env_file = current_dir.unwrap.join;
let vars = load_dotenv?;
assert_eq!;
load_dotenv_in_env_vars(path, override_existing) -> Result<(), EnvroError>
Parses the file and sets process environment variables.
override_existing |
Behavior |
|---|---|
false |
Keep existing non-empty process values; set unset or empty ones from the file |
true |
Always set values from the file, overwriting existing process values |
Keep existing values:
use env;
use *;
set_var;
let env_file = current_dir.unwrap.join;
load_dotenv_in_env_vars?;
// process value wins when already set and non-empty
assert_eq!;
Override existing values:
use env;
use *;
set_var;
let env_file = current_dir.unwrap.join;
load_dotenv_in_env_vars?;
// file value wins
assert_eq!;
Validation
Envro validates env vars with a small, composable rule set. Validation is
opt-in and decoupled from loading: the same Schema works on any
HashMap<String, String>, on a .env file, or on the live process
environment. Envro collects every failing rule and returns them all at once.
At a glance
- Build a
SchemaofFieldspecs, one per env var you care about. - Every
Fieldstarts asField::required()orField::optional(), then chains rules. - Envro collects every failing rule across every field before returning a single
EnvroError::Validation. - Validation is decoupled from where the vars come from: pass a
HashMap, load from a.envfile, or read directly from the process environment.
Same knobs as Getting started, without the derive:
use *;
let schema = new
.field
.field
.field
.field
.field
.field;
validate_env?;
Prefer #[derive(Envro)] when you also want typed fields — see Typed Config. Hand-written Schema stays useful for maps, tests, and validating without a struct.
Sources
Envro exposes three entry points that all share the same Schema:
1. Any HashMap<String, String> — validate(&vars, &schema)
Use when your env vars come from a CLI, a config service, test fixtures, or already-loaded values:
use *;
let mut vars = new;
vars.insert;
vars.insert;
let schema = new
.field
.field;
validate?;
2. From a .env file — load_dotenv_validated(path, &schema)
One-shot helper: parse the file, then validate:
use *;
let env_file = current_dir.unwrap.join;
let vars = load_dotenv_validated?;
Equivalent to let vars = load_dotenv(&env_file)?; validate(&vars, &schema)?;.
3. Directly on the process environment — validate_env(&schema)
Use when env vars are already set (shell exports, container runtime, systemd
unit, env::set_var, tests):
use *;
// vars are already in the process environment
validate_env?;
For every key in the schema, validate_env reads std::env::var(key) and
applies the field's rules. Missing/errored reads count as "not present" — same
semantics as an absent map key.
Types and functions
Schema— collection of(name, Field)specsField— a required/optional field with a chain of rulesValidationIssue— one failure:{ key: String, rule: &'static str, reason: String }EnvroError::Validation { errors: Vec<ValidationIssue> }— carries every collected failurevalidate(&EnvroVars, &Schema) -> Result<(), EnvroError>— validate any mapvalidate_env(&Schema) -> Result<(), EnvroError>— validate the process environmentload_dotenv_validated(&Path, &Schema) -> Result<EnvroVars, EnvroError>— load a.envand validate in one call
Semantics
| Situation | Behavior |
|---|---|
| Key missing from the map | required fails; optional skips remaining rules |
Key present with "" |
Same as missing (empty is treated as absent) |
| Key present with a value | Every rule on the field is checked; all failures are collected |
| Key in the map but not in the schema | Ignored |
| Multiple failures on one field | All collected |
| Multiple failing fields | All collected in one EnvroError::Validation |
Length rules (min_len, max_len, exact_len) |
Count Unicode scalars, not bytes |
Presence
Every field starts here. It is the only rule that is not chained.
required // must be present and non-empty
optional.min_len // if present, must be >= 3 chars
.env view:
# required -> fails
APP_NAME=
# required -> passes
APP_NAME=envro
# optional -> passes (missing entirely, other rules skipped)
Rule reference
Each entry below shows a schema snippet and one .env value that passes and
one that fails.
Length / string shape
-
min_len(n)— at leastncharactersrequired.min_lenAPP_NAME=envro # ok APP_NAME=x # fail: min_len -
max_len(n)— at mostncharactersrequired.max_lenSLUG=envro # ok SLUG=very-long-x # fail: max_len -
exact_len(n)— exactlyncharactersrequired.exact_lenCOLOR=ff00aa # ok COLOR=ff00 # fail: exact_len -
alpha()— only alphabetic charactersrequired.alphaREGION=eu # ok REGION=eu1 # fail: alpha -
alphanumeric()— only letters and digitsrequired.alphanumericBUILD=abc123 # ok BUILD=abc-123 # fail: alphanumeric -
digits()— only ASCII digits0-9required.digitsRETRY_COUNT=42 # ok RETRY_COUNT=42a # fail: digits -
ascii()— only ASCII charactersrequired.asciiUSER=alice # ok USER=alicé # fail: ascii -
lowercase()— no uppercase charactersrequired.lowercaseBUCKET=media # ok BUCKET=Media # fail: lowercase -
uppercase()— no lowercase charactersrequired.uppercaseREGION_CODE=EU # ok REGION_CODE=Eu # fail: uppercase -
starts_with(s)— must start withsrequired.starts_withSTRIPE_KEY=pk_live_123 # ok STRIPE_KEY=sk_live_123 # fail: starts_with -
ends_with(s)— must end withsrequired.ends_withCONFIG=prod.env # ok CONFIG=prod.yaml # fail: ends_with -
contains(s)— must containsrequired.containsDB_URL=pg://x # ok DB_URL=no-url # fail: contains -
one_of(&[..])— must equal one of the optionsrequired.one_ofLOG_LEVEL=info # ok LOG_LEVEL=trace # fail: one_of -
not_one_of(&[..])— must not equal any of the optionsrequired.not_one_ofAPP_USER=envro # ok APP_USER=root # fail: not_one_of
Numbers
-
integer()— parses asi64required.integerOFFSET=-3 # ok OFFSET=1.5 # fail: integer -
positive_integer()—i64and> 0required.positive_integerWORKERS=4 # ok WORKERS=0 # fail: positive_integer -
non_negative_integer()—i64and>= 0required.non_negative_integerRETRIES=0 # ok RETRIES=-1 # fail: non_negative_integer -
float()— parses asf64required.floatRATIO=0.75 # ok RATIO=abc # fail: float -
positive_float()—f64and> 0.0required.positive_floatRATE=1.5 # ok RATE=0.0 # fail: positive_float -
non_negative_float()—f64and>= 0.0required.non_negative_floatRATE=0.0 # ok RATE=-0.5 # fail: non_negative_float -
int_range(min, max)— inclusive integer boundsrequired.int_rangePERCENT=50 # ok PERCENT=150 # fail: int_range -
float_range(min, max)— inclusive float boundsrequired.float_rangeSAMPLE_RATE=0.25 # ok SAMPLE_RATE=2.0 # fail: float_range -
port()— integer in1..=65535required.portPORT=8080 # ok PORT=70000 # fail: port
Boolean
boolean()— one oftrue/false/1/0/yes/no(case-insensitive)required.booleanFEATURE_X=true # ok FEATURE_X=YES # ok (case-insensitive) FEATURE_X=on # fail: boolean (not accepted)
Formats (best-effort, std-only)
-
email()—local@domain, no whitespace, both sides non-empty. Practical, not RFC 5322.optional.emailADMIN_EMAIL=ops@example.com # ok ADMIN_EMAIL=ops@ # fail: email (empty domain) -
url()— starts withhttp://orhttps://, remainder non-empty and whitespace-freerequired.urlWEBHOOK=https://example.com/hook # ok WEBHOOK=ftp://x # fail: url (bad scheme) -
uuid()— canonical 8-4-4-4-12 hex form (dashes at positions 8, 13, 18, 23)required.uuidTENANT_ID=550e8400-e29b-41d4-a716-446655440000 # ok TENANT_ID=not-a-uuid # fail: uuid -
ipv4()— parses viastd::net::Ipv4Addrrequired.ipv4BIND=127.0.0.1 # ok BIND=127.0.0.256 # fail: ipv4 -
ip()— parses viastd::net::IpAddr(v4 or v6)required.ipBIND=::1 # ok BIND=not-an-ip # fail: ip -
hex()— optional0x/0Xprefix, rest must be non-empty hex digitsrequired.hexSECRET=0xDEADBEEF # ok SECRET=0xZZ # fail: hex
Lists
list(delim, item) splits the value on delim, trims each part, and applies
item's rules to every element. Failing elements report the key as KEY[i]
where i is the 0-based position. min_items(n) and max_items(n) chain
after list(..) to bound the number of elements.
-
required items, non-empty parts required:
required.listTAGS=alpha,beta,gamma # ok TAGS=alpha,,gamma # fail: TAGS[1] required -
optional items, empty parts skipped:
required.listT=aa,,cc # ok (empty middle skipped) T=a,bb,cc # fail: T[0] min_len -
CSV of positive integers, with size bounds:
required .list .min_items .max_itemsPORTS=80,443,8080 # ok PORTS= # fail: PORTS required (list rules skipped when empty) PORTS=1,2,3,4,5,6 # fail: max_items
Inspecting errors
EnvroError::Validation carries the full list of ValidationIssues, and
Display joins them under a VALIDATION_ERROR ... prefix:
match load_dotenv_validated
Example message:
VALIDATION_ERROR PORT[port]: 70000 not in 1..=65535; ADMIN_EMAIL[email]: contains whitespace
Not in v1
Intentionally out of scope for the current rule set:
- regex / arbitrary predicates
- filesystem path existence
- JSON schema, nested maps
.env format
Small, explicit dialect. Values may contain =. Duplicate keys are a hard
error. Only ${VAR} is expanded (from other keys in the same file, then the
process environment). Bare $ is always literal. Use \${VAR} to keep the
braced form without replacement. Unknown, empty, or invalid ${…} becomes an
empty string.
Example file:
# comments are ignored
HOST=db.example.com
DB_CONNECTION_STRING=pg://user:pass@${HOST}/mydb
DB_POOL_SIZE=32
EMPTY=
QUOTED="value with spaces"
ESCAPED="say \"hello\""
WITH_EQUALS=host=localhost user=admin
LITERAL=\${HOST}
HASH=$2a$10$abc
Variable substitution
| Form | Behavior |
|---|---|
${NAME} |
Replaced from other keys in the same file (any order), else from the process environment |
${} |
Replaced with "" |
\${} / \${NAME} |
Literal ${} / ${NAME} (skip replacement) |
Unknown / invalid ${…} |
Replaced with "" |
$NAME / $2a$… |
Always literal — bare $ is a normal character |
NAME must match [A-Za-z_][A-Za-z0-9_]*. Expansion is order-independent:
all keys are parsed first, then ${VAR} refs are resolved across the file
(and the process env) in as many passes as needed. Circular references
resolve to empty strings. There is no ${NAME:-default} syntax.
Compose values, validate the parts
Store each knob as its own env var, compose derived values with ${VAR}, and
validate every part with a Schema. That keeps rules close to the data
(length, port, allow-list, …) instead of parsing a blob at runtime.
Definition order does not matter.
Example — Postgres URI from validated parts
.env:
PG_USER=app
PG_PASS=secret
PG_HOST=db.example.com
PG_PORT=5432
PG_DB=mydb
PG_SSLMODE=require
DATABASE_URI=pg://${PG_USER}:${PG_PASS}@${PG_HOST}:${PG_PORT}/${PG_DB}?sslmode=${PG_SSLMODE}
After load, DATABASE_URI is
pg://app:secret@db.example.com:5432/mydb?sslmode=require.
use *;
let schema = new
.field
.field
.field
.field
.field
.field
.field;
let env_file = current_dir.unwrap.join;
let vars = load_dotenv_validated?;
let uri = vars.get.unwrap;
See example/ for a runnable version of this pattern.
Valid rows
| Row | Parses to | Notes |
|---|---|---|
# any text |
(skipped) | Full-line comment |
| (empty line) | (skipped) | Blank lines are ignored |
NAME=envro |
NAME = envro |
Basic KEY=value |
EMPTY= |
EMPTY = "" |
Empty value, no quotes |
EMPTY="" |
EMPTY = "" |
Empty quoted value |
QUOTED="a b" |
QUOTED = a b |
Double-quoted value |
ESCAPED="say \"hi\"" |
ESCAPED = say "hi" |
\" escapes an inner quote |
DSN=host=db user=admin |
DSN = host=db user=admin |
= allowed inside value |
HOST=h / URL=${HOST} (any order) |
URL = h |
Order-independent ${VAR} |
A=abc${B} / B=123 |
A = abc123 |
Forward refs resolve |
BARE=$HOST |
BARE = $HOST |
Bare $ never expanded |
LIT=\${HOST} |
LIT = ${HOST} |
\${…} skips replacement |
X=${} |
X = "" |
Empty braces → empty |
X=\${} |
X = ${} |
\${} skips replacement |
X=${MISSING} |
X = "" |
Unknown ${VAR} → empty |
X=${1} |
X = "" |
Invalid name → empty |
HASH=$2a$10$abc |
HASH = $2a$10$abc |
$ allowed as a normal char |
URL="pg://u:p@h/db" |
URL = pg://u:p@h/db |
Any chars fine inside quotes |
KEY="line1line2" |
KEY = line1\nline2 |
Multi-line quoted value — newlines preserved |
Multi-line values
Double-quoted values may span multiple physical lines. When a value opens
with " and does not close on the same line, envro keeps reading lines
(joining them with \n) until it finds a line ending with an unescaped ".
Lines inside the quotes are taken literally — blank lines and # at the
start of a line are part of the value, not comments. CRLF endings are
normalized to \n inside the value.
PEM="-----BEGIN PRIVATE KEY-----
MIIBVwIBADANBgkqhkiG9w0BAQEFAA...
-----END PRIVATE KEY-----"
Escapes inside quotes: \" — a literal "; a trailing \" on a line therefore
does not close the value.
Invalid rows
| Row | Error |
|---|---|
NAME value |
PARSE_ERROR ... missing value (no =) |
=value |
PARSE_ERROR ... missing variable name |
KEY="unclosed |
PARSE_ERROR ... missing closing quote |
KEY=a + KEY=b (same file) |
PARSE_ERROR ... duplicate variable name: KEY |
Anything a .env file rejects surfaces as EnvroError::Parse; unreadable /
missing files surface as EnvroError::File. See Validation for
EnvroError::Validation.
Typed Config (#[derive(Envro)])
Single source of truth: Rust field types are the coerce targets; #[envro(...)]
attributes are the validation rules. Values are never baked into the binary —
they still load at runtime from a .env file, a map, or the process environment.
Supported field types: String, bool, i32, i64, u16, u32, u64,
f32, f64, and Option<T> of those (optional presence).
Default env key: screaming-snake of the field name (retry_count → RETRY_COUNT).
Override with from = "KEY".
use ;
Generated API (via EnvroConfig):
Config::schema() -> SchemaConfig::from_vars(&EnvroVars) -> Result<Self, EnvroError>Config::from_dotenv(&Path) -> Result<Self, EnvroError>Config::from_env() -> Result<Self, EnvroError>
Common #[envro(...)] rules mirror Field: flags such as
port, boolean, integer, positive_integer, email, … and keyed forms
min_len = n, max_len = n, starts_with = "...", one_of("a", "b"),
int_range(1, 100), etc. The hand-written Schema / Field API remains fully
supported.
Out of scope
Features not implemented by design:
- No multi-file layering — composable configs are avoided; one path per call. Follows the “No-Inheritance” Flat principle (CUE on inheritance, Angular LIFT Flat).
- No macros that bake env values into the binary —
#[derive(Envro)]encodes types and rules only;.env/ process values are always read at runtime. Same build, different env files or containers.
TODO
- encryption
- performance
- proper parsing
LICENSE
MIT License
Copyright (c) 2024-2026 Simone Sanfratello
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.