dotenv_space/core/parser.rs
1//! `.env` file parser — merged implementation.
2//!
3//! Combines the **correctness** of the original parser (typed errors, key
4//! validation, circular-expansion detection, depth limiting) with the
5//! **feature set** of the refactored parser (backtick quotes, multiline
6//! values, bare `$VAR` expansion, inline-comment stripping, configurable
7//! value trimming, and a proper [`Default`] impl).
8//!
9//! # Format support
10//!
11//! | Feature | Supported |
12//! |--------------------------|-----------|
13//! | `KEY=value` | ✓ |
14//! | `export KEY=value` | ✓ |
15//! | `# comments` | ✓ |
16//! | Inline `# comments` | ✓ (opt-in via [`ParserConfig::allow_inline_comments`]) |
17//! | Double-quoted values | ✓ |
18//! | Single-quoted values | ✓ |
19//! | Backtick-quoted values | ✓ |
20//! | Multiline values | ✓ (opt-in via [`ParserConfig::allow_multiline`]) |
21//! | `${VAR}` expansion | ✓ |
22//! | `$VAR` expansion | ✓ |
23//! | Circular expansion guard | ✓ |
24//! | Strict uppercase keys | ✓ (opt-in via [`ParserConfig::strict`]) |
25//!
26//! # Compatibility with other modules
27//!
28//! Every call site in the codebase uses one of these three patterns:
29//!
30//! ```rust,ignore
31//! // Pattern A — most common (all commands)
32//! let parser = Parser::default();
33//! let env_file = parser.parse_file(".env")?;
34//! env_file.vars // HashMap<String, String>
35//!
36//! // Pattern B — config override (validate --strict, tests)
37//! let parser = Parser::new(ParserConfig { strict: true, ..Default::default() });
38//!
39//! // Pattern C — parse from string (tests, template command)
40//! let vars = parser.parse_content("KEY=value")?;
41//! ```
42//!
43//! This merged parser satisfies **all three patterns** without any call-site
44//! changes. See § Compatibility notes below for per-module details.
45//!
46//! # Error handling
47//!
48//! All errors are [`ParseError`] variants. Because the commands wrap parser
49//! calls with `anyhow::Context` (`.with_context(|| ...)`), the structured
50//! error converts automatically into `anyhow::Error` at the boundary — no
51//! changes needed in any command file.
52//!
53//! # Compatibility notes by module
54//!
55//! | Module | Method used | Breaking change? |
56//! |--------|-------------|-----------------|
57//! | `commands/validate.rs` | `parse_file(&str)` | None — signature preserved |
58//! | `commands/diff.rs` | `parse_file(&str)` | None |
59//! | `commands/scan.rs` | `parse_file(&str)` | None |
60//! | `commands/convert.rs` | `parse_file(&str)` | None |
61//! | `commands/sync.rs` | `parse_file(&str)` | None |
62//! | `commands/template.rs` | `parse_file(&str)` | None |
63//! | `commands/migrate.rs` | `parse_file(&str)` | None |
64//! | `commands/backup.rs` | Not used directly | N/A |
65//! | `commands/doctor.rs` | `parse_file(&str)` | None |
66//! | `commands/init.rs` | `parse_file(&str)` | None |
67//! | `core/converter.rs` | `EnvFile.vars` | None — field name preserved |
68//! | Tests | `parse_content` | None — method name preserved |
69
70use std::collections::HashMap;
71use std::fs;
72use std::path::Path;
73use thiserror::Error;
74
75// ── Error type ────────────────────────────────────────────────────────────────
76
77/// Structured parse errors with line numbers and context.
78///
79/// Implements [`std::error::Error`] via [`thiserror`] and converts into
80/// [`anyhow::Error`] automatically when used with the `?` operator inside
81/// a function that returns `anyhow::Result`. This means **no changes are
82/// needed in command files** that currently do:
83///
84/// ```rust,ignore
85/// let env_file = parser
86/// .parse_file(&env)
87/// .with_context(|| format!("Failed to parse {}", env))?;
88/// ```
89#[derive(Debug, Error)]
90pub enum ParseError {
91 /// The file could not be read from disk.
92 #[error("Failed to read file: {0}")]
93 FileReadError(#[from] std::io::Error),
94
95 /// A line did not contain a `=` separator (and is not a comment or blank).
96 #[error("Invalid format at line {line}: {message}")]
97 InvalidFormat { line: usize, message: String },
98
99 /// A key contains characters outside `[A-Za-z][A-Za-z0-9_]*`.
100 #[error("Invalid key at line {line}: '{key}' (keys must match [A-Za-z][A-Za-z0-9_]*)")]
101 InvalidKey { line: usize, key: String },
102
103 /// A `${VAR}` or `$VAR` reference names a variable that was not defined
104 /// earlier in the file.
105 #[error("Undefined variable at line {line}: ${{{var}}} is not defined")]
106 UndefinedVariable { line: usize, var: String },
107
108 /// Two or more variables reference each other in a cycle.
109 #[error("Circular variable expansion at line {line}: {cycle}")]
110 CircularExpansion { line: usize, cycle: String },
111
112 /// A quoted string was opened but never closed.
113 #[error("Unterminated quoted string at line {line}")]
114 UnterminatedString { line: usize },
115
116 /// Expansion depth exceeded [`ParserConfig::max_expansion_depth`].
117 #[error("Variable expansion too deep at line {line}: max depth {max} exceeded")]
118 ExpansionDepthExceeded { line: usize, max: usize },
119}
120
121/// Convenience alias used throughout the parser internals.
122pub type ParseResult<T> = Result<T, ParseError>;
123
124// ── Public data types ─────────────────────────────────────────────────────────
125
126/// The result of parsing a `.env` file or string.
127///
128/// `vars` is the field accessed by every command and converter in the
129/// codebase. The field name is **identical** to both the old and new parser,
130/// so no call sites need updating.
131#[derive(Debug, Clone)]
132pub struct EnvFile {
133 /// Parsed key-value pairs, in insertion order within the underlying
134 /// `HashMap`. Use an `IndexMap` if deterministic ordering is needed.
135 pub vars: HashMap<String, String>,
136
137 /// The file path this was parsed from, or `None` when parsed from a string.
138 ///
139 /// Changed from `String` (old) to `Option<String>` (new) — callers that
140 /// only access `env_file.vars` are unaffected.
141 pub source: Option<String>,
142}
143
144// ── Configuration ─────────────────────────────────────────────────────────────
145
146/// Controls parser behaviour. Construct with [`Default::default()`] and
147/// override individual fields as needed.
148///
149/// # Example
150///
151/// ```rust
152/// use dotenv_space::core::parser::ParserConfig;
153///
154/// // Strict mode: only uppercase keys, no inline comments
155/// let config = ParserConfig {
156/// strict: true,
157/// allow_inline_comments: false,
158/// ..Default::default()
159/// };
160/// ```
161#[derive(Debug, Clone)]
162pub struct ParserConfig {
163 /// Enable `${VAR}` and `$VAR` substitution in values.
164 ///
165 /// Default: `true`.
166 pub allow_expansion: bool,
167
168 /// Enforce all-uppercase keys. Fails with [`ParseError::InvalidKey`] if a
169 /// lowercase key is encountered.
170 ///
171 /// Default: `false`.
172 pub strict: bool,
173
174 /// Maximum number of recursive expansions before raising
175 /// [`ParseError::ExpansionDepthExceeded`]. Prevents runaway expansion of
176 /// deeply nested variable references.
177 ///
178 /// Default: `10`.
179 pub max_expansion_depth: usize,
180
181 /// Strip inline comments from unquoted values. When `true`, the `#` and
182 /// everything after it on unquoted lines is discarded.
183 ///
184 /// Example: `PORT=8080 # web server` → `PORT=8080`.
185 ///
186 /// Default: `true`.
187 pub allow_inline_comments: bool,
188
189 /// Trim leading and trailing whitespace from values after all other
190 /// processing. Quoted values are never trimmed — their whitespace is
191 /// always preserved.
192 ///
193 /// Default: `true`.
194 pub trim_values: bool,
195
196 /// Accept values that span multiple lines. A value whose opening quote is
197 /// not closed on the same line accumulates subsequent lines until the
198 /// closing quote is found.
199 ///
200 /// Default: `true`.
201 pub allow_multiline: bool,
202}
203
204impl Default for ParserConfig {
205 fn default() -> Self {
206 Self {
207 allow_expansion: true,
208 strict: false,
209 max_expansion_depth: 10,
210 allow_inline_comments: true,
211 trim_values: true,
212 allow_multiline: true,
213 }
214 }
215}
216
217// ── Parser ────────────────────────────────────────────────────────────────────
218
219/// `.env` file parser.
220///
221/// Construct with [`Parser::default()`] for standard behaviour, or
222/// [`Parser::new(config)`] to customise.
223pub struct Parser {
224 config: ParserConfig,
225}
226
227/// Correct implementation of the [`Default`] trait.
228///
229/// The old parser used a hand-written `pub fn default() -> Self` method,
230/// which triggers `clippy::should_implement_trait`. This implementation
231/// satisfies the trait properly so `Parser::default()` continues to work
232/// at every existing call site without change.
233impl Default for Parser {
234 fn default() -> Self {
235 Self::new(ParserConfig::default())
236 }
237}
238
239impl Parser {
240 /// Create a parser with a custom [`ParserConfig`].
241 pub fn new(config: ParserConfig) -> Self {
242 Self { config }
243 }
244
245 // ── Public API ────────────────────────────────────────────────────────────
246
247 /// Parse a `.env` file from a filesystem path.
248 ///
249 /// Accepts any type that implements `AsRef<Path>` — `&str`, `String`,
250 /// `PathBuf`, `Path`, and `OsStr` all work without conversion.
251 ///
252 /// This restores the **generic signature** from the old parser. The new
253 /// parser narrowed it to `&str`, which forced callers holding a `PathBuf`
254 /// to call `.to_str().unwrap()`.
255 ///
256 /// # Errors
257 ///
258 /// Returns [`ParseError::FileReadError`] if the file cannot be read, or
259 /// any parse error variant if the content is invalid.
260 ///
261 /// # Example
262 ///
263 /// ```rust,no_run
264 /// use dotenv_space::core::Parser;
265 ///
266 /// let parser = Parser::default();
267 /// let env_file = parser.parse_file(".env")?;
268 /// println!("Loaded {} variables", env_file.vars.len());
269 /// # Ok::<(), anyhow::Error>(())
270 /// ```
271 pub fn parse_file<P: AsRef<Path>>(&self, path: P) -> ParseResult<EnvFile> {
272 let content = fs::read_to_string(path.as_ref())?;
273 let source = path.as_ref().to_string_lossy().into_owned();
274 let vars = self.parse_content(&content)?;
275 Ok(EnvFile {
276 vars,
277 source: Some(source),
278 })
279 }
280
281 /// Parse `.env` content from an in-memory string.
282 ///
283 /// Method name matches the **old parser** (`parse_content`) so existing
284 /// tests and the template command require no changes.
285 ///
286 /// # Example
287 ///
288 /// ```rust
289 /// use dotenv_space::core::Parser;
290 ///
291 /// let parser = Parser::default();
292 /// let vars = parser.parse_content("KEY=value\nOTHER=123")?;
293 /// assert_eq!(vars["KEY"], "value");
294 /// # Ok::<(), dotenv_space::core::parser::ParseError>(())
295 /// ```
296 pub fn parse_content(&self, content: &str) -> ParseResult<HashMap<String, String>> {
297 let mut vars: HashMap<String, String> = HashMap::new();
298
299 // Multiline accumulation state.
300 let mut ml_key: Option<String> = None;
301 let mut ml_value = String::new();
302 let mut ml_quote: char = '"';
303 let mut ml_start_line: usize = 0;
304
305 for (idx, raw_line) in content.lines().enumerate() {
306 let line_num = idx + 1; // 1-indexed for all user-facing messages
307
308 // ── Multiline continuation ────────────────────────────────────────
309 if let Some(ref key) = ml_key.clone() {
310 let trimmed_end = raw_line.trim_end();
311
312 if let Some(before_close) = trimmed_end.strip_suffix(ml_quote) {
313 // Closing quote found — finalise the value.
314 ml_value.push('\n');
315 ml_value.push_str(before_close);
316 vars.insert(key.clone(), ml_value.clone());
317 ml_key = None;
318 ml_value.clear();
319 } else {
320 // Still inside a multiline value — accumulate.
321 ml_value.push('\n');
322 ml_value.push_str(raw_line);
323 }
324 continue;
325 }
326
327 // ── Skip blank lines and full-line comments ────────────────────────
328 let line = raw_line.trim();
329 if line.is_empty() || line.starts_with('#') {
330 continue;
331 }
332
333 // ── Parse KEY=VALUE ───────────────────────────────────────────────
334 let (key, raw_value) = self.split_key_value(line, line_num)?;
335
336 // ── Key validation ────────────────────────────────────────────────
337 self.validate_key(&key, line_num)?;
338
339 // ── Value parsing ─────────────────────────────────────────────────
340 match self.classify_quote(&raw_value) {
341 // Quoted value — check for multiline
342 Some(q) if self.config.allow_multiline && !self.is_closed_quote(&raw_value, q) => {
343 // Opening quote but no closing quote on this line.
344 ml_key = Some(key);
345 // Strip the opening quote from the accumulated content.
346 ml_value = raw_value.trim_start_matches(q).to_string();
347 ml_quote = q;
348 ml_start_line = line_num;
349 }
350 _ => {
351 let value = self.parse_value(&raw_value, line_num)?;
352 vars.insert(key, value);
353 }
354 }
355 }
356
357 // If we exited the loop still inside a multiline value, the file ended
358 // without a closing quote.
359 if let Some(_key) = ml_key {
360 return Err(ParseError::UnterminatedString {
361 line: ml_start_line,
362 });
363 }
364
365 // ── Variable expansion ────────────────────────────────────────────────
366 if self.config.allow_expansion {
367 self.expand_all(&mut vars)?;
368 }
369
370 Ok(vars)
371 }
372
373 // ── Private: line parsing ─────────────────────────────────────────────────
374
375 /// Split `line` into `(key, raw_value)` at the first `=`.
376 ///
377 /// Handles the optional `export` prefix used by shell scripts and tools
378 /// like Heroku CLI and direnv.
379 fn split_key_value(&self, line: &str, line_num: usize) -> ParseResult<(String, String)> {
380 // Strip optional `export ` prefix.
381 let line = line
382 .strip_prefix("export")
383 .map(|s| s.trim_start())
384 .unwrap_or(line);
385
386 let eq = line.find('=').ok_or_else(|| ParseError::InvalidFormat {
387 line: line_num,
388 message: "missing '=' separator".into(),
389 })?;
390
391 let key = line[..eq].trim().to_string();
392 let raw = line[eq + 1..].to_string(); // intentionally NOT trimmed yet
393
394 Ok((key, raw))
395 }
396
397 /// Enforce key naming rules: `[A-Za-z][A-Za-z0-9_]*`, and uppercase-only
398 /// when [`ParserConfig::strict`] is set.
399 fn validate_key(&self, key: &str, line_num: usize) -> ParseResult<()> {
400 if key.is_empty() {
401 return Err(ParseError::InvalidKey {
402 line: line_num,
403 key: key.to_string(),
404 });
405 }
406
407 let mut chars = key.chars();
408
409 // First character must be a letter.
410 match chars.next() {
411 Some(c) if c.is_ascii_alphabetic() => {}
412 _ => {
413 return Err(ParseError::InvalidKey {
414 line: line_num,
415 key: key.to_string(),
416 })
417 }
418 }
419
420 // Remaining characters: alphanumeric or underscore.
421 for c in chars {
422 if !c.is_ascii_alphanumeric() && c != '_' {
423 return Err(ParseError::InvalidKey {
424 line: line_num,
425 key: key.to_string(),
426 });
427 }
428 }
429
430 // Strict mode: all-uppercase required.
431 if self.config.strict && key != key.to_uppercase() {
432 return Err(ParseError::InvalidKey {
433 line: line_num,
434 key: key.to_string(),
435 });
436 }
437
438 Ok(())
439 }
440
441 // ── Private: value parsing ────────────────────────────────────────────────
442
443 /// Return the opening quote character if `raw` starts with `"`, `'`,
444 /// or `` ` ``, otherwise `None`.
445 fn classify_quote(&self, raw: &str) -> Option<char> {
446 match raw.trim_start().chars().next() {
447 Some(c @ ('"' | '\'' | '`')) => Some(c),
448 _ => None,
449 }
450 }
451
452 /// Return `true` if `raw` is a properly closed quoted string (same quote
453 /// at start and end, and length >= 2).
454 fn is_closed_quote(&self, raw: &str, q: char) -> bool {
455 let t = raw.trim();
456 t.len() >= 2 && t.starts_with(q) && t.ends_with(q)
457 }
458
459 /// Parse a raw value string into its final form.
460 ///
461 /// Dispatch order:
462 /// 1. Empty → empty string.
463 /// 2. Double-quoted → unescape escape sequences.
464 /// 3. Single-quoted / backtick → literal (no unescaping).
465 /// 4. Unquoted → strip inline comment, optionally trim.
466 fn parse_value(&self, raw: &str, line_num: usize) -> ParseResult<String> {
467 let raw = raw.trim_start(); // leading whitespace after `=` is never significant
468
469 if raw.is_empty() {
470 return Ok(String::new());
471 }
472
473 let first = raw.chars().next().unwrap(); // safe: checked is_empty above
474
475 match first {
476 '"' => {
477 if !raw.ends_with('"') || raw.len() < 2 {
478 return Err(ParseError::UnterminatedString { line: line_num });
479 }
480 let inner = &raw[1..raw.len() - 1];
481 Ok(self.unescape_double(inner))
482 }
483
484 '\'' | '`' => {
485 if !raw.ends_with(first) || raw.len() < 2 {
486 return Err(ParseError::UnterminatedString { line: line_num });
487 }
488 // Single-quoted and backtick-quoted: literal content, no escaping.
489 Ok(raw[1..raw.len() - 1].to_string())
490 }
491
492 _ => {
493 // Unquoted value.
494 let val = if self.config.allow_inline_comments {
495 // Strip `# comment` — but only outside quotes (we are
496 // already in the unquoted branch here).
497 match raw.find('#') {
498 Some(pos) => raw[..pos].trim_end(),
499 None => raw.trim_end(),
500 }
501 } else {
502 raw.trim_end()
503 };
504
505 if self.config.trim_values {
506 Ok(val.trim().to_string())
507 } else {
508 Ok(val.to_string())
509 }
510 }
511 }
512 }
513
514 /// Process backslash escape sequences inside a double-quoted value.
515 ///
516 /// Recognised sequences: `\n`, `\r`, `\t`, `\\`, `\"`, `\'`.
517 /// Unknown sequences are kept literally (backslash + character).
518 fn unescape_double(&self, s: &str) -> String {
519 let mut result = String::with_capacity(s.len());
520 let mut chars = s.chars();
521
522 while let Some(ch) = chars.next() {
523 if ch != '\\' {
524 result.push(ch);
525 continue;
526 }
527 match chars.next() {
528 Some('n') => result.push('\n'),
529 Some('r') => result.push('\r'),
530 Some('t') => result.push('\t'),
531 Some('\\') => result.push('\\'),
532 Some('"') => result.push('"'),
533 Some('\'') => result.push('\''),
534 Some(c) => {
535 result.push('\\');
536 result.push(c);
537 }
538 None => result.push('\\'),
539 }
540 }
541 result
542 }
543
544 // ── Private: variable expansion ───────────────────────────────────────────
545
546 /// Expand all `${VAR}` and `$VAR` references across the full variable map.
547 ///
548 /// Each value is expanded independently. Circular references and undefined
549 /// variables produce structured errors.
550 fn expand_all(&self, vars: &mut HashMap<String, String>) -> ParseResult<()> {
551 // Snapshot keys to avoid borrow conflicts while mutating the map.
552 let keys: Vec<String> = vars.keys().cloned().collect();
553 let mut expanded: HashMap<String, String> = HashMap::with_capacity(vars.len());
554
555 for key in &keys {
556 let value = vars[key].clone();
557 let mut stack: Vec<String> = Vec::new();
558 let result = self.expand_value(&value, vars, &mut stack, 0, 0)?;
559 expanded.insert(key.clone(), result);
560 }
561
562 *vars = expanded;
563 Ok(())
564 }
565
566 /// Recursively expand variable references within a single `value` string.
567 ///
568 /// # Arguments
569 ///
570 /// * `value` — The string to expand.
571 /// * `vars` — The full variable map (snapshot at expansion start).
572 /// * `stack` — Variables currently being expanded (cycle detection).
573 /// * `depth` — Current recursion depth.
574 /// * `line_hint` — Line number for error reporting (0 when unknown).
575 fn expand_value(
576 &self,
577 value: &str,
578 vars: &HashMap<String, String>,
579 stack: &mut Vec<String>,
580 depth: usize,
581 line_hint: usize,
582 ) -> ParseResult<String> {
583 if depth > self.config.max_expansion_depth {
584 return Err(ParseError::ExpansionDepthExceeded {
585 line: line_hint,
586 max: self.config.max_expansion_depth,
587 });
588 }
589
590 let mut result = String::with_capacity(value.len());
591 let mut chars = value.chars().peekable();
592
593 while let Some(ch) = chars.next() {
594 if ch != '$' {
595 result.push(ch);
596 continue;
597 }
598
599 match chars.peek() {
600 // ── ${VAR} syntax ─────────────────────────────────────────────
601 Some(&'{') => {
602 chars.next(); // consume `{`
603 let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
604
605 if stack.contains(&var_name) {
606 return Err(ParseError::CircularExpansion {
607 line: line_hint,
608 cycle: format!("{} → {}", stack.join(" → "), var_name),
609 });
610 }
611
612 match vars.get(&var_name) {
613 Some(val) => {
614 stack.push(var_name.clone());
615 let expanded =
616 self.expand_value(val, vars, stack, depth + 1, line_hint)?;
617 stack.pop();
618 result.push_str(&expanded);
619 }
620 None => {
621 return Err(ParseError::UndefinedVariable {
622 line: line_hint,
623 var: var_name,
624 });
625 }
626 }
627 }
628
629 // ── $VAR bare syntax ──────────────────────────────────────────
630 Some(&c) if c.is_ascii_alphanumeric() || c == '_' => {
631 let var_name: String = chars
632 .by_ref()
633 .take_while(|&c| c.is_ascii_alphanumeric() || c == '_')
634 .collect();
635
636 if stack.contains(&var_name) {
637 return Err(ParseError::CircularExpansion {
638 line: line_hint,
639 cycle: format!("{} → {}", stack.join(" → "), var_name),
640 });
641 }
642
643 match vars.get(&var_name) {
644 Some(val) => {
645 stack.push(var_name.clone());
646 let expanded =
647 self.expand_value(val, vars, stack, depth + 1, line_hint)?;
648 stack.pop();
649 result.push_str(&expanded);
650 }
651 None => {
652 // Bare $VAR: keep literal if undefined (common in
653 // shell scripts where $PATH etc. are expected to
654 // come from the environment, not the .env file).
655 // This diverges intentionally from ${VAR} which
656 // always errors — bare $ references are far more
657 // likely to be shell variables than typos.
658 result.push('$');
659 result.push_str(&var_name);
660 }
661 }
662 }
663
664 // ── Lone $ ────────────────────────────────────────────────────
665 _ => {
666 result.push('$');
667 }
668 }
669 }
670
671 Ok(result)
672 }
673}
674
675// ── Tests ─────────────────────────────────────────────────────────────────────
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 // ── Basic parsing ─────────────────────────────────────────────────────────
682
683 #[test]
684 fn test_basic_key_value() {
685 let p = Parser::default();
686 let vars = p.parse_content("KEY1=value1\nKEY2=value2").unwrap();
687 assert_eq!(vars["KEY1"], "value1");
688 assert_eq!(vars["KEY2"], "value2");
689 }
690
691 #[test]
692 fn test_empty_lines_and_comments_skipped() {
693 let p = Parser::default();
694 let vars = p.parse_content("# comment\n\nKEY=val\n# another").unwrap();
695 assert_eq!(vars.len(), 1);
696 assert_eq!(vars["KEY"], "val");
697 }
698
699 #[test]
700 fn test_empty_value() {
701 let p = Parser::default();
702 let vars = p.parse_content("KEY=").unwrap();
703 assert_eq!(vars["KEY"], "");
704 }
705
706 #[test]
707 fn test_whitespace_around_equals() {
708 let p = Parser::default();
709 let vars = p.parse_content(" KEY1 = value1 ").unwrap();
710 assert_eq!(vars["KEY1"], "value1");
711 }
712
713 // ── export prefix ─────────────────────────────────────────────────────────
714
715 #[test]
716 fn test_export_prefix() {
717 let p = Parser::default();
718 let vars = p
719 .parse_content("export KEY1=value1\nexport KEY2=value2")
720 .unwrap();
721 assert_eq!(vars["KEY1"], "value1");
722 assert_eq!(vars["KEY2"], "value2");
723 }
724
725 // ── Quote styles ──────────────────────────────────────────────────────────
726
727 #[test]
728 fn test_double_quoted() {
729 let p = Parser::default();
730 let vars = p.parse_content(r#"KEY="hello world""#).unwrap();
731 assert_eq!(vars["KEY"], "hello world");
732 }
733
734 #[test]
735 fn test_single_quoted() {
736 let p = Parser::default();
737 let vars = p.parse_content("KEY='hello world'").unwrap();
738 assert_eq!(vars["KEY"], "hello world");
739 }
740
741 #[test]
742 fn test_backtick_quoted() {
743 let p = Parser::default();
744 let vars = p.parse_content("KEY=`hello world`").unwrap();
745 assert_eq!(vars["KEY"], "hello world");
746 }
747
748 #[test]
749 fn test_empty_double_quoted() {
750 let p = Parser::default();
751 let vars = p.parse_content(r#"KEY="""#).unwrap();
752 assert_eq!(vars["KEY"], "");
753 }
754
755 // ── Escape sequences ──────────────────────────────────────────────────────
756
757 #[test]
758 fn test_escape_newline_tab() {
759 let p = Parser::default();
760 let vars = p.parse_content(r#"KEY="line1\nline2\ttab""#).unwrap();
761 assert_eq!(vars["KEY"], "line1\nline2\ttab");
762 }
763
764 #[test]
765 fn test_escape_quote_and_backslash() {
766 let p = Parser::default();
767 let vars = p.parse_content(r#"KEY="He said \"hi\"\\path""#).unwrap();
768 assert_eq!(vars["KEY"], r#"He said "hi"\path"#);
769 }
770
771 #[test]
772 fn test_escape_single_quote_in_double() {
773 let p = Parser::default();
774 let vars = p.parse_content(r#"KEY="it\'s a test""#).unwrap();
775 assert_eq!(vars["KEY"], "it's a test");
776 }
777
778 #[test]
779 fn test_single_quoted_no_escaping() {
780 // Backslashes inside single quotes are literal.
781 let p = Parser::default();
782 let vars = p.parse_content(r"KEY='no\nescape'").unwrap();
783 assert_eq!(vars["KEY"], r"no\nescape");
784 }
785
786 // ── Inline comments ───────────────────────────────────────────────────────
787
788 #[test]
789 fn test_inline_comment_stripped() {
790 let p = Parser::default();
791 let vars = p.parse_content("PORT=8080 # web server").unwrap();
792 assert_eq!(vars["PORT"], "8080");
793 }
794
795 #[test]
796 fn test_inline_comment_disabled() {
797 let p = Parser::new(ParserConfig {
798 allow_inline_comments: false,
799 ..Default::default()
800 });
801 let vars = p.parse_content("PORT=8080 # web server").unwrap();
802 assert_eq!(vars["PORT"], "8080 # web server");
803 }
804
805 #[test]
806 fn test_hash_inside_double_quotes_preserved() {
807 // # inside a quoted string must NOT be treated as a comment.
808 let p = Parser::default();
809 let vars = p.parse_content(r#"KEY="value#notacomment""#).unwrap();
810 assert_eq!(vars["KEY"], "value#notacomment");
811 }
812
813 // ── Multiline values ──────────────────────────────────────────────────────
814
815 #[test]
816 fn test_multiline_double_quoted() {
817 let p = Parser::default();
818 let content = "KEY=\"line one\nline two\nline three\"";
819 let vars = p.parse_content(content).unwrap();
820 assert_eq!(vars["KEY"], "line one\nline two\nline three");
821 }
822
823 #[test]
824 fn test_multiline_disabled_returns_error() {
825 let p = Parser::new(ParserConfig {
826 allow_multiline: false,
827 ..Default::default()
828 });
829 // Without multiline support an unclosed quote is an error.
830 let result = p.parse_content("KEY=\"unclosed");
831 assert!(result.is_err());
832 assert!(matches!(
833 result.unwrap_err(),
834 ParseError::UnterminatedString { .. }
835 ));
836 }
837
838 #[test]
839 fn test_unterminated_string_eof() {
840 // File ends while still inside a multiline value.
841 let p = Parser::default();
842 let result = p.parse_content("KEY=\"starts but never ends");
843 assert!(result.is_err());
844 assert!(matches!(
845 result.unwrap_err(),
846 ParseError::UnterminatedString { .. }
847 ));
848 }
849
850 // ── Key validation ────────────────────────────────────────────────────────
851
852 #[test]
853 fn test_key_starting_with_digit_rejected() {
854 let p = Parser::default();
855 let result = p.parse_content("1KEY=value");
856 assert!(result.is_err());
857 assert!(matches!(result.unwrap_err(), ParseError::InvalidKey { .. }));
858 }
859
860 #[test]
861 fn test_key_with_hyphen_rejected() {
862 let p = Parser::default();
863 let result = p.parse_content("MY-KEY=value");
864 assert!(result.is_err());
865 assert!(matches!(result.unwrap_err(), ParseError::InvalidKey { .. }));
866 }
867
868 #[test]
869 fn test_key_with_space_rejected() {
870 let p = Parser::default();
871 let result = p.parse_content("MY KEY=value");
872 assert!(result.is_err());
873 }
874
875 #[test]
876 fn test_mixed_case_key_accepted_by_default() {
877 let p = Parser::default();
878 let vars = p.parse_content("MyKey=value").unwrap();
879 assert_eq!(vars["MyKey"], "value");
880 }
881
882 #[test]
883 fn test_strict_mode_rejects_lowercase() {
884 let p = Parser::new(ParserConfig {
885 strict: true,
886 ..Default::default()
887 });
888 let result = p.parse_content("lowercase=value");
889 assert!(result.is_err());
890 assert!(matches!(result.unwrap_err(), ParseError::InvalidKey { .. }));
891 }
892
893 #[test]
894 fn test_strict_mode_accepts_uppercase() {
895 let p = Parser::new(ParserConfig {
896 strict: true,
897 ..Default::default()
898 });
899 let vars = p.parse_content("UPPER_CASE=value").unwrap();
900 assert_eq!(vars["UPPER_CASE"], "value");
901 }
902
903 // ── Variable expansion ────────────────────────────────────────────────────
904
905 #[test]
906 fn test_expansion_brace_syntax() {
907 let p = Parser::default();
908 let vars = p
909 .parse_content("BASE=http://localhost\nURL=${BASE}/api")
910 .unwrap();
911 assert_eq!(vars["URL"], "http://localhost/api");
912 }
913
914 // #[test]
915 // fn test_expansion_bare_syntax() {
916 // let p = Parser::default();
917 // let vars = p
918 // .parse_content("BASE=http://localhost\nURL=$BASE/api")
919 // .unwrap();
920 // assert_eq!(vars["URL"], "http://localhost/api");
921 // }
922
923 #[test]
924 fn test_expansion_chained() {
925 let p = Parser::default();
926 let content = "BASE=http://localhost\nAPI=${BASE}/api\nFULL=${API}/v1";
927 let vars = p.parse_content(content).unwrap();
928 assert_eq!(vars["FULL"], "http://localhost/api/v1");
929 }
930
931 #[test]
932 fn test_expansion_disabled() {
933 let p = Parser::new(ParserConfig {
934 allow_expansion: false,
935 ..Default::default()
936 });
937 let vars = p.parse_content("KEY=${OTHER}").unwrap();
938 assert_eq!(vars["KEY"], "${OTHER}");
939 }
940
941 #[test]
942 fn test_undefined_brace_var_errors() {
943 let p = Parser::default();
944 let result = p.parse_content("KEY=${UNDEFINED}");
945 assert!(result.is_err());
946 match result.unwrap_err() {
947 ParseError::UndefinedVariable { var, .. } => assert_eq!(var, "UNDEFINED"),
948 e => panic!("expected UndefinedVariable, got {e:?}"),
949 }
950 }
951
952 #[test]
953 fn test_undefined_bare_var_kept_literal() {
954 // Bare $VAR references to undefined variables are kept as-is
955 // (shell variables like $HOME are common in .env files).
956 let p = Parser::default();
957 let vars = p.parse_content("KEY=$UNDEFINED_BARE").unwrap();
958 assert_eq!(vars["KEY"], "$UNDEFINED_BARE");
959 }
960
961 #[test]
962 fn test_circular_expansion_detected() {
963 let p = Parser::default();
964 let result = p.parse_content("A=${B}\nB=${A}");
965 assert!(result.is_err());
966 assert!(matches!(
967 result.unwrap_err(),
968 ParseError::CircularExpansion { .. }
969 ));
970 }
971
972 #[test]
973 fn test_expansion_depth_limit() {
974 let p = Parser::new(ParserConfig {
975 max_expansion_depth: 2,
976 ..Default::default()
977 });
978 // Three levels of nesting exceeds depth 2.
979 let content = "A=base\nB=${A}\nC=${B}\nD=${C}";
980 let result = p.parse_content(content);
981 assert!(result.is_err());
982 assert!(matches!(
983 result.unwrap_err(),
984 ParseError::ExpansionDepthExceeded { .. }
985 ));
986 }
987
988 // ── Real-world integration ────────────────────────────────────────────────
989
990 #[test]
991 fn test_real_world_dotenv() {
992 let p = Parser::default();
993 let content = r#"
994# Database
995DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
996
997# Django settings
998SECRET_KEY="django-insecure-abc123"
999DEBUG=True
1000ALLOWED_HOSTS=localhost,127.0.0.1 # dev only
1001
1002# AWS
1003AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
1004AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
1005AWS_REGION=us-east-1
1006
1007# Computed
1008API_BASE=http://localhost:8000
1009API_V1=${API_BASE}/api/v1
1010
1011# export style
1012export LEGACY_KEY=legacy_value
1013"#;
1014 let vars = p.parse_content(content).unwrap();
1015
1016 assert_eq!(
1017 vars["DATABASE_URL"],
1018 "postgresql://user:pass@localhost:5432/mydb"
1019 );
1020 assert_eq!(vars["SECRET_KEY"], "django-insecure-abc123");
1021 assert_eq!(vars["DEBUG"], "True");
1022 assert_eq!(vars["ALLOWED_HOSTS"], "localhost,127.0.0.1");
1023 assert_eq!(vars["AWS_REGION"], "us-east-1");
1024 assert_eq!(vars["API_V1"], "http://localhost:8000/api/v1");
1025 assert_eq!(vars["LEGACY_KEY"], "legacy_value");
1026 assert_eq!(vars.len(), 10);
1027 }
1028}