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}