Skip to main content

otto_cli/
envfile.rs

1use regex::Regex;
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5use std::sync::LazyLock;
6
7static KEY_RE: LazyLock<Regex> =
8    LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").expect("valid regex"));
9
10pub fn load(path: &Path) -> Result<HashMap<String, String>, std::io::Error> {
11    let text = fs::read_to_string(path)?;
12    parse(&text).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
13}
14
15pub fn parse(text: &str) -> Result<HashMap<String, String>, String> {
16    let mut out = HashMap::new();
17
18    for (index, raw) in text.lines().enumerate() {
19        let mut line = raw.trim_end_matches('\r').trim().to_string();
20
21        if line.is_empty() || line.starts_with('#') {
22            continue;
23        }
24
25        if let Some(stripped) = line.strip_prefix("export ") {
26            line = stripped.trim().to_string();
27        }
28
29        let Some(cut) = line.find('=') else {
30            return Err(format!("line {}: expected KEY=VALUE", index + 1));
31        };
32
33        if cut == 0 {
34            return Err(format!("line {}: expected KEY=VALUE", index + 1));
35        }
36
37        let key = line[..cut].trim();
38        if !KEY_RE.is_match(key) {
39            return Err(format!("line {}: invalid key {key:?}", index + 1));
40        }
41
42        let value = parse_value(line[cut + 1..].trim())
43            .map_err(|err| format!("line {}: {err}", index + 1))?;
44
45        out.insert(key.to_string(), value);
46    }
47
48    Ok(out)
49}
50
51fn parse_value(value: &str) -> Result<String, String> {
52    if value.is_empty() {
53        return Ok(String::new());
54    }
55
56    if value.starts_with('"') {
57        if !value.ends_with('"') || value.len() == 1 {
58            return Err("unterminated double-quoted value".to_string());
59        }
60
61        let quoted = serde_json::from_str::<String>(value)
62            .map_err(|_| "invalid double-quoted value".to_string())?;
63        return Ok(quoted);
64    }
65
66    if value.starts_with('\'') {
67        if !value.ends_with('\'') || value.len() == 1 {
68            return Err("unterminated single-quoted value".to_string());
69        }
70        return Ok(value[1..value.len() - 1].to_string());
71    }
72
73    let trimmed = if let Some(idx) = value.find(" #") {
74        value[..idx].trim().to_string()
75    } else {
76        value.to_string()
77    };
78
79    Ok(trimmed)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use tempfile::tempdir;
86
87    #[test]
88    fn parse_envfile() {
89        let text = r#"
90# comment
91FOO=bar
92EMPTY=
93export NAME=otto
94SINGLE='hello world'
95DOUBLE="a\\nb"
96RAW=hello # trailing comment
97"#;
98
99        let out = parse(text).expect("parse dotenv");
100        assert_eq!(out.get("FOO"), Some(&"bar".to_string()));
101        assert_eq!(out.get("EMPTY"), Some(&"".to_string()));
102        assert_eq!(out.get("NAME"), Some(&"otto".to_string()));
103        assert_eq!(out.get("SINGLE"), Some(&"hello world".to_string()));
104        assert_eq!(out.get("DOUBLE"), Some(&"a\\nb".to_string()));
105        assert_eq!(out.get("RAW"), Some(&"hello".to_string()));
106    }
107
108    #[test]
109    fn parse_rejects_invalid_line() {
110        assert!(parse("not-valid").is_err());
111    }
112
113    #[test]
114    fn load_missing_file() {
115        let dir = tempdir().expect("tempdir");
116        let path = dir.path().join("missing.env");
117        let err = load(&path).expect_err("expected missing");
118        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
119    }
120}