Skip to main content

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}