Skip to main content

toolkit/bootstrap/host/paths/
home_dir.rs

1use std::{
2    env,
3    path::{Path, PathBuf},
4};
5
6/// Errors for resolving the home directory
7#[derive(Debug, thiserror::Error)]
8pub enum HomeDirError {
9    #[error("HOME environment variable is not set")]
10    HomeMissing,
11    #[error("failed to get executable path: {0}")]
12    ExecutablePathError(String),
13    #[error("IO error: {0}")]
14    Io(#[from] std::io::Error),
15}
16
17#[must_use]
18pub fn default_home_dir() -> PathBuf {
19    env::home_dir()
20        .or_else(|| env::current_dir().ok())
21        .unwrap_or_else(env::temp_dir)
22}
23
24/// Expand `~` prefix to user home directory.
25///
26/// Returns the path unchanged if no tilde prefix is present.
27/// On Windows, uses `USERPROFILE` or `HOME` environment variable.
28/// On Unix, uses `HOME` environment variable.
29///
30/// # Errors
31/// Returns `HomeDirError::HomeMissing` if the home directory cannot be determined.
32pub fn expand_tilde(raw: &str) -> Result<PathBuf, HomeDirError> {
33    #[cfg(target_os = "windows")]
34    {
35        if raw.starts_with('~') {
36            let user_home = env::home_dir()
37                .ok_or_else(|| env::var("HOME"))
38                .map_err(|_| HomeDirError::HomeMissing)?;
39            if raw == "~" {
40                Ok(user_home)
41            } else if let Some(rest) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) {
42                Ok(Path::new(&user_home).join(rest))
43            } else {
44                // Patterns like "~username" are not supported; treat as user home + rest
45                let rest = raw.trim_start_matches('~');
46                let rest = rest.trim_start_matches(['/', '\\']);
47                Ok(Path::new(&user_home).join(rest))
48            }
49        } else {
50            Ok(PathBuf::from(raw))
51        }
52    }
53
54    #[cfg(not(target_os = "windows"))]
55    {
56        if let Some(stripped) = raw.strip_prefix("~/") {
57            let home = env::home_dir().ok_or(HomeDirError::HomeMissing)?;
58            Ok(Path::new(&home).join(stripped))
59        } else if raw == "~" {
60            let home = env::home_dir().ok_or(HomeDirError::HomeMissing)?;
61            Ok(home)
62        } else {
63            Ok(PathBuf::from(raw))
64        }
65    }
66}
67
68/// Normalize a path.
69///
70/// Rules:
71/// - `~` prefix: expand to user home directory
72/// - Absolute path: use as-is
73/// - Other: prepend CWD
74///
75/// # Errors
76/// Returns `HomeDirError` if path normalization fails.
77pub fn normalize_path(raw: &str) -> Result<PathBuf, HomeDirError> {
78    // First, expand tilde if present
79    let expanded = expand_tilde(raw)?;
80
81    // If already absolute, return as-is
82    if expanded.is_absolute() {
83        return Ok(expanded);
84    }
85
86    std::path::absolute(raw).map_err(|err| {
87        HomeDirError::ExecutablePathError(format!("path '{raw}' is invalid due to: {err}"))
88    })
89}
90
91#[cfg(test)]
92#[cfg_attr(coverage_nightly, coverage(off))]
93mod tests {
94    use super::*;
95    use tempfile::tempdir;
96
97    // -------------------------
98    // expand_tilde tests
99    // -------------------------
100    #[cfg(not(target_os = "windows"))]
101    #[test]
102    fn expand_tilde_with_path() {
103        let tmp = tempdir().unwrap();
104        let tmp_path = tmp.path().to_str().unwrap();
105
106        temp_env::with_var("HOME", Some(tmp_path), || {
107            let result = super::expand_tilde("~/bin/app").unwrap();
108            assert!(result.is_absolute());
109            assert!(result.ends_with("bin/app"));
110        });
111    }
112
113    #[cfg(not(target_os = "windows"))]
114    #[test]
115    fn expand_tilde_only() {
116        let tmp = tempdir().unwrap();
117        let tmp_path = tmp.path().to_str().unwrap();
118
119        temp_env::with_var("HOME", Some(tmp_path), || {
120            let result = expand_tilde("~").unwrap();
121            assert_eq!(result, tmp.path());
122        });
123    }
124
125    #[test]
126    fn expand_tilde_no_tilde() {
127        let result = expand_tilde("/usr/bin/app").unwrap();
128        assert_eq!(result, PathBuf::from("/usr/bin/app"));
129    }
130
131    #[cfg(target_os = "windows")]
132    #[test]
133    fn windows_expand_tilde_with_path() {
134        let tmp = tempdir().unwrap();
135        let tmp_path = tmp.path().to_str().unwrap();
136
137        temp_env::with_var("USERPROFILE", Some(tmp_path), || {
138            let result = expand_tilde("~/bin/app").unwrap();
139            assert!(result.is_absolute());
140            assert!(result.ends_with("bin\\app") || result.ends_with("bin/app"));
141        });
142    }
143
144    // -------------------------
145    // normalize_executable_path tests
146    // -------------------------
147    #[cfg(not(target_os = "windows"))]
148    #[test]
149    fn normalize_absolute_path() {
150        let result = normalize_path("/usr/bin/myapp").unwrap();
151        assert_eq!(result, PathBuf::from("/usr/bin/myapp"));
152    }
153
154    #[cfg(target_os = "windows")]
155    #[test]
156    fn windows_normalize_absolute_path() {
157        let result = normalize_path("C:\\bin\\myapp.exe").unwrap();
158        assert_eq!(result, PathBuf::from("C:\\bin\\myapp.exe"));
159    }
160
161    #[cfg(not(target_os = "windows"))]
162    #[test]
163    fn normalize_tilde_path() {
164        let tmp = tempdir().unwrap();
165        let tmp_path = tmp.path().to_str().unwrap();
166
167        temp_env::with_var("HOME", Some(tmp_path), || {
168            let result = normalize_path("~/bin/myapp").unwrap();
169            assert!(result.is_absolute());
170            assert!(result.starts_with(tmp_path));
171            assert!(result.ends_with("bin/myapp"));
172        });
173    }
174
175    #[test]
176    fn normalize_filename_only() {
177        let result = normalize_path("myapp.exe").unwrap();
178        let cwd = env::current_dir().unwrap();
179        assert_eq!(result, cwd.join("myapp.exe"));
180    }
181
182    #[test]
183    #[cfg(not(target_os = "windows"))]
184    fn normalize_relative_path_resolves_to_absolute() {
185        let err = normalize_path("./bin/myapp").unwrap();
186        assert!(err.is_absolute());
187        assert!(err.ends_with("bin/myapp"));
188    }
189
190    #[cfg(target_os = "windows")]
191    #[test]
192    fn windows_normalize_relative_path_resolves_to_absolute() {
193        let err = normalize_path(".\\bin\\myapp").unwrap();
194        assert!(err.is_absolute());
195        assert!(err.ends_with("bin\\myapp"));
196    }
197}