Skip to main content

commons/
fs.rs

1//! Cross-platform filesystem utilities.
2//!
3//! Provides helpers for path resolution, directory creation, and
4//! bidirectional Windows/WSL path translation.
5//!
6//! # Example
7//!
8//! ```rust
9//! use commons::fs::{resolve_path, ensure_dir, to_wsl_path};
10//! use std::path::Path;
11//!
12//! let expanded = resolve_path("~/config.toml");
13//! assert!(!expanded.starts_with("~"));
14//!
15//! let wsl = to_wsl_path(r"C:\Users\Name\file.txt");
16//! assert_eq!(wsl, Path::new("/mnt/c/Users/Name/file.txt"));
17//! ```
18
19use std::io;
20use std::path::{Path, PathBuf};
21
22/// Expand `~` to the current user's home directory.
23///
24/// Only `~/...` (and bare `~`) are expanded. Patterns like `~otheruser/...`
25/// are **not** supported — they require OS-specific user lookups that are
26/// outside this crate's scope — and are returned unchanged.
27///
28/// Falls back to returning the path unchanged if the home directory
29/// cannot be determined.
30#[must_use]
31pub fn resolve_path(path: impl AsRef<Path>) -> PathBuf {
32    let path = path.as_ref();
33    let path_str = path.to_string_lossy();
34
35    if !path_str.starts_with('~') {
36        return path.to_path_buf();
37    }
38
39    // Only expand bare `~` or `~/...` / `~\...`.  Anything else (e.g.
40    // `~otheruser/...`) is returned as-is because we cannot resolve
41    // arbitrary user home directories portably.
42    if path_str.len() > 1 && !path_str[1..].starts_with('/') && !path_str[1..].starts_with('\\') {
43        return path.to_path_buf();
44    }
45
46    let Some(home) = home_dir() else {
47        return path.to_path_buf();
48    };
49
50    if path_str == "~" {
51        return home;
52    }
53
54    // Strip the `~/` or `~\` prefix.
55    let rest = &path_str[2..];
56    home.join(rest)
57}
58
59/// Create a directory and all of its parents if they don't exist.
60///
61/// Equivalent to `std::fs::create_dir_all` but reads more clearly
62/// at call sites.
63///
64/// # Errors
65///
66/// Returns an `io::Error` if directory creation fails (e.g., permission denied).
67pub fn ensure_dir(path: impl AsRef<Path>) -> io::Result<()> {
68    std::fs::create_dir_all(path)
69}
70
71/// Detect whether the current process is running under WSL.
72///
73/// Checks `/proc/version` for the string `microsoft` (case-insensitive),
74/// which is present in both WSL 1 and WSL 2 kernels.
75/// Always returns `false` on non-Linux platforms.
76#[must_use]
77#[cfg(target_os = "linux")]
78pub fn is_wsl() -> bool {
79    std::fs::read_to_string("/proc/version")
80        .map(|v| v.to_ascii_lowercase().contains("microsoft"))
81        .unwrap_or(false)
82}
83
84/// Detect whether the current process is running under WSL.
85///
86/// Always returns `false` on non-Linux platforms.
87#[must_use]
88#[cfg(not(target_os = "linux"))]
89pub fn is_wsl() -> bool {
90    false
91}
92
93/// Convert a Windows-style path to its WSL `/mnt/` equivalent.
94///
95/// Handles drive letters (`C:\...` or `C:/...`) by lowercasing the drive
96/// letter and mapping to `/mnt/<drive>/...`. Forward and backward slashes
97/// in the input are both supported.
98///
99/// Paths that don't match `X:\` or `X:/` patterns are returned unchanged
100/// (with backslashes normalised to forward slashes).
101#[must_use]
102pub fn to_wsl_path(path: impl AsRef<Path>) -> PathBuf {
103    let s = path.as_ref().to_string_lossy();
104
105    // Check for drive-letter pattern: single ASCII alpha followed by :\ or :/
106    let bytes = s.as_bytes();
107    if bytes.len() >= 3
108        && bytes[0].is_ascii_alphabetic()
109        && bytes[1] == b':'
110        && (bytes[2] == b'\\' || bytes[2] == b'/')
111    {
112        let drive = (bytes[0] as char).to_ascii_lowercase();
113        let rest = s[3..].replace('\\', "/");
114        return PathBuf::from(format!("/mnt/{drive}/{rest}"));
115    }
116
117    // Not a Windows path — normalise slashes only
118    PathBuf::from(s.replace('\\', "/"))
119}
120
121/// Convert a WSL `/mnt/` path back to a Windows-style path.
122///
123/// Maps `/mnt/c/Users/...` to `C:\Users\...`. The drive letter is
124/// uppercased and forward slashes are converted to backslashes.
125///
126/// Only single-letter mount points are converted (matching WSL's
127/// standard drive-letter mounts). Multi-letter mounts such as
128/// `/mnt/wslg/` or `/mnt/data/` are left unchanged.
129///
130/// Paths that don't start with `/mnt/<letter>/` are returned unchanged.
131#[must_use]
132pub fn from_wsl_path(path: impl AsRef<Path>) -> PathBuf {
133    let s = path.as_ref().to_string_lossy();
134
135    if s.starts_with("/mnt/") && s.len() >= 7 {
136        let bytes = s.as_bytes();
137        // Require exactly one ASCII letter followed by '/' — this
138        // prevents multi-letter mounts like /mnt/wslg/ from being
139        // misinterpreted as drive letters.
140        if bytes[5].is_ascii_alphabetic() && bytes[6] == b'/' {
141            let drive = (bytes[5] as char).to_ascii_uppercase();
142            let rest = s[7..].replace('/', "\\");
143            return PathBuf::from(format!("{drive}:\\{rest}"));
144        }
145    }
146
147    PathBuf::from(s.into_owned())
148}
149
150/// Get the current user's home directory.
151#[cfg(unix)]
152fn home_dir() -> Option<PathBuf> {
153    std::env::var_os("HOME").map(PathBuf::from)
154}
155
156/// Get the current user's home directory.
157#[cfg(windows)]
158fn home_dir() -> Option<PathBuf> {
159    std::env::var_os("USERPROFILE")
160        .or_else(|| std::env::var_os("HOME"))
161        .map(PathBuf::from)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_resolve_path_tilde() {
170        let home = home_dir().unwrap();
171        assert_eq!(resolve_path("~"), home);
172        assert_eq!(resolve_path("~/config.toml"), home.join("config.toml"));
173        assert_eq!(
174            resolve_path("~/.euxis/config.toml"),
175            home.join(".euxis/config.toml")
176        );
177    }
178
179    #[test]
180    fn test_resolve_path_no_tilde() {
181        let p = Path::new("/usr/local/bin");
182        assert_eq!(resolve_path(p), p.to_path_buf());
183
184        let rel = Path::new("relative/path");
185        assert_eq!(resolve_path(rel), rel.to_path_buf());
186    }
187
188    #[test]
189    fn test_ensure_dir() {
190        let tmp = tempfile::tempdir().unwrap();
191        let nested = tmp.path().join("a").join("b").join("c");
192        assert!(!nested.exists());
193        ensure_dir(&nested).unwrap();
194        assert!(nested.is_dir());
195    }
196
197    #[test]
198    fn test_to_wsl_path_drive_letters() {
199        assert_eq!(
200            to_wsl_path(r"C:\Users\Name\file.txt"),
201            PathBuf::from("/mnt/c/Users/Name/file.txt")
202        );
203        assert_eq!(
204            to_wsl_path(r"D:\Projects\src"),
205            PathBuf::from("/mnt/d/Projects/src")
206        );
207        // Forward-slash variant
208        assert_eq!(
209            to_wsl_path("E:/data/log.txt"),
210            PathBuf::from("/mnt/e/data/log.txt")
211        );
212    }
213
214    #[test]
215    fn test_to_wsl_path_non_windows() {
216        // Unix paths pass through unchanged
217        assert_eq!(
218            to_wsl_path("/usr/local/bin"),
219            PathBuf::from("/usr/local/bin")
220        );
221        assert_eq!(to_wsl_path("relative/path"), PathBuf::from("relative/path"));
222    }
223
224    #[test]
225    fn test_resolve_path_tilde_otheruser() {
226        // ~otheruser should NOT be expanded — returned unchanged
227        let p = Path::new("~otheruser/downloads");
228        assert_eq!(resolve_path(p), p.to_path_buf());
229    }
230
231    #[test]
232    fn test_from_wsl_path_drive_letters() {
233        assert_eq!(
234            from_wsl_path("/mnt/c/Users/Name/file.txt"),
235            PathBuf::from("C:\\Users\\Name\\file.txt")
236        );
237        assert_eq!(
238            from_wsl_path("/mnt/d/Projects/src"),
239            PathBuf::from("D:\\Projects\\src")
240        );
241    }
242
243    #[test]
244    fn test_from_wsl_path_passthrough() {
245        // Non-WSL paths pass through unchanged
246        assert_eq!(
247            from_wsl_path("/usr/local/bin"),
248            PathBuf::from("/usr/local/bin")
249        );
250        // Multi-letter mounts are NOT drive letters
251        assert_eq!(
252            from_wsl_path("/mnt/data/shared"),
253            PathBuf::from("/mnt/data/shared")
254        );
255        assert_eq!(
256            from_wsl_path("/mnt/wslg/x.socket"),
257            PathBuf::from("/mnt/wslg/x.socket")
258        );
259        assert_eq!(
260            from_wsl_path("/mnt/cache/app"),
261            PathBuf::from("/mnt/cache/app")
262        );
263    }
264
265    #[test]
266    fn test_wsl_roundtrip() {
267        let win = r"C:\Users\Name\file.txt";
268        let wsl = to_wsl_path(win);
269        let back = from_wsl_path(&wsl);
270        assert_eq!(back, PathBuf::from(win));
271    }
272
273    #[test]
274    fn test_is_wsl_smoke() {
275        // Just ensure it doesn't panic; actual value depends on runtime.
276        let _ = is_wsl();
277    }
278}