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/// Canonicalize with a timeout guard. On Windows, `std::fs::canonicalize` can hang
19/// indefinitely on cloud-synced paths, reparse points, or network drives.
20/// Falls back to the original path if canonicalize doesn't complete within the timeout.
21pub fn safe_canonicalize_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
22    #[cfg(windows)]
23    {
24        let path_owned = path.to_path_buf();
25        let (tx, rx) = std::sync::mpsc::channel();
26        let _ = std::thread::Builder::new()
27            .name("canonicalize-bounded".into())
28            .spawn(move || {
29                let result = safe_canonicalize(&path_owned).unwrap_or_else(|_| path_owned);
30                let _ = tx.send(result);
31            });
32        match rx.recv_timeout(std::time::Duration::from_millis(timeout_ms)) {
33            Ok(canonical) => canonical,
34            Err(_) => {
35                tracing::debug!(
36                    "canonicalize timed out ({}ms) for {}; using original path",
37                    timeout_ms,
38                    path.display()
39                );
40                path.to_path_buf()
41            }
42        }
43    }
44    #[cfg(not(windows))]
45    {
46        let _ = timeout_ms;
47        safe_canonicalize_or_self(path)
48    }
49}
50
51/// Remove the `\\?\` / `//?/` verbatim prefix from a `PathBuf`.
52/// Handles both regular verbatim (`\\?\C:\...`) and UNC verbatim (`\\?\UNC\...`).
53pub fn strip_verbatim(path: PathBuf) -> PathBuf {
54    let s = path.to_string_lossy();
55    if let Some(stripped) = strip_verbatim_str(&s) {
56        PathBuf::from(stripped)
57    } else {
58        path
59    }
60}
61
62/// Remove the `\\?\` / `//?/` verbatim prefix from a path string.
63/// Returns `Some(cleaned)` if a prefix was found, `None` otherwise.
64pub fn strip_verbatim_str(path: &str) -> Option<String> {
65    let normalized = path.replace('\\', "/");
66
67    if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
68        Some(format!("//{rest}"))
69    } else {
70        normalized
71            .strip_prefix("//?/")
72            .map(std::string::ToString::to_string)
73    }
74}
75
76/// Normalize paths from any client format to a consistent OS-native form.
77/// Handles MSYS2/Git Bash (`/c/Users/...` -> `C:/Users/...`), mixed separators,
78/// double slashes, and trailing slashes. Uses forward slashes for consistency.
79pub fn normalize_tool_path(path: &str) -> String {
80    let mut p = match strip_verbatim_str(path) {
81        Some(stripped) => stripped,
82        None => path.to_string(),
83    };
84
85    // MSYS2/Git Bash: /c/Users/... -> C:/Users/...
86    if p.len() >= 3
87        && p.starts_with('/')
88        && p.as_bytes()[1].is_ascii_alphabetic()
89        && p.as_bytes()[2] == b'/'
90    {
91        let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
92        p = format!("{drive}:{}", &p[2..]);
93    }
94
95    p = p.replace('\\', "/");
96
97    // Collapse double slashes (preserve UNC paths starting with //)
98    while p.contains("//") && !p.starts_with("//") {
99        p = p.replace("//", "/");
100    }
101
102    // Remove trailing slash (unless root like "/" or "C:/")
103    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
104        p.pop();
105    }
106
107    p
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn strip_regular_verbatim() {
116        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
117        let result = strip_verbatim(p);
118        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
119    }
120
121    #[test]
122    fn strip_unc_verbatim() {
123        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
124        let result = strip_verbatim(p);
125        assert_eq!(result, PathBuf::from("//server/share/dir"));
126    }
127
128    #[test]
129    fn no_prefix_unchanged() {
130        let p = PathBuf::from("/home/user/project");
131        let result = strip_verbatim(p.clone());
132        assert_eq!(result, p);
133    }
134
135    #[test]
136    fn windows_drive_unchanged() {
137        let p = PathBuf::from("C:/Users/dev");
138        let result = strip_verbatim(p.clone());
139        assert_eq!(result, p);
140    }
141
142    #[test]
143    fn strip_str_regular() {
144        assert_eq!(
145            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
146            Some("E:/code/lean-ctx".to_string())
147        );
148    }
149
150    #[test]
151    fn strip_str_unc() {
152        assert_eq!(
153            strip_verbatim_str(r"\\?\UNC\myserver\data"),
154            Some("//myserver/data".to_string())
155        );
156    }
157
158    #[test]
159    fn strip_str_forward_slash_variant() {
160        assert_eq!(
161            strip_verbatim_str("//?/C:/Users/dev"),
162            Some("C:/Users/dev".to_string())
163        );
164    }
165
166    #[test]
167    fn strip_str_no_prefix() {
168        assert_eq!(strip_verbatim_str("/home/user"), None);
169    }
170
171    #[test]
172    fn safe_canonicalize_or_self_nonexistent() {
173        let p = Path::new("/this/path/should/not/exist/xyzzy");
174        let result = safe_canonicalize_or_self(p);
175        assert_eq!(result, p.to_path_buf());
176    }
177
178    #[test]
179    fn normalize_msys_path_to_native() {
180        assert_eq!(
181            normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
182            "C:/Users/ABC/AppData/lean-ctx"
183        );
184    }
185
186    #[test]
187    fn normalize_msys_uppercase_drive() {
188        assert_eq!(
189            normalize_tool_path("/D/Program Files/lean-ctx.exe"),
190            "D:/Program Files/lean-ctx.exe"
191        );
192    }
193
194    #[test]
195    fn normalize_native_windows_path_unchanged() {
196        assert_eq!(
197            normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
198            "C:/Users/ABC/lean-ctx.exe"
199        );
200    }
201
202    #[test]
203    fn normalize_backslash_windows_path() {
204        assert_eq!(
205            normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
206            "C:/Users/ABC/lean-ctx.exe"
207        );
208    }
209
210    #[test]
211    fn normalize_unix_path_unchanged() {
212        assert_eq!(
213            normalize_tool_path("/usr/local/bin/lean-ctx"),
214            "/usr/local/bin/lean-ctx"
215        );
216    }
217
218    #[test]
219    fn normalize_double_slashes() {
220        assert_eq!(
221            normalize_tool_path("C:/Users//ABC//lean-ctx"),
222            "C:/Users/ABC/lean-ctx"
223        );
224    }
225
226    #[test]
227    fn normalize_trailing_slash_removed() {
228        assert_eq!(normalize_tool_path("/c/Users/ABC/"), "C:/Users/ABC");
229    }
230
231    #[test]
232    fn normalize_root_slash_preserved() {
233        assert_eq!(normalize_tool_path("/"), "/");
234    }
235
236    #[test]
237    fn normalize_drive_root_preserved() {
238        assert_eq!(normalize_tool_path("C:/"), "C:/");
239    }
240
241    #[test]
242    fn normalize_verbatim_with_msys() {
243        assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
244    }
245}