command_stream/zx/
dotenv.rs1use std::collections::BTreeMap;
4use std::collections::HashMap;
5use std::path::Path;
6
7use super::error::ZxError;
8
9const QUOTES: [&str; 3] = ["\"", "'", "`"];
10const TRIPLE_QUOTES: [&str; 3] = ["\"\"\"", "'''", "```"];
11
12fn find_closing_quote(text: &str, quote: char) -> Option<usize> {
14 let mut chars = text.char_indices();
15 while let Some((i, c)) = chars.next() {
16 if c == '\\' && quote != '\'' {
17 chars.next();
18 } else if c == quote {
19 return Some(i);
20 }
21 }
22 None
23}
24
25fn strip_inline_comment(value: &str) -> &str {
26 let cut = value
27 .match_indices('#')
28 .map(|(i, _)| i)
29 .find(|&i| i == 0 || value[..i].ends_with(char::is_whitespace));
30 match cut {
32 Some(i) => value[..i].trim(),
33 None => value.trim(),
34 }
35}
36
37fn read_quoted(
39 lines: &[&str],
40 start: usize,
41 first: &str,
42 closer: impl Fn(&str) -> Option<usize>,
43) -> (String, usize) {
44 if let Some(end) = closer(first) {
45 return (first[..end].to_string(), start + 1);
46 }
47 let mut parts: Vec<&str> = if first.is_empty() {
48 vec![]
49 } else {
50 vec![first]
51 };
52 for (i, line) in lines.iter().enumerate().skip(start + 1) {
53 if let Some(end) = closer(line) {
54 let tail = &line[..end];
55 if !tail.trim().is_empty() || parts.is_empty() {
56 parts.push(tail);
57 }
58 return (parts.join("\n"), i + 1);
59 }
60 parts.push(line);
61 }
62 (first.to_string(), start + 1)
63}
64
65fn read_value(lines: &[&str], index: usize, raw: &str) -> (String, usize) {
66 if let Some(triple) = TRIPLE_QUOTES.iter().find(|q| raw.starts_with(*q)) {
67 return read_quoted(lines, index, &raw[3..], |line| line.find(triple));
68 }
69 if let Some(quote) = QUOTES.iter().find(|q| raw.starts_with(*q)) {
70 let q = quote.chars().next().expect("non-empty quote");
71 return read_quoted(lines, index, &raw[1..], |line| find_closing_quote(line, q));
72 }
73 (strip_inline_comment(raw).to_string(), index + 1)
74}
75
76pub fn parse(content: &str) -> BTreeMap<String, String> {
78 let mut result = BTreeMap::new();
79 let lines: Vec<&str> = content
80 .split('\n')
81 .map(|l| l.strip_suffix('\r').unwrap_or(l))
82 .collect();
83 let mut index = 0;
84 while index < lines.len() {
85 let trimmed = lines[index].trim();
86 let line = strip_export(trimmed);
87 let key = match line.find('=') {
88 Some(eq) if eq > 0 => line[..eq].trim(),
89 _ => "",
90 };
91 if line.starts_with('#') || key.is_empty() || key.contains(char::is_whitespace) {
92 index += 1;
93 continue;
94 }
95 let eq = line.find('=').expect("key implies '='");
96 let (value, next) = read_value(&lines, index, line[eq + 1..].trim());
97 result.insert(key.to_string(), value);
98 index = next;
99 }
100 result
101}
102
103fn strip_export(line: &str) -> &str {
104 if let Some(rest) = line.strip_prefix("export") {
105 if rest.starts_with(char::is_whitespace) {
106 return rest.trim_start();
107 }
108 }
109 line
110}
111
112fn quote_value(value: &str) -> String {
113 let needs_quotes = value
114 .chars()
115 .any(|c| c.is_whitespace() || matches!(c, '#' | '\'' | '"' | '`'));
116 if !needs_quotes {
117 return value.to_string();
118 }
119 if !value.contains(['\r', '\n']) {
120 if !value.contains('\'') {
121 return format!("'{value}'");
122 }
123 if !value.contains('\\') {
124 if let Some(q) = QUOTES.iter().find(|q| !value.contains(*q)) {
125 return format!("{q}{value}{q}");
126 }
127 }
128 }
129 let triple = TRIPLE_QUOTES
130 .iter()
131 .find(|q| !value.contains(*q))
132 .unwrap_or(&TRIPLE_QUOTES[0]);
133 format!("{triple}\n{value}\n{triple}")
134}
135
136pub fn stringify<'a, I, K, V>(env: I) -> String
139where
140 I: IntoIterator<Item = (&'a K, &'a V)>,
141 K: AsRef<str> + ?Sized + 'a,
142 V: AsRef<str> + ?Sized + 'a,
143{
144 env.into_iter()
145 .map(|(k, v)| format!("{}={}", k.as_ref(), quote_value(v.as_ref())))
146 .collect::<Vec<_>>()
147 .join("\n")
148}
149
150fn read_file(file: &Path) -> Result<BTreeMap<String, String>, ZxError> {
151 let text = std::fs::read_to_string(file)?;
152 Ok(parse(&text))
153}
154
155pub fn load<P: AsRef<Path>>(files: &[P]) -> Result<BTreeMap<String, String>, ZxError> {
157 let mut env = BTreeMap::new();
158 for file in files {
159 for (k, v) in read_file(file.as_ref())? {
160 env.entry(k).or_insert(v);
161 }
162 }
163 Ok(env)
164}
165
166pub fn load_safe<P: AsRef<Path>>(files: &[P]) -> BTreeMap<String, String> {
168 let mut env = BTreeMap::new();
169 for file in files {
170 if let Ok(parsed) = read_file(file.as_ref()) {
171 for (k, v) in parsed {
172 env.entry(k).or_insert(v);
173 }
174 }
175 }
176 env
177}
178
179pub fn config<P: AsRef<Path>>(files: &[P]) -> HashMap<String, String> {
183 let mut env: HashMap<String, String> = std::env::vars().collect();
184 for (k, v) in load_safe(files) {
185 let taken = if cfg!(windows) {
187 env.keys().any(|e| e.eq_ignore_ascii_case(&k))
188 } else {
189 env.contains_key(&k)
190 };
191 if !taken {
192 env.insert(k, v);
193 }
194 }
195 env
196}