Skip to main content

lean_ctx/core/
pathutil.rs

1use std::path::{Path, PathBuf};
2
3/// Canonicalize a path and strip the Windows verbatim/extended-length prefix (`\\?\`)
4/// that `std::fs::canonicalize` adds on Windows. This prefix breaks many tools and
5/// string-based path comparisons.
6///
7/// On non-Windows platforms this is equivalent to `std::fs::canonicalize`.
8pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
9    let canon = std::fs::canonicalize(path)?;
10    Ok(strip_verbatim(canon))
11}
12
13/// Like `safe_canonicalize` but returns the original path on failure.
14pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
15    safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
16}
17
18/// Remove the `\\?\` / `//?/` verbatim prefix from a `PathBuf`.
19/// Handles both regular verbatim (`\\?\C:\...`) and UNC verbatim (`\\?\UNC\...`).
20pub fn strip_verbatim(path: PathBuf) -> PathBuf {
21    let s = path.to_string_lossy();
22    if let Some(stripped) = strip_verbatim_str(&s) {
23        PathBuf::from(stripped)
24    } else {
25        path
26    }
27}
28
29/// Remove the `\\?\` / `//?/` verbatim prefix from a path string.
30/// Returns `Some(cleaned)` if a prefix was found, `None` otherwise.
31pub fn strip_verbatim_str(path: &str) -> Option<String> {
32    let normalized = path.replace('\\', "/");
33
34    if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
35        Some(format!("//{rest}"))
36    } else {
37        normalized
38            .strip_prefix("//?/")
39            .map(std::string::ToString::to_string)
40    }
41}
42
43/// Normalize paths from any client format to a consistent OS-native form.
44/// Handles MSYS2/Git Bash (`/c/Users/...` -> `C:/Users/...`), mixed separators,
45/// double slashes, and trailing slashes. Uses forward slashes for consistency.
46pub fn normalize_tool_path(path: &str) -> String {
47    let mut p = match strip_verbatim_str(path) {
48        Some(stripped) => stripped,
49        None => path.to_string(),
50    };
51
52    // MSYS2/Git Bash: /c/Users/... -> C:/Users/...
53    if p.len() >= 3
54        && p.starts_with('/')
55        && p.as_bytes()[1].is_ascii_alphabetic()
56        && p.as_bytes()[2] == b'/'
57    {
58        let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
59        p = format!("{drive}:{}", &p[2..]);
60    }
61
62    p = p.replace('\\', "/");
63
64    // Collapse double slashes (preserve UNC paths starting with //)
65    while p.contains("//") && !p.starts_with("//") {
66        p = p.replace("//", "/");
67    }
68
69    // Remove trailing slash (unless root like "/" or "C:/")
70    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
71        p.pop();
72    }
73
74    p
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn strip_regular_verbatim() {
83        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
84        let result = strip_verbatim(p);
85        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
86    }
87
88    #[test]
89    fn strip_unc_verbatim() {
90        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
91        let result = strip_verbatim(p);
92        assert_eq!(result, PathBuf::from("//server/share/dir"));
93    }
94
95    #[test]
96    fn no_prefix_unchanged() {
97        let p = PathBuf::from("/home/user/project");
98        let result = strip_verbatim(p.clone());
99        assert_eq!(result, p);
100    }
101
102    #[test]
103    fn windows_drive_unchanged() {
104        let p = PathBuf::from("C:/Users/dev");
105        let result = strip_verbatim(p.clone());
106        assert_eq!(result, p);
107    }
108
109    #[test]
110    fn strip_str_regular() {
111        assert_eq!(
112            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
113            Some("E:/code/lean-ctx".to_string())
114        );
115    }
116
117    #[test]
118    fn strip_str_unc() {
119        assert_eq!(
120            strip_verbatim_str(r"\\?\UNC\myserver\data"),
121            Some("//myserver/data".to_string())
122        );
123    }
124
125    #[test]
126    fn strip_str_forward_slash_variant() {
127        assert_eq!(
128            strip_verbatim_str("//?/C:/Users/dev"),
129            Some("C:/Users/dev".to_string())
130        );
131    }
132
133    #[test]
134    fn strip_str_no_prefix() {
135        assert_eq!(strip_verbatim_str("/home/user"), None);
136    }
137
138    #[test]
139    fn safe_canonicalize_or_self_nonexistent() {
140        let p = Path::new("/this/path/should/not/exist/xyzzy");
141        let result = safe_canonicalize_or_self(p);
142        assert_eq!(result, p.to_path_buf());
143    }
144}