adotenv 0.10.1

A tooling-grade .env parser: literal values (no variable expansion), exact byte spans, and edits that leave the rest of the file untouched.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# adotenv

A literal, lossless `.env` parser and editing library for Rust.

[![crates.io](https://img.shields.io/crates/v/adotenv.svg)](https://crates.io/crates/adotenv)
[![docs.rs](https://img.shields.io/docsrs/adotenv)](https://docs.rs/adotenv)
[![CI](https://github.com/asamarts/adotenv/actions/workflows/ci.yml/badge.svg)](https://github.com/asamarts/adotenv/actions/workflows/ci.yml)

adotenv is built for linters, formatters, editors, scanners and codebase maintenance
tools. It keeps source text exactly as written, reports precise locations and
verifies every edit before returning it.

## What it does

- Values stay literal. `${HOME}`, `$USER` and command-like text are never
  expanded or executed by the parser.
- Any UTF-8 source round-trips byte for byte, including invalid statements,
  comments, spacing, a BOM and mixed line endings.
- Every token has a source span. Decoded values map back to the original bytes.
- Edits return source-relative text edits and a re-parsed, verified result.
- The core has no runtime dependencies, no file I/O, no environment access and
  no process spawning. `#![forbid(unsafe_code)]` is unconditional.
- Filesystem loading, process mutation and command execution are separate
  opt-in features or crates.

## Install

```toml
[dependencies]
adotenv = "0.10.1"
```

| Feature | Purpose |
|---|---|
| default (`std`) | Error traits and standard desktop use |
| `load` | Read `.env` cascades from files |
| `serde` | Deserialize documents and loaded environments |
| `exec` | Run command substitutions with an explicit directory |

Disable default features for a `no_std` core that uses `alloc`:

```toml
adotenv = { version = "0.10.1", default-features = false }
```

## Parse

```rust
let text = "# app\nexport PORT=8080\nURL=\"${HOST}/api\" # base\n";
let doc = adotenv::parse_strict(text).unwrap();

assert_eq!(doc.get("PORT"), Some("8080"));
assert_eq!(doc.get("URL"), Some("${HOST}/api"));
assert_eq!(doc.to_string(), text);

let url = doc.get_entry("URL").unwrap();
assert_eq!(url.line(), 3);
assert_eq!(&text[url.value_span().unwrap()], "\"${HOST}/api\"");

let edited = doc.set("URL", "https://example.invalid").unwrap();
assert_eq!(
    adotenv::parse_strict(edited.as_str()).unwrap().get("URL"),
    Some("https://example.invalid")
);
```

`parse_strict` returns the first error. `parse` always returns a document and a
list of diagnostics, so callers can inspect broken files without losing source:

```rust
let (doc, diagnostics) = adotenv::parse("BAD KEY=value\nGOOD=retained\n");
assert_eq!(doc.get("GOOD"), Some("retained"));
assert_eq!(doc.items().next().unwrap().kind(), adotenv::ItemKind::Invalid);
assert_eq!(diagnostics[0].code(), adotenv::DiagnosticCode::InvalidKey);
assert_eq!(doc.source(), "BAD KEY=value\nGOOD=retained\n");
```

Diagnostics have stable codes, severities, one-based lines and primary or
secondary spans. Match codes and spans, not message text.

`parse_owned(String)` moves the source into the document. `Document::into_owned`
copies only when the document still borrows its input.

## Dialects

The default is `Dialect::PORTABLE`. Other presets are `NODE_DOTENV`,
`PYTHON_DOTENV`, `COMPOSE` and `DOCKER_RAW`. Presets choose parsing rules only.
They do not enable interpolation, inheritance or loading.

```rust
use adotenv::{parse_with, Dialect, InlineComments};

let source = "A=a#b\nB=`two words`\n";
let (node, _) = parse_with(source, Dialect::NODE_DOTENV);
assert_eq!(node.get("A"), Some("a"));
assert_eq!(node.get("B"), Some("two words"));

let (raw, _) = node.reparse(Dialect::DOCKER_RAW);
assert_eq!(raw.get("A"), Some("a#b"));
assert_eq!(raw.get("B"), Some("`two words`"));

let custom = Dialect::NODE_DOTENV.with_inline_comments(InlineComments::None);
assert_eq!(parse_with(source, custom).0.get("A"), Some("a#b"));
```

Each document retains its exact configuration. `reparse` borrows the same source
and rebuilds items, entries and source maps for another dialect. Snapshot IDs do
not carry across interpretations.

## Lookups and raw tokens

Duplicate declarations are retained. `get_entry` selects the effective
declaration, `get_all` returns every occurrence and `unique` rejects ambiguity.

```rust
let doc = adotenv::parse_strict("A=1\nA=2\nB=3\n").unwrap();
assert_eq!(doc.get("A"), Some("2"));
assert_eq!(doc.get_all("A").count(), 2);
assert!(doc.unique("A").is_err());

let index = doc.lookup_index();
assert_eq!(index.get("A"), Some("2"));
assert_eq!(index.len(), 2);

let winners: Vec<_> = doc
    .effective_entries()
    .map(|entry| (entry.key(), entry.value()))
    .collect();
assert_eq!(winners, [("A", "2"), ("B", "3")]);
```

Raw tokens remain available:

```rust
let source = "export A=\"two words\" # note\n";
let doc = adotenv::parse_strict(source).unwrap();
let entry = doc.unique("A").unwrap();

assert!(entry.exported());
assert_eq!(&source[entry.key_span()], "A");
assert_eq!(entry.raw_value(), Some("\"two words\""));
assert_eq!(entry.value(), Some("two words"));
assert_eq!(entry.inline_comment(), Some("# note"));
assert_eq!(entry.decoded_span_to_raw(0..3).unwrap(), 10..13);
```

## References and resolution

`Entry::references` reports `$VAR`, `${VAR:-default}` and `$(command)` candidates
without expanding them. Each reference records spans, quote and escape context,
operators and nested forms.

```rust
use adotenv::{parse_strict, ReferenceOperator, ReferenceRules};

let source = "A=${HOST:-localhost}\n";
let doc = parse_strict(source).unwrap();
let reference = &doc.unique("A").unwrap().references()[0];

assert_eq!(&source[reference.span.clone()], "${HOST:-localhost}");
assert_eq!(
    reference.operator,
    Some(ReferenceOperator::DefaultIfUnsetOrEmpty)
);
assert!(reference.is_eligible(ReferenceRules::COMPOSE));
assert!(!reference.is_eligible(ReferenceRules::PORTABLE));
```

Resolution uses document bindings plus a caller lookup closure. It never reads
the environment, files or commands on its own.

```rust
use adotenv::{parse_strict, ExpandOptions, LookupOrder};

let doc = parse_strict("BASE=base\nURL=${BASE}/api\n").unwrap();
let resolved = doc.resolve(|_| None, &ExpandOptions::portable()).unwrap();
assert_eq!(resolved.entries()[1].value.as_deref(), Some("base/api"));

let caller = doc
    .resolve(
        |key| (key == "BASE").then(|| "external".to_string()),
        &ExpandOptions::portable().with_lookup_order(LookupOrder::CallerFirst),
    )
    .unwrap();
assert_eq!(caller.entries()[1].value.as_deref(), Some("external/api"));
```

Commands run only through an explicit callback or the opt-in `exec` feature:

```rust
use adotenv::{parse_strict, ExpandOptions};

let doc = parse_strict("A=$(echo hi)\n").unwrap();
let resolved = doc
    .resolve_with_commands(
        |_| None,
        |context| Ok(format!("ran:{}", context.command)),
        Some("synthetic.env"),
        &ExpandOptions::dotenv_expand(),
    )
    .unwrap();
assert_eq!(resolved.entries()[0].value.as_deref(), Some("ran:echo hi"));
assert_eq!(resolved.commands().len(), 1);
```

## Format and sort

`Document::format` changes only requested trivia and verifies the complete result
with the document dialect.

```rust
use adotenv::{parse_strict, FormatOptions};

let source = "# app   \n\nA=1   \nB =  2 # note   \n";
let doc = parse_strict(source).unwrap();
let formatted = doc.format(&FormatOptions::normalize()).unwrap();
assert_eq!(formatted.as_str(), "# app\n\nA = 1\nB = 2 # note\n");
assert!(parse_strict(formatted.as_str()).is_ok());
```

`Document::sort` orders declarations inside blank-line-separated groups and
keeps comments and multiline entries intact. References inside a group are
placed before their dependents. Groups with duplicates, empty keys, malformed
items or dependency cycles are left unchanged.

```rust
let doc = adotenv::parse_strict("# header\nB=${A}\n# A note\nA=1\n# bottom\n").unwrap();
assert_eq!(
    doc.sort().unwrap().as_str(),
    "# header\n# A note\nA=1\nB=${A}\n# bottom\n"
);
```

## Compare and generate examples

```rust
use adotenv::{parse_strict, DifferenceKind};

let left = parse_strict("A=1\nB=\nC=3\n").unwrap();
let right = parse_strict("A=2\nB\nD=4\n").unwrap();
let comparison = left.compare(&right);
let changes: Vec<_> = comparison
    .differences()
    .iter()
    .map(|entry| (entry.key.as_str(), entry.kind))
    .collect();

assert_eq!(
    changes,
    [
        ("A", DifferenceKind::Value),
        ("B", DifferenceKind::State),
        ("C", DifferenceKind::OnlyLeft),
        ("D", DifferenceKind::OnlyRight),
    ]
);
```

`Document::example` removes assigned values while preserving layout. Malformed
statements and empty-key entries become comments so they cannot leak values.

```rust
let source = "# app\nA=1\nB=\"two\" # note\nBARE\nBAD KEY=secret\n";
assert_eq!(
    adotenv::parse(source).0.example(),
    "# app\nA=\nB= # note\nBARE\n#\n"
);
```

## Edit a snapshot

Every edit method returns `VerifiedEdits`. Ranges refer to the original source.
Apply them from the highest offset to the lowest.

```rust
use adotenv::{parse_strict, Edit, InsertPosition};

let doc = parse_strict("A=one\nA=two\n").unwrap();
let second = doc.get_all("A").nth(1).unwrap();
let verified = doc
    .edit(&[
        Edit::Set { entry: second.id(), value: "changed" },
        Edit::Insert { key: "B", value: None, position: InsertPosition::End },
    ])
    .unwrap();

let (after, _) = adotenv::parse(verified.as_str());
assert_eq!(after.get("A"), Some("changed"));
assert_eq!(after.get_entry("B").unwrap().value(), None);
assert!(!verified.edits().is_empty());
```

`set`, `remove`, `rename`, `insert`, `comment`, `uncomment`, `set_export` and
`edit` verify the whole batch and reject changes that alter unrelated syntax.

Use `encode_value_with(value, dialect)` when you need a standalone literal token:

```rust
use adotenv::{encode_value_with, parse_strict_with, Dialect};

let value = "literal #\0\n";
let token = encode_value_with(value, Dialect::PYTHON_DOTENV).unwrap();
let source = format!("A={token}\n");
assert_eq!(
    parse_strict_with(&source, Dialect::PYTHON_DOTENV).unwrap().get("A"),
    Some(value)
);
assert!(encode_value_with("two\nlines", Dialect::DOCKER_RAW).is_err());
```

## Positions

Line and column conversions are explicit and checked.

```rust
use adotenv::{parse_strict, ColumnEncoding, Position};

let doc = parse_strict("A=é\n").unwrap();
let index = doc.line_index();
let position = index.to_position(2, ColumnEncoding::Scalar).unwrap();

assert_eq!(position, Position { line: 0, column: 2 });
assert_eq!(
    index.to_offset(position, ColumnEncoding::Scalar).unwrap(),
    2
);
```

`ColumnEncoding` supports UTF-8 bytes, Unicode scalars and UTF-16 code units.
Out-of-range positions and split surrogates return an error instead of being
clamped. Entry and diagnostic lines are one-based; `Position` is zero-based.

## Load files

Enable the `load` feature to read a cascade from an explicit directory. The order
is `.env`, `.env.local`, `.env.{mode}` and `.env.{mode}.local`. Later files win
by effective key. Missing cascade files are skipped. Read, UTF-8 and syntax
errors invalidate the whole load.

```rust,ignore
use adotenv::load::load_cascade;

let loaded = load_cascade("/srv/my-app", "production")?;
assert_eq!(loaded.get("PORT"), Some("8080"));

let origin = loaded.origin("PORT").unwrap();
println!("{}:{}", origin.path().display(), origin.line());
```

The default loader keeps values, shared-file origins and raw winning tokens.
Reports are opt-in:

```rust,ignore
use adotenv::load::{load_cascade_with_options, LoadOptions};

let loaded = load_cascade_with_options(
    "/srv/my-app",
    "production",
    LoadOptions::full(adotenv::Dialect::PORTABLE),
)?;

for entry in loaded.entries() {
    println!(
        "{}={} at {}:{}",
        entry.key(),
        entry.value(),
        entry.origin().path().display(),
        entry.origin().line()
    );
}
```

`LoadOptions::new(dialect)` is the default profile. Builders can disable raw
tokens, origins or reports. `load_values` and `load_cascade_values` retain only
final strings.

`LoadedEnvironment::resolve_contextual` resolves merged values with their original
quote and escape context. `resolve_decoded` treats final values as unquoted text.

## Process environment

`adotenv-process` applies a loaded environment to the current process. The API
is unsafe because the process environment is global and not thread-safe on all
platforms. Validation happens before the first mutation.

```rust,ignore
use adotenv::load::load_cascade;
use adotenv_process::apply;

let loaded = load_cascade("/srv/my-app", "production")?;
// The caller must guarantee no concurrent environment access.
unsafe { apply(&loaded)? };
```

Prefer iterating `LoadedEnvironment::values()` when mutation is not required.

## Serde

With the `serde` feature, documents and loaded environments deserialize into
typed values:

```rust,ignore
use serde::Deserialize;

#[derive(Deserialize)]
struct Config {
    host: String,
    port: u16,
}

let doc = adotenv::parse_strict("host=localhost\nport=8080\n").unwrap();
let config: Config = doc.deserialize()?;
```

Only strings, booleans, numbers and characters are converted from literal
values. Nested structures and enum layouts are not invented. Errors carry the
source line and key.

## CLI

The workspace includes `adotenv-cli`:

```text
get <file> <key>
check [--format human|json|github] [--source] [--recursive] <path>...
fmt [--write|--check] [--sort] [--recursive] <path>...
diff [--format human|matrix|json] <baseline> <file>...
example <file>
load <directory> [mode]
set <file> <key> <value>
run <directory> [mode] -- <program> [args...]
```

`check --format json` and `diff --format json` use versioned machine schemas.
`check --format github` emits workflow annotations. Human output omits source
lines unless `--source` is passed.

`run` starts a child with loaded values. It never mutates the parent process.

See [CLI details](docs/design/cli.md).

## Adapters

`adotenv-config` exposes literal values as a `config` source. `adotenv-figment`
exposes a Figment dictionary. Both support loaded environments through their
`load` feature.

```rust,ignore
use adotenv::parse_strict;
use adotenv_config::AdotenvSource;

let doc = parse_strict("host=localhost\nport=8080\n").unwrap();
let config = config::Config::builder()
    .add_source(AdotenvSource::from_document(&doc))
    .build()?;
```

## Design limits

- Input is UTF-8. Decode bytes before parsing; invalid UTF-8 is rejected rather
  than replaced.
- Parsing does not read files, environment variables or commands.
- Retained documents keep token and item metadata. Apply byte and line budgets
  for untrusted input.
- Windows writes use a direct replace when possible and a backup swap otherwise.
  The fallback is not crash-atomic.
- The `config` integration uses `config` 0.14.

## Rust version

Rust 1.81, edition 2021. Development and fuzz dependencies may require a newer
toolchain.

## License

Licensed under either [Apache License, Version 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT), at your option.