1use std::collections::{BTreeMap, HashMap, HashSet};
4
5use thiserror::Error;
6
7use super::platform::Platform;
8
9#[derive(Debug, Error, PartialEq, Eq)]
11pub enum ResolveError {
12 #[error("malformed path expression `{input}`: {reason}")]
14 Malformed {
15 input: String,
17 reason: String,
19 },
20
21 #[error("unknown path variable `${{{name}}}`")]
23 UnknownVar {
24 name: String,
26 },
27
28 #[error("cycle resolving `${{{name}}}`: {chain}")]
30 Cycle {
31 name: String,
33 chain: String,
35 },
36
37 #[error("`${{{name}}}` is only available on {required} (current platform: {current})")]
39 WrongPlatform {
40 name: String,
42 required: Platform,
44 current: Platform,
46 },
47}
48
49#[derive(Debug, Clone)]
54pub struct Resolver {
55 platform: Platform,
56 overrides: BTreeMap<String, String>,
57 env: HashMap<String, String>,
58}
59
60impl Resolver {
61 pub fn new() -> Self {
63 Self {
64 platform: Platform::current(),
65 overrides: BTreeMap::new(),
66 env: std::env::vars().collect(),
67 }
68 }
69
70 pub fn for_platform(platform: Platform) -> Self {
72 Self {
73 platform,
74 overrides: BTreeMap::new(),
75 env: std::env::vars().collect(),
76 }
77 }
78
79 pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
82 self.env = env;
83 self
84 }
85
86 pub fn with_overrides(mut self, overrides: BTreeMap<String, String>) -> Self {
89 self.overrides = overrides;
90 self
91 }
92
93 pub fn resolve(&self, input: &str) -> Result<String, ResolveError> {
95 self.resolve_with_stack(input, &mut Vec::new())
96 }
97
98 pub fn resolve_var(&self, name: &str) -> Result<String, ResolveError> {
101 self.resolve_var_with_stack(name, &mut Vec::new())
102 }
103
104 pub fn known_vars(&self) -> Vec<String> {
108 let mut names: HashSet<String> = self
109 .builtin_names()
110 .iter()
111 .map(|s| (*s).to_string())
112 .collect();
113 names.extend(self.overrides.keys().cloned());
114 let mut out: Vec<String> = names.into_iter().collect();
115 out.sort();
116 out
117 }
118
119 fn builtin_names(&self) -> &'static [&'static str] {
121 match self.platform {
122 Platform::Windows => &[
123 "HOME",
124 "XDG_CONFIG",
125 "XDG_DATA",
126 "XDG_STATE",
127 "XDG_CACHE",
128 "XDG_RUNTIME",
129 "LOCAL_BIN",
130 "DOCUMENTS",
131 "WIN_LOCALAPPDATA",
132 "WIN_APPDATA",
133 ],
134 Platform::Macos => &[
135 "HOME",
136 "XDG_CONFIG",
137 "XDG_DATA",
138 "XDG_STATE",
139 "XDG_CACHE",
140 "XDG_RUNTIME",
141 "LOCAL_BIN",
142 "DOCUMENTS",
143 "MAC_LIBRARY",
144 ],
145 Platform::Linux => &[
146 "HOME",
147 "XDG_CONFIG",
148 "XDG_DATA",
149 "XDG_STATE",
150 "XDG_CACHE",
151 "XDG_RUNTIME",
152 "LOCAL_BIN",
153 "DOCUMENTS",
154 ],
155 }
156 }
157
158 fn resolve_with_stack(
161 &self,
162 input: &str,
163 stack: &mut Vec<String>,
164 ) -> Result<String, ResolveError> {
165 let mut out = String::with_capacity(input.len());
166 let mut rest = input;
167 while let Some(idx) = rest.find("${") {
168 out.push_str(&rest[..idx]);
169 rest = &rest[idx + 2..]; let end = find_matching_brace(rest).ok_or_else(|| ResolveError::Malformed {
173 input: input.into(),
174 reason: "unclosed `${`".into(),
175 })?;
176 let expr = &rest[..end];
177 if expr.is_empty() {
178 return Err(ResolveError::Malformed {
179 input: input.into(),
180 reason: "empty `${}`".into(),
181 });
182 }
183 let resolved = self.resolve_expr(expr, stack)?;
184 out.push_str(&resolved);
185 rest = &rest[end + 1..]; }
187 out.push_str(rest);
188 Ok(out)
189 }
190
191 fn resolve_expr(&self, expr: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
193 if let Some(env_expr) = expr.strip_prefix("env:") {
194 let (name, fallback) = match env_expr.split_once(":-") {
195 Some((n, fb)) => (n, Some(fb)),
196 None => (env_expr, None),
197 };
198 if name.is_empty() {
199 return Err(ResolveError::Malformed {
200 input: format!("${{{expr}}}"),
201 reason: "empty env var name".into(),
202 });
203 }
204 return match self.env.get(name) {
205 Some(v) if !v.is_empty() => Ok(v.clone()),
206 _ => match fallback {
207 Some(fb) => self.resolve_with_stack(fb, stack),
208 None => Ok(String::new()),
209 },
210 };
211 }
212 self.resolve_var_with_stack(expr, stack)
213 }
214
215 fn resolve_var_with_stack(
216 &self,
217 name: &str,
218 stack: &mut Vec<String>,
219 ) -> Result<String, ResolveError> {
220 if stack.iter().any(|n| n == name) {
221 let chain = stack
222 .iter()
223 .chain(std::iter::once(&name.to_string()))
224 .cloned()
225 .collect::<Vec<_>>()
226 .join(" -> ");
227 return Err(ResolveError::Cycle {
228 name: name.into(),
229 chain,
230 });
231 }
232 stack.push(name.into());
233 let result = self.lookup_var(name, stack);
234 stack.pop();
235 result
236 }
237
238 fn lookup_var(&self, name: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
239 if let Some(template) = self.overrides.get(name) {
240 return self.resolve_with_stack(template, stack);
241 }
242 self.builtin_var(name, stack)
243 }
244
245 fn builtin_var(&self, name: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
246 match name {
248 "WIN_LOCALAPPDATA" | "WIN_APPDATA" if self.platform != Platform::Windows => {
249 return Err(ResolveError::WrongPlatform {
250 name: name.into(),
251 required: Platform::Windows,
252 current: self.platform,
253 });
254 }
255 "MAC_LIBRARY" if self.platform != Platform::Macos => {
256 return Err(ResolveError::WrongPlatform {
257 name: name.into(),
258 required: Platform::Macos,
259 current: self.platform,
260 });
261 }
262 _ => {}
263 }
264
265 let home_env_key = match self.platform {
270 Platform::Windows => "USERPROFILE",
271 Platform::Linux | Platform::Macos => "HOME",
272 };
273 let home_from_env = || -> Result<String, ResolveError> {
274 self.env
275 .get(home_env_key)
276 .filter(|v| !v.is_empty())
277 .cloned()
278 .ok_or_else(|| ResolveError::UnknownVar { name: name.into() })
279 };
280 let derived_from_home =
284 |suffix: &str, stack: &mut Vec<String>| -> Result<String, ResolveError> {
285 let h = self.resolve_var_with_stack("HOME", stack)?;
286 Ok(format!("{h}{suffix}"))
287 };
288 let xdg = |env_key: &str,
289 fallback_suffix: &str,
290 stack: &mut Vec<String>|
291 -> Result<String, ResolveError> {
292 if let Some(v) = self.env.get(env_key)
293 && !v.is_empty()
294 {
295 return Ok(v.clone());
296 }
297 derived_from_home(fallback_suffix, stack)
298 };
299
300 match name {
301 "HOME" => home_from_env(),
302 "XDG_CONFIG" => xdg("XDG_CONFIG_HOME", "/.config", stack),
303 "XDG_DATA" => xdg("XDG_DATA_HOME", "/.local/share", stack),
304 "XDG_STATE" => xdg("XDG_STATE_HOME", "/.local/state", stack),
305 "XDG_CACHE" => xdg("XDG_CACHE_HOME", "/.cache", stack),
306 "XDG_RUNTIME" => {
307 if let Some(v) = self.env.get("XDG_RUNTIME_DIR")
308 && !v.is_empty()
309 {
310 return Ok(v.clone());
311 }
312 let fallback_keys = match self.platform {
313 Platform::Windows => ["TEMP", "TMP"].as_slice(),
314 Platform::Linux | Platform::Macos => ["TMPDIR"].as_slice(),
315 };
316 for k in fallback_keys {
317 if let Some(v) = self.env.get(*k)
318 && !v.is_empty()
319 {
320 return Ok(v.clone());
321 }
322 }
323 Ok(match self.platform {
324 Platform::Windows => "C:/Windows/Temp".into(),
325 _ => "/tmp".into(),
326 })
327 }
328 "LOCAL_BIN" => derived_from_home("/.local/bin", stack),
329 "DOCUMENTS" => derived_from_home("/Documents", stack),
330 "WIN_LOCALAPPDATA" => self
331 .env
332 .get("LOCALAPPDATA")
333 .filter(|v| !v.is_empty())
334 .cloned()
335 .ok_or_else(|| ResolveError::UnknownVar { name: name.into() }),
336 "WIN_APPDATA" => self
337 .env
338 .get("APPDATA")
339 .filter(|v| !v.is_empty())
340 .cloned()
341 .ok_or_else(|| ResolveError::UnknownVar { name: name.into() }),
342 "MAC_LIBRARY" => derived_from_home("/Library", stack),
343 _ => Err(ResolveError::UnknownVar { name: name.into() }),
344 }
345 }
346}
347
348impl Default for Resolver {
349 fn default() -> Self {
350 Self::new()
351 }
352}
353
354fn find_matching_brace(s: &str) -> Option<usize> {
358 let bytes = s.as_bytes();
359 let mut depth: usize = 0;
360 let mut i = 0;
361 while i < bytes.len() {
362 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
363 depth += 1;
364 i += 2;
365 continue;
366 }
367 if bytes[i] == b'}' {
368 if depth == 0 {
369 return Some(i);
370 }
371 depth -= 1;
372 }
373 i += 1;
374 }
375 None
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 fn linux(home: &str) -> Resolver {
383 let mut env = HashMap::new();
384 env.insert("HOME".into(), home.into());
385 Resolver::for_platform(Platform::Linux).with_env(env)
386 }
387
388 fn windows(profile: &str) -> Resolver {
389 let mut env = HashMap::new();
390 env.insert("USERPROFILE".into(), profile.into());
391 env.insert("LOCALAPPDATA".into(), format!("{profile}/AppData/Local"));
392 env.insert("APPDATA".into(), format!("{profile}/AppData/Roaming"));
393 Resolver::for_platform(Platform::Windows).with_env(env)
394 }
395
396 fn macos(home: &str) -> Resolver {
397 let mut env = HashMap::new();
398 env.insert("HOME".into(), home.into());
399 Resolver::for_platform(Platform::Macos).with_env(env)
400 }
401
402 #[test]
403 fn home_resolves() {
404 let r = linux("/home/user");
405 assert_eq!(r.resolve("${HOME}/x").unwrap(), "/home/user/x");
406 }
407
408 #[test]
409 fn xdg_falls_back_to_default() {
410 let r = linux("/home/user");
411 assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/home/user/.config");
412 assert_eq!(r.resolve("${XDG_DATA}").unwrap(), "/home/user/.local/share");
413 }
414
415 #[test]
416 fn xdg_env_override_wins() {
417 let mut env = HashMap::new();
418 env.insert("HOME".into(), "/home/user".into());
419 env.insert("XDG_CONFIG_HOME".into(), "/custom/cfg".into());
420 let r = Resolver::for_platform(Platform::Linux).with_env(env);
421 assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/custom/cfg");
422 }
423
424 #[test]
425 fn env_passthrough() {
426 let mut env = HashMap::new();
427 env.insert("HOME".into(), "/h".into());
428 env.insert("EDITOR".into(), "nvim".into());
429 let r = Resolver::for_platform(Platform::Linux).with_env(env);
430 assert_eq!(r.resolve("editor=${env:EDITOR}").unwrap(), "editor=nvim");
431 }
432
433 #[test]
434 fn env_fallback_used_when_unset() {
435 let r = linux("/h");
436 assert_eq!(r.resolve("${env:NOPE:-default}").unwrap(), "default");
437 }
438
439 #[test]
440 fn env_fallback_can_reference_other_vars() {
441 let r = linux("/h");
442 assert_eq!(r.resolve("${env:NOPE:-${HOME}/x}").unwrap(), "/h/x");
443 }
444
445 #[test]
446 fn user_override_shadows_builtin() {
447 let mut overrides = BTreeMap::new();
448 overrides.insert("HOME".into(), "/custom".into());
449 let r = linux("/orig").with_overrides(overrides);
450 assert_eq!(r.resolve("${HOME}/x").unwrap(), "/custom/x");
451 }
452
453 #[test]
454 fn home_override_cascades_into_derived_vars() {
455 let mut overrides = BTreeMap::new();
456 overrides.insert("HOME".into(), "/custom".into());
457 let r = linux("/orig").with_overrides(overrides);
458 assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/custom/.config");
459 assert_eq!(r.resolve("${LOCAL_BIN}").unwrap(), "/custom/.local/bin");
460 assert_eq!(r.resolve("${DOCUMENTS}").unwrap(), "/custom/Documents");
461 }
462
463 #[test]
464 fn override_loop_through_derived_vars_is_caught() {
465 let mut overrides = BTreeMap::new();
466 overrides.insert("HOME".into(), "${XDG_CONFIG}/parent".into());
468 let r = linux("/orig").with_overrides(overrides);
469 assert!(matches!(
470 r.resolve("${HOME}").unwrap_err(),
471 ResolveError::Cycle { .. }
472 ));
473 }
474
475 #[test]
476 fn override_can_reference_other_vars() {
477 let mut overrides = BTreeMap::new();
478 overrides.insert("MYBIN".into(), "${HOME}/bin".into());
479 let r = linux("/h").with_overrides(overrides);
480 assert_eq!(r.resolve("${MYBIN}/x").unwrap(), "/h/bin/x");
481 }
482
483 #[test]
484 fn cycle_is_detected() {
485 let mut overrides = BTreeMap::new();
486 overrides.insert("A".into(), "${B}".into());
487 overrides.insert("B".into(), "${A}".into());
488 let r = linux("/h").with_overrides(overrides);
489 match r.resolve("${A}").unwrap_err() {
490 ResolveError::Cycle { chain, .. } => assert!(chain.contains("A -> B -> A")),
491 other => panic!("expected Cycle, got {other:?}"),
492 }
493 }
494
495 #[test]
496 fn unknown_var_errors() {
497 let r = linux("/h");
498 match r.resolve("${NOPE}").unwrap_err() {
499 ResolveError::UnknownVar { name } => assert_eq!(name, "NOPE"),
500 other => panic!("expected UnknownVar, got {other:?}"),
501 }
502 }
503
504 #[test]
505 fn win_var_errors_on_linux() {
506 let r = linux("/h");
507 let err = r.resolve("${WIN_LOCALAPPDATA}").unwrap_err();
508 assert!(matches!(err, ResolveError::WrongPlatform { .. }));
509 }
510
511 #[test]
512 fn win_var_resolves_on_windows() {
513 let r = windows("C:/Users/u");
514 assert_eq!(
515 r.resolve("${WIN_LOCALAPPDATA}/x").unwrap(),
516 "C:/Users/u/AppData/Local/x"
517 );
518 assert_eq!(
519 r.resolve("${WIN_APPDATA}/x").unwrap(),
520 "C:/Users/u/AppData/Roaming/x"
521 );
522 }
523
524 #[test]
525 fn mac_var_errors_on_linux() {
526 let r = linux("/h");
527 assert!(matches!(
528 r.resolve("${MAC_LIBRARY}").unwrap_err(),
529 ResolveError::WrongPlatform { .. }
530 ));
531 }
532
533 #[test]
534 fn mac_var_resolves_on_macos() {
535 let r = macos("/Users/u");
536 assert_eq!(r.resolve("${MAC_LIBRARY}/x").unwrap(), "/Users/u/Library/x");
537 }
538
539 #[test]
540 fn unclosed_brace_errors() {
541 let r = linux("/h");
542 let err = r.resolve("${HOME").unwrap_err();
543 assert!(matches!(err, ResolveError::Malformed { .. }));
544 }
545
546 #[test]
547 fn empty_braces_errors() {
548 let r = linux("/h");
549 let err = r.resolve("${}").unwrap_err();
550 assert!(matches!(err, ResolveError::Malformed { .. }));
551 }
552
553 #[test]
554 fn no_vars_passthrough() {
555 let r = linux("/h");
556 assert_eq!(r.resolve("/etc/hosts").unwrap(), "/etc/hosts");
557 }
558
559 #[test]
560 fn known_vars_lists_platform_appropriate() {
561 let linux_r = linux("/h");
562 let names = linux_r.known_vars();
563 assert!(names.contains(&"HOME".to_string()));
564 assert!(names.contains(&"XDG_CONFIG".to_string()));
565 assert!(names.contains(&"DOCUMENTS".to_string()));
566 assert!(!names.iter().any(|n| n.starts_with("WIN_")));
567 assert!(!names.iter().any(|n| n.starts_with("MAC_")));
568
569 let win_r = windows("C:/U");
570 let win_names = win_r.known_vars();
571 assert!(win_names.contains(&"WIN_LOCALAPPDATA".to_string()));
572 assert!(win_names.contains(&"WIN_APPDATA".to_string()));
573 assert!(!win_names.iter().any(|n| n.starts_with("MAC_")));
574 }
575
576 #[test]
577 fn xdg_runtime_uses_env_then_tmpdir() {
578 let mut env = HashMap::new();
579 env.insert("HOME".into(), "/h".into());
580 env.insert("XDG_RUNTIME_DIR".into(), "/run/user/1000".into());
581 let r = Resolver::for_platform(Platform::Linux).with_env(env);
582 assert_eq!(r.resolve("${XDG_RUNTIME}").unwrap(), "/run/user/1000");
583
584 let mut env2 = HashMap::new();
585 env2.insert("HOME".into(), "/h".into());
586 env2.insert("TMPDIR".into(), "/var/tmp".into());
587 let r2 = Resolver::for_platform(Platform::Linux).with_env(env2);
588 assert_eq!(r2.resolve("${XDG_RUNTIME}").unwrap(), "/var/tmp");
589 }
590}