Skip to main content

envx_secure/
parser.rs

1//! Shared `.env` file parser.
2//!
3//! Parses a `.env` file into an [`IndexMap`] that preserves insertion order.
4//! Both single-quoted and double-quoted values are unquoted automatically.
5//! Blank lines and lines beginning with `#` are skipped.
6//!
7//! [`IndexMap`]: indexmap::IndexMap
8
9use anyhow::{bail, Context, Result};
10use indexmap::IndexMap;
11use std::path::Path;
12
13/// Parse a `.env` file at `path` into an ordered key→value map.
14///
15/// # Errors
16///
17/// Returns an error if:
18/// - the file cannot be read,
19/// - a non-blank, non-comment line has no `=` separator, or
20/// - a key is empty.
21///
22/// # Example
23///
24/// Given the file:
25/// ```text
26/// # comment
27/// DATABASE_URL=postgres://localhost/mydb
28/// SECRET_KEY="s3cr3t"
29/// ```
30///
31/// ```no_run
32/// use std::path::Path;
33/// use envx_secure::parser;
34///
35/// let map = parser::parse(Path::new(".env")).unwrap();
36/// assert_eq!(map["DATABASE_URL"], "postgres://localhost/mydb");
37/// assert_eq!(map["SECRET_KEY"], "s3cr3t");
38/// ```
39pub fn parse(path: &Path) -> Result<IndexMap<String, String>> {
40    let content = std::fs::read_to_string(path)
41        .with_context(|| format!("failed to read {}", path.display()))?;
42
43    let mut map = IndexMap::new();
44
45    for (lineno, line) in content.lines().enumerate() {
46        let line = line.trim();
47
48        if line.is_empty() || line.starts_with('#') {
49            continue;
50        }
51
52        let eq = line.find('=').with_context(|| {
53            format!(
54                "{}:{}: malformed line (expected KEY=VALUE): {:?}",
55                path.display(),
56                lineno + 1,
57                line
58            )
59        })?;
60
61        let key = line[..eq].trim().to_string();
62        let raw_val = line[eq + 1..].trim();
63
64        if key.is_empty() {
65            bail!(
66                "{}:{}: empty key in line: {:?}",
67                path.display(),
68                lineno + 1,
69                line
70            );
71        }
72
73        let value = strip_quotes(raw_val);
74        map.insert(key, value.to_string());
75    }
76
77    Ok(map)
78}
79
80/// Strip a single layer of matching `"…"` or `'…'` quotes from `s`.
81fn strip_quotes(s: &str) -> &str {
82    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
83        &s[1..s.len() - 1]
84    } else {
85        s
86    }
87}