Skip to main content

cyril_core/platform/
path.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::Value;
4
5/// Translate an agent-provided path to the native filesystem path.
6/// On Windows (WSL bridge), converts `/mnt/c/...` → `C:\...`.
7/// On Linux (direct), returns the path unchanged.
8pub fn to_native(path: &Path) -> PathBuf {
9    if cfg!(target_os = "windows") {
10        wsl_to_win(&path.to_string_lossy())
11    } else {
12        path.to_path_buf()
13    }
14}
15
16/// Translate a native filesystem path to an agent-compatible path.
17/// On Windows (WSL bridge), converts `C:\...` → `/mnt/c/...`.
18/// On Linux (direct), returns the path unchanged.
19pub fn to_agent(path: &Path) -> PathBuf {
20    if cfg!(target_os = "windows") {
21        win_to_wsl(path)
22    } else {
23        path.to_path_buf()
24    }
25}
26
27/// Direction of path translation.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Direction {
30    WinToWsl,
31    WslToWin,
32}
33
34/// Convert a Windows path to a WSL path.
35///
36/// `C:\Users\foo\bar` → `/mnt/c/Users/foo/bar`
37/// `D:\project` → `/mnt/d/project`
38/// `\\?\C:\Users\foo` → `/mnt/c/Users/foo` (extended-length prefix stripped)
39pub fn win_to_wsl(path: &Path) -> PathBuf {
40    let s = path.to_string_lossy();
41    // Strip the \\?\ extended-length path prefix that canonicalize() produces on Windows.
42    let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
43    // Handle drive letter paths like C:\ or C:/
44    if s.len() >= 2 && s.as_bytes()[1] == b':' {
45        let drive = s.as_bytes()[0].to_ascii_lowercase() as char;
46        let rest = &s[2..];
47        let rest = rest.replace('\\', "/");
48        let rest = rest.trim_start_matches('/');
49        if rest.is_empty() {
50            PathBuf::from(format!("/mnt/{drive}"))
51        } else {
52            PathBuf::from(format!("/mnt/{drive}/{rest}"))
53        }
54    } else {
55        // Already a unix-style path or relative — return as-is with forward slashes
56        PathBuf::from(s.replace('\\', "/"))
57    }
58}
59
60/// Convert a WSL path to a Windows path.
61///
62/// `/mnt/c/Users/foo/bar` → `C:\Users\foo\bar`
63/// `/mnt/d/project` → `D:\project`
64pub fn wsl_to_win(path: &str) -> PathBuf {
65    if let Some(rest) = path.strip_prefix("/mnt/") {
66        if rest.len() >= 1 {
67            let drive = rest.as_bytes()[0].to_ascii_uppercase() as char;
68            let after_drive = &rest[1..];
69            if after_drive.is_empty() || after_drive.starts_with('/') {
70                let suffix = after_drive.strip_prefix('/').unwrap_or("");
71                let win_path = if suffix.is_empty() {
72                    format!("{drive}:\\")
73                } else {
74                    format!("{drive}:\\{}", suffix.replace('/', "\\"))
75                };
76                return PathBuf::from(win_path);
77            }
78        }
79    }
80    // Not a /mnt/ path — return as-is
81    PathBuf::from(path)
82}
83
84/// Recursively translate paths in a JSON value.
85/// Looks for string values that look like paths and translates them.
86pub fn translate_paths_in_json(value: &mut Value, direction: Direction) {
87    match value {
88        Value::String(s) => {
89            let translated = match direction {
90                Direction::WinToWsl => {
91                    if looks_like_windows_path(s) {
92                        win_to_wsl(Path::new(s.as_str()))
93                            .to_string_lossy()
94                            .into_owned()
95                    } else {
96                        return;
97                    }
98                }
99                Direction::WslToWin => {
100                    if looks_like_wsl_mount_path(s) {
101                        wsl_to_win(s).to_string_lossy().into_owned()
102                    } else {
103                        return;
104                    }
105                }
106            };
107            *s = translated;
108        }
109        Value::Array(arr) => {
110            for item in arr {
111                translate_paths_in_json(item, direction);
112            }
113        }
114        Value::Object(map) => {
115            for (_, v) in map.iter_mut() {
116                translate_paths_in_json(v, direction);
117            }
118        }
119        _ => {}
120    }
121}
122
123fn looks_like_windows_path(s: &str) -> bool {
124    // Strip \\?\ extended-length prefix so the drive-letter check below still works.
125    let s = s.strip_prefix(r"\\?\").unwrap_or(s);
126    s.len() >= 3
127        && s.as_bytes()[0].is_ascii_alphabetic()
128        && s.as_bytes()[1] == b':'
129        && (s.as_bytes()[2] == b'\\' || s.as_bytes()[2] == b'/')
130}
131
132fn looks_like_wsl_mount_path(s: &str) -> bool {
133    if let Some(rest) = s.strip_prefix("/mnt/") {
134        rest.len() >= 1 && rest.as_bytes()[0].is_ascii_alphabetic()
135    } else {
136        false
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_win_to_wsl_c_drive() {
146        assert_eq!(
147            win_to_wsl(Path::new(r"C:\Users\foo\bar")),
148            PathBuf::from("/mnt/c/Users/foo/bar")
149        );
150    }
151
152    #[test]
153    fn test_win_to_wsl_d_drive() {
154        assert_eq!(
155            win_to_wsl(Path::new(r"D:\project\src")),
156            PathBuf::from("/mnt/d/project/src")
157        );
158    }
159
160    #[test]
161    fn test_win_to_wsl_root() {
162        assert_eq!(
163            win_to_wsl(Path::new(r"C:\")),
164            PathBuf::from("/mnt/c")
165        );
166    }
167
168    #[test]
169    fn test_win_to_wsl_forward_slashes() {
170        assert_eq!(
171            win_to_wsl(Path::new("C:/Users/foo")),
172            PathBuf::from("/mnt/c/Users/foo")
173        );
174    }
175
176    #[test]
177    fn test_wsl_to_win_basic() {
178        assert_eq!(
179            wsl_to_win("/mnt/c/Users/foo/bar"),
180            PathBuf::from(r"C:\Users\foo\bar")
181        );
182    }
183
184    #[test]
185    fn test_wsl_to_win_d_drive() {
186        assert_eq!(
187            wsl_to_win("/mnt/d/project"),
188            PathBuf::from(r"D:\project")
189        );
190    }
191
192    #[test]
193    fn test_wsl_to_win_root() {
194        assert_eq!(
195            wsl_to_win("/mnt/c"),
196            PathBuf::from(r"C:\")
197        );
198    }
199
200    #[test]
201    fn test_wsl_to_win_non_mount_path() {
202        assert_eq!(
203            wsl_to_win("/home/user/.config"),
204            PathBuf::from("/home/user/.config")
205        );
206    }
207
208    #[test]
209    fn test_roundtrip_win_wsl_win() {
210        let original = r"C:\Users\dwall\repos\project\src\main.rs";
211        let wsl = win_to_wsl(Path::new(original));
212        let back = wsl_to_win(&wsl.to_string_lossy());
213        assert_eq!(back, PathBuf::from(original));
214    }
215
216    #[test]
217    fn test_translate_json_wsl_to_win() {
218        let mut val = serde_json::json!({
219            "path": "/mnt/c/Users/foo/file.txt",
220            "content": "hello world",
221            "nested": {
222                "file": "/mnt/d/project/src/main.rs"
223            }
224        });
225        translate_paths_in_json(&mut val, Direction::WslToWin);
226        assert_eq!(val["path"], r"C:\Users\foo\file.txt");
227        assert_eq!(val["content"], "hello world");
228        assert_eq!(val["nested"]["file"], r"D:\project\src\main.rs");
229    }
230
231    #[test]
232    fn test_translate_json_win_to_wsl() {
233        let mut val = serde_json::json!({
234            "path": r"C:\Users\foo\file.txt",
235            "count": 42
236        });
237        translate_paths_in_json(&mut val, Direction::WinToWsl);
238        assert_eq!(val["path"], "/mnt/c/Users/foo/file.txt");
239        assert_eq!(val["count"], 42);
240    }
241
242    // ── \\?\ extended-length prefix tests ──
243
244    #[test]
245    fn test_win_to_wsl_strips_extended_prefix() {
246        assert_eq!(
247            win_to_wsl(Path::new(r"\\?\C:\Users\foo\bar")),
248            PathBuf::from("/mnt/c/Users/foo/bar")
249        );
250    }
251
252    #[test]
253    fn test_win_to_wsl_strips_extended_prefix_d_drive() {
254        assert_eq!(
255            win_to_wsl(Path::new(r"\\?\D:\project\src")),
256            PathBuf::from("/mnt/d/project/src")
257        );
258    }
259
260    #[test]
261    fn test_win_to_wsl_extended_prefix_root() {
262        assert_eq!(
263            win_to_wsl(Path::new(r"\\?\C:\")),
264            PathBuf::from("/mnt/c")
265        );
266    }
267
268    #[test]
269    fn test_roundtrip_extended_prefix() {
270        let original = r"\\?\C:\Users\dwall\repos\project\src\main.rs";
271        let wsl = win_to_wsl(Path::new(original));
272        assert_eq!(wsl, PathBuf::from("/mnt/c/Users/dwall/repos/project/src/main.rs"));
273        let back = wsl_to_win(&wsl.to_string_lossy());
274        // Roundtrip produces the canonical form without \\?\ prefix
275        assert_eq!(back, PathBuf::from(r"C:\Users\dwall\repos\project\src\main.rs"));
276    }
277
278    #[test]
279    fn test_translate_json_extended_prefix() {
280        let mut val = serde_json::json!({
281            "path": r"\\?\C:\Users\foo\file.txt",
282            "normal": r"D:\project\src\main.rs"
283        });
284        translate_paths_in_json(&mut val, Direction::WinToWsl);
285        assert_eq!(val["path"], "/mnt/c/Users/foo/file.txt");
286        assert_eq!(val["normal"], "/mnt/d/project/src/main.rs");
287    }
288
289    #[test]
290    fn test_unc_path_not_mangled() {
291        // UNC paths (\\server\share) should pass through without prefix stripping
292        let result = win_to_wsl(Path::new(r"\\server\share\file.txt"));
293        assert_eq!(result, PathBuf::from("//server/share/file.txt"));
294    }
295
296    #[test]
297    fn test_translate_json_unc_path_not_translated() {
298        let mut val = serde_json::json!({
299            "path": r"\\?\UNC\server\share\file.txt"
300        });
301        translate_paths_in_json(&mut val, Direction::WinToWsl);
302        // \\?\UNC\... after prefix stripping becomes UNC\server\share\file.txt
303        // which doesn't match drive-letter pattern, so it should not be translated
304        assert_eq!(val["path"], r"\\?\UNC\server\share\file.txt");
305    }
306}