Envro
Env vars for Rust: parse .env files, load them into std::env, and validate values with a composable rule set.
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. - Small
.envdialect:#comments, empty values, double-quoted strings (with multi-line support),=inside values,$kept literal, duplicate keys rejected.
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.
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. $ is always kept literal (no $VAR substitution),
duplicate keys are a hard error, and values may contain =.
Example file:
# comments are ignored
DB_CONNECTION_STRING=pg://user:pass@db/mydb
DB_POOL_SIZE=32
EMPTY=
QUOTED="value with spaces"
ESCAPED="say \"hello\""
WITH_EQUALS=host=localhost user=admin
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 |
HASH=$2a$10$abc |
HASH = $2a$10$abc |
$ kept literal (no $VAR) |
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.
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 compile-time macros — config stays outside the binary so the same build can run with different env files or process env (deploy, containers, CI). Values are never baked in at
cargo build.
TODO
- coerce env vars to types
- support $VAR replacing
- encryption
- performance
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.