1use crate::util::{Error, Result};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9pub fn expand_path(path: &str) -> PathBuf {
11 if let Some(rest) = path.strip_prefix("~/")
12 && let Some(home) = dirs::home_dir()
13 {
14 return home.join(rest);
15 }
16 PathBuf::from(path)
17}
18
19pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
29 let content = std::fs::read_to_string(path).map_err(|source| Error::ConfigRead {
30 path: path.to_path_buf(),
31 source,
32 })?;
33
34 let mut env = HashMap::new();
35
36 for (line_num, line) in content.lines().enumerate() {
37 let line = line.trim();
38
39 if line.is_empty() || line.starts_with('#') {
41 continue;
42 }
43
44 let line = line.strip_prefix("export ").unwrap_or(line);
46
47 let Some(eq_pos) = line.find('=') else {
49 tracing::warn!(
50 "{}:{}: Invalid line (no '='): {}",
51 path.display(),
52 line_num + 1,
53 line
54 );
55 continue;
56 };
57
58 let key = line[..eq_pos].trim().to_string();
59 let value_raw = line[eq_pos + 1..].trim();
60
61 let value = parse_env_value(value_raw);
63
64 if !key.is_empty() {
65 env.insert(key, value);
66 }
67 }
68
69 Ok(env)
70}
71
72fn contains_shell_metacharacters(value: &str) -> bool {
74 value
75 .chars()
76 .any(|c| matches!(c, '$' | '`' | '|' | ';' | '&' | '<' | '>' | '\n' | '\r'))
77}
78
79fn parse_env_value(raw: &str) -> String {
81 let raw = raw.trim();
82
83 if raw.starts_with('"') && raw.ends_with('"') && raw.len() >= 2 {
85 let value = raw[1..raw.len() - 1]
86 .replace("\\n", "\n")
87 .replace("\\t", "\t")
88 .replace("\\\"", "\"")
89 .replace("\\\\", "\\");
90
91 if contains_shell_metacharacters(&value) {
92 tracing::warn!("Environment value contains shell metacharacters: {}", raw);
93 }
94
95 return value;
96 }
97
98 if raw.starts_with('\'') && raw.ends_with('\'') && raw.len() >= 2 {
100 let value = raw[1..raw.len() - 1].to_string();
101
102 if contains_shell_metacharacters(&value) {
103 tracing::warn!("Environment value contains shell metacharacters: {}", raw);
104 }
105
106 return value;
107 }
108
109 let value = if let Some(comment_pos) = raw.find(" #") {
111 raw[..comment_pos].trim().to_string()
112 } else {
113 raw.to_string()
114 };
115
116 if contains_shell_metacharacters(&value) {
117 tracing::warn!("Environment value contains shell metacharacters: {}", raw);
118 }
119
120 value
121}
122
123pub fn load_env_files(paths: &[String]) -> HashMap<String, String> {
127 let mut env = HashMap::new();
128
129 for path_str in paths {
130 let path = expand_path(path_str);
131 if !path.exists() {
132 tracing::debug!("Env file not found (skipping): {:?}", path);
133 continue;
134 }
135
136 match parse_env_file(&path) {
137 Ok(file_env) => {
138 tracing::debug!("Loaded {} vars from {:?}", file_env.len(), path);
139 env.extend(file_env);
140 }
141 Err(e) => {
142 tracing::warn!("Failed to parse env file {:?}: {}", path, e);
143 }
144 }
145 }
146
147 env
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn test_parse_env_value_unquoted() {
156 assert_eq!(parse_env_value("hello"), "hello");
157 assert_eq!(parse_env_value(" hello "), "hello");
158 }
159
160 #[test]
161 fn test_parse_env_value_double_quoted() {
162 assert_eq!(parse_env_value(r#""hello world""#), "hello world");
163 assert_eq!(parse_env_value(r#""line1\nline2""#), "line1\nline2");
164 assert_eq!(parse_env_value(r#""tab\there""#), "tab\there");
165 assert_eq!(parse_env_value(r#""escaped\"quote""#), "escaped\"quote");
166 }
167
168 #[test]
169 fn test_parse_env_value_single_quoted() {
170 assert_eq!(parse_env_value("'hello world'"), "hello world");
171 assert_eq!(parse_env_value(r"'no\nescapes'"), r"no\nescapes");
172 }
173
174 #[test]
175 fn test_parse_env_value_inline_comment() {
176 assert_eq!(parse_env_value("value # comment"), "value");
177 }
178
179 #[test]
180 fn test_expand_path() {
181 assert_eq!(expand_path("/usr/bin"), PathBuf::from("/usr/bin"));
183 assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path"));
184
185 if let Some(home) = dirs::home_dir() {
187 assert_eq!(expand_path("~/test"), home.join("test"));
188 assert_eq!(expand_path("~/.config/app"), home.join(".config/app"));
189 }
190 }
191}