Skip to main content

bnto_core/
dotenv.rs

1// Simple dotenv parser — loads KEY=VALUE pairs from `.env` files.
2//
3// Intentionally minimal (no crate dependency). Handles comments, blank lines,
4// quoted values, and optional `export` prefix. No shell expansion, no
5// multiline values. See strategy/recipe-secrets.md for the design rationale.
6
7use std::collections::HashMap;
8
9/// Parse dotenv file contents into key-value pairs.
10///
11/// Supports: `KEY=VALUE`, `KEY="quoted value"`, `KEY='single quoted'`,
12/// `export KEY=VALUE`, `# comments`, blank lines.
13pub fn parse_dotenv(contents: &str) -> HashMap<String, String> {
14    let mut result = HashMap::new();
15    for line in contents.lines() {
16        let trimmed = line.trim();
17
18        // Skip blanks and comments.
19        if trimmed.is_empty() || trimmed.starts_with('#') {
20            continue;
21        }
22
23        // Strip optional `export ` prefix for shell compatibility.
24        let stripped = trimmed.strip_prefix("export ").unwrap_or(trimmed);
25
26        let Some((key, raw_value)) = stripped.split_once('=') else {
27            continue;
28        };
29
30        let key = key.trim();
31        if key.is_empty() {
32            continue;
33        }
34
35        let value = unquote(raw_value.trim());
36        result.insert(key.to_string(), value);
37    }
38    result
39}
40
41/// Remove surrounding quotes (double or single) from a value string.
42fn unquote(s: &str) -> String {
43    if s.len() >= 2
44        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
45    {
46        return s[1..s.len() - 1].to_string();
47    }
48    s.to_string()
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn basic_key_value() {
57        let env = parse_dotenv("API_KEY=sk-abc123");
58        assert_eq!(env.get("API_KEY").unwrap(), "sk-abc123");
59    }
60
61    #[test]
62    fn multiple_pairs() {
63        let env = parse_dotenv("A=1\nB=2\nC=3");
64        assert_eq!(env.len(), 3);
65        assert_eq!(env["A"], "1");
66        assert_eq!(env["B"], "2");
67        assert_eq!(env["C"], "3");
68    }
69
70    #[test]
71    fn comments_and_blanks_skipped() {
72        let input = "# This is a comment\n\nKEY=value\n  # Another comment\n  \n";
73        let env = parse_dotenv(input);
74        assert_eq!(env.len(), 1);
75        assert_eq!(env["KEY"], "value");
76    }
77
78    #[test]
79    fn double_quoted_value() {
80        let env = parse_dotenv(r#"SECRET="hello world""#);
81        assert_eq!(env["SECRET"], "hello world");
82    }
83
84    #[test]
85    fn single_quoted_value() {
86        let env = parse_dotenv("SECRET='hello world'");
87        assert_eq!(env["SECRET"], "hello world");
88    }
89
90    #[test]
91    fn export_prefix_stripped() {
92        let env = parse_dotenv("export API_KEY=sk-abc123");
93        assert_eq!(env["API_KEY"], "sk-abc123");
94    }
95
96    #[test]
97    fn export_with_quotes() {
98        let env = parse_dotenv(r#"export SECRET="my value""#);
99        assert_eq!(env["SECRET"], "my value");
100    }
101
102    #[test]
103    fn value_with_equals_sign() {
104        let env = parse_dotenv("URL=https://example.com?foo=bar");
105        assert_eq!(env["URL"], "https://example.com?foo=bar");
106    }
107
108    #[test]
109    fn empty_value() {
110        let env = parse_dotenv("EMPTY=");
111        assert_eq!(env["EMPTY"], "");
112    }
113
114    #[test]
115    fn whitespace_around_key_and_value() {
116        let env = parse_dotenv("  KEY  =  value  ");
117        assert_eq!(env["KEY"], "value");
118    }
119
120    #[test]
121    fn line_without_equals_skipped() {
122        let env = parse_dotenv("NOEQUALS\nKEY=value");
123        assert_eq!(env.len(), 1);
124        assert_eq!(env["KEY"], "value");
125    }
126
127    #[test]
128    fn empty_key_skipped() {
129        let env = parse_dotenv("=value");
130        assert!(env.is_empty());
131    }
132
133    #[test]
134    fn empty_input() {
135        let env = parse_dotenv("");
136        assert!(env.is_empty());
137    }
138
139    #[test]
140    fn quoted_empty_value() {
141        let env = parse_dotenv(r#"KEY="""#);
142        assert_eq!(env["KEY"], "");
143    }
144
145    #[test]
146    fn mixed_content() {
147        let input = "\
148# Database config
149DB_HOST=localhost
150DB_PORT=5432
151export DB_PASS=\"s3cret\"
152
153# API keys
154API_KEY='sk-test-123'
155";
156        let env = parse_dotenv(input);
157        assert_eq!(env.len(), 4);
158        assert_eq!(env["DB_HOST"], "localhost");
159        assert_eq!(env["DB_PORT"], "5432");
160        assert_eq!(env["DB_PASS"], "s3cret");
161        assert_eq!(env["API_KEY"], "sk-test-123");
162    }
163}