1use std::path::{Path, PathBuf};
7
8pub fn resolve_base_dir(env_var_name: &str, config_path: Option<&Path>) -> PathBuf {
33 if let Ok(env_dir) = std::env::var(env_var_name) {
35 let p = PathBuf::from(&env_dir);
36 if p.is_absolute() {
37 log::debug!("base_dir from {env_var_name}: {}", p.display());
38 return p;
39 }
40 if let Ok(cwd) = std::env::current_dir() {
42 let resolved = cwd.join(&p);
43 log::debug!(
44 "base_dir from {env_var_name} (relative → absolute): {}",
45 resolved.display()
46 );
47 return resolved;
48 }
49 }
50
51 if let Some(cfg) = config_path
53 && let Some(parent) = cfg.parent()
54 && !parent.as_os_str().is_empty()
55 {
56 log::debug!("base_dir from config file parent: {}", parent.display());
57 return parent.to_path_buf();
58 }
59
60 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
62 log::debug!("base_dir from CWD fallback: {}", cwd.display());
63 cwd
64}
65
66pub fn resolve_path(base: &Path, path: &str) -> String {
72 if path.is_empty() {
73 return path.to_string();
74 }
75 let p = Path::new(path);
76 if p.is_absolute() {
77 return path.to_string();
78 }
79 base.join(p).to_string_lossy().to_string()
80}
81
82pub fn resolve_opt_path(field: &mut Option<String>, base: &Path, field_name: &str) {
87 if let Some(val) = field
88 && !val.is_empty()
89 {
90 let resolved = resolve_path(base, val);
91 if resolved != *val {
92 log::debug!("resolved {field_name}: {val} → {resolved}");
93 *val = resolved;
94 }
95 }
96}
97
98pub fn flatten_toml_value(value: &toml::Value, prefix: &str, out: &mut Vec<(String, String)>) {
123 match value {
124 toml::Value::Table(table) => {
125 for (key, val) in table {
126 let env_key = format!("{}_{}", prefix, key.to_uppercase().replace('-', "_"));
127 flatten_toml_value(val, &env_key, out);
128 }
129 }
130 toml::Value::Array(arr) => {
131 if let Ok(json) = serde_json::to_string(arr) {
132 out.push((prefix.to_string(), json));
133 }
134 }
135 toml::Value::String(s) => {
136 out.push((prefix.to_string(), s.clone()));
137 }
138 toml::Value::Integer(i) => {
139 out.push((prefix.to_string(), i.to_string()));
140 }
141 toml::Value::Float(f) => {
142 out.push((prefix.to_string(), f.to_string()));
143 }
144 toml::Value::Boolean(b) => {
145 out.push((prefix.to_string(), b.to_string()));
146 }
147 toml::Value::Datetime(dt) => {
148 out.push((prefix.to_string(), dt.to_string()));
149 }
150 }
151}
152
153#[cfg(test)]
158mod tests {
159 use super::*;
160 use std::sync::Mutex;
161
162 static ENV_MUTEX: Mutex<()> = Mutex::new(());
164
165 struct EnvGuard {
166 key: String,
167 prev: Option<String>,
168 }
169
170 impl EnvGuard {
171 fn new(key: &str, value: &str) -> Self {
172 let prev = std::env::var(key).ok();
173 unsafe { std::env::set_var(key, value) };
174 Self {
175 key: key.to_string(),
176 prev,
177 }
178 }
179
180 fn remove(key: &str) -> Self {
181 let prev = std::env::var(key).ok();
182 unsafe { std::env::remove_var(key) };
183 Self {
184 key: key.to_string(),
185 prev,
186 }
187 }
188 }
189
190 impl Drop for EnvGuard {
191 fn drop(&mut self) {
192 if let Some(val) = &self.prev {
193 unsafe { std::env::set_var(&self.key, val) };
194 } else {
195 unsafe { std::env::remove_var(&self.key) };
196 }
197 }
198 }
199
200 #[test]
203 fn test_resolve_base_dir_from_env_var_absolute() {
204 let _lock = ENV_MUTEX.lock().unwrap();
205 let _guard = EnvGuard::new("TEST_BASE_DIR_ABS", "/explicit/base");
206 let base = resolve_base_dir("TEST_BASE_DIR_ABS", None);
207 assert_eq!(base, PathBuf::from("/explicit/base"));
208 }
209
210 #[test]
211 fn test_resolve_base_dir_from_env_var_relative() {
212 let _lock = ENV_MUTEX.lock().unwrap();
213 let _guard = EnvGuard::new("TEST_BASE_DIR_REL", "relative/path");
214 let base = resolve_base_dir("TEST_BASE_DIR_REL", None);
215 let cwd = std::env::current_dir().unwrap();
216 assert_eq!(base, cwd.join("relative/path"));
217 }
218
219 #[test]
220 fn test_resolve_base_dir_from_config_parent() {
221 let _lock = ENV_MUTEX.lock().unwrap();
222 let _guard = EnvGuard::remove("TEST_BASE_DIR_CFG");
223 let config_path = Path::new("/home/user/.config/app/config.toml");
224 let base = resolve_base_dir("TEST_BASE_DIR_CFG", Some(config_path));
225 assert_eq!(base, PathBuf::from("/home/user/.config/app"));
226 }
227
228 #[test]
229 fn test_resolve_base_dir_cwd_fallback() {
230 let _lock = ENV_MUTEX.lock().unwrap();
231 let _guard = EnvGuard::remove("TEST_BASE_DIR_CWD");
232 let base = resolve_base_dir("TEST_BASE_DIR_CWD", None);
233 let cwd = std::env::current_dir().unwrap();
234 assert_eq!(base, cwd);
235 }
236
237 #[test]
240 fn test_resolve_path_empty_passthrough() {
241 let base = Path::new("/base");
242 assert_eq!(resolve_path(base, ""), "");
243 }
244
245 #[test]
246 fn test_resolve_path_absolute_passthrough() {
247 let base = Path::new("/base");
248 assert_eq!(resolve_path(base, "/absolute/path"), "/absolute/path");
249 }
250
251 #[test]
252 fn test_resolve_path_relative_joined() {
253 let base = Path::new("/base");
254 assert_eq!(resolve_path(base, "relative/path"), "/base/relative/path");
255 }
256
257 #[test]
258 fn test_resolve_path_dot_prefix() {
259 let base = Path::new("/base");
260 assert_eq!(resolve_path(base, "./local"), "/base/./local");
261 }
262
263 #[test]
264 fn test_resolve_path_bare_filename() {
265 let base = Path::new("/config");
266 assert_eq!(resolve_path(base, "cert.pem"), "/config/cert.pem");
267 }
268
269 #[test]
272 fn test_resolve_opt_path_none_unchanged() {
273 let mut field: Option<String> = None;
274 resolve_opt_path(&mut field, Path::new("/base"), "test");
275 assert!(field.is_none());
276 }
277
278 #[test]
279 fn test_resolve_opt_path_empty_unchanged() {
280 let mut field = Some(String::new());
281 resolve_opt_path(&mut field, Path::new("/base"), "test");
282 assert_eq!(field, Some(String::new()));
283 }
284
285 #[test]
286 fn test_resolve_opt_path_absolute_unchanged() {
287 let mut field = Some("/absolute/path".to_string());
288 resolve_opt_path(&mut field, Path::new("/base"), "test");
289 assert_eq!(field, Some("/absolute/path".to_string()));
290 }
291
292 #[test]
293 fn test_resolve_opt_path_relative_resolved() {
294 let mut field = Some("relative/file".to_string());
295 resolve_opt_path(&mut field, Path::new("/base"), "test");
296 assert_eq!(field, Some("/base/relative/file".to_string()));
297 }
298
299 #[test]
302 fn test_flatten_toml_value_string() {
303 let val = toml::Value::String("hello".into());
304 let mut out = Vec::new();
305 flatten_toml_value(&val, "APP", &mut out);
306 assert_eq!(out, vec![("APP".to_string(), "hello".to_string())]);
307 }
308
309 #[test]
310 fn test_flatten_toml_value_nested_table() {
311 let val: toml::Value = toml::from_str("[bq]\nproject = \"my-project\"").unwrap();
312 let mut out = Vec::new();
313 flatten_toml_value(&val, "APP", &mut out);
314 assert_eq!(
315 out,
316 vec![("APP_BQ_PROJECT".to_string(), "my-project".to_string())]
317 );
318 }
319
320 #[test]
321 fn test_flatten_toml_value_array() {
322 let val: toml::Value = toml::from_str("items = [\"a\", \"b\"]").unwrap();
323 let mut out = Vec::new();
324 flatten_toml_value(&val, "APP", &mut out);
325 assert_eq!(
326 out,
327 vec![("APP_ITEMS".to_string(), "[\"a\",\"b\"]".to_string())]
328 );
329 }
330
331 #[test]
332 fn test_flatten_toml_value_boolean() {
333 let val: toml::Value = toml::from_str("enabled = true").unwrap();
334 let mut out = Vec::new();
335 flatten_toml_value(&val, "APP", &mut out);
336 assert_eq!(out, vec![("APP_ENABLED".to_string(), "true".to_string())]);
337 }
338
339 #[test]
340 fn test_flatten_toml_value_integer() {
341 let val: toml::Value = toml::from_str("port = 8080").unwrap();
342 let mut out = Vec::new();
343 flatten_toml_value(&val, "APP", &mut out);
344 assert_eq!(out, vec![("APP_PORT".to_string(), "8080".to_string())]);
345 }
346
347 #[test]
348 fn test_flatten_toml_value_hyphen_to_underscore() {
349 let val: toml::Value = toml::from_str("[my-section]\nmy-key = \"value\"").unwrap();
350 let mut out = Vec::new();
351 flatten_toml_value(&val, "APP", &mut out);
352 assert_eq!(
353 out,
354 vec![("APP_MY_SECTION_MY_KEY".to_string(), "value".to_string())]
355 );
356 }
357}