Skip to main content

rusty_stack/platform/
path_bridge.rs

1//! Windows ↔ WSL2 path translation bridge.
2//!
3//! Provides bidirectional path translation between Windows and WSL2 path
4//! formats, handling drive letters, UNC paths, Unicode, and spaces.
5//!
6//! # Path Translation Rules
7//!
8//! **Windows → WSL2:**
9//! - `C:\Users\alice` → `/mnt/c/Users/alice`
10//! - `D:\data\file.txt` → `/mnt/d/data/file.txt`
11//! - `\\wsl$\Ubuntu\home\alice` → `/home/alice` (UNC WSL path)
12//!
13//! **WSL2 → Windows:**
14//! - `/home/alice` → `\\wsl$\Ubuntu\home\alice`
15//! - `/mnt/c/Users` → `C:\Users`
16//!
17//! # Unicode and Spaces
18//!
19//! All translations preserve Unicode characters and handle spaces correctly.
20//! Round-trip translation produces the original path for supported formats.
21
22use serde::{Deserialize, Serialize};
23
24/// Default WSL distribution name used for WSL2 → Windows translation.
25pub const DEFAULT_WSL_DISTRO: &str = "Ubuntu";
26
27// ===========================================================================
28// Public API
29// ===========================================================================
30
31/// Translate a Windows-style path to a WSL2 Linux path.
32///
33/// # Rules
34///
35/// - Drive letter paths: `C:\path` → `/mnt/c/path`
36/// - Drive letter only: `C:` → `/mnt/c`
37/// - UNC WSL paths: `\\wsl$\Distro\path` → `/path`
38/// - Forward slashes are normalized to forward slashes
39/// - Drive letter is lowercased
40///
41/// # Examples
42///
43/// ```
44/// use rusty_stack::platform::path_bridge::windows_to_wsl;
45///
46/// assert_eq!(windows_to_wsl(r"C:\Users\alice\mlstack"), "/mnt/c/Users/alice/mlstack");
47/// assert_eq!(windows_to_wsl(r"\\wsl$\Ubuntu\home\alice\.mlstack"), "/home/alice/.mlstack");
48/// ```
49pub fn windows_to_wsl(path: &str) -> String {
50    let path = path.trim();
51
52    // Handle UNC WSL paths: \\wsl$\Distro\path → /path
53    if let Some(rest) = try_strip_unc_wsl_prefix(path) {
54        // Normalize backslashes to forward slashes and ensure leading /
55        let posix = normalize_to_posix(&rest);
56        if posix.is_empty() {
57            return "/".to_string();
58        }
59        if posix.starts_with('/') {
60            return posix;
61        }
62        return format!("/{}", posix);
63    }
64
65    // Handle drive letter paths: C:\path → /mnt/c/path
66    if let Some(result) = try_drive_letter_to_wsl(path) {
67        return result;
68    }
69
70    // Fallback: normalize slashes
71    normalize_to_posix(path)
72}
73
74/// Translate a WSL2 Linux path to a Windows-style path.
75///
76/// # Rules
77///
78/// - `/mnt/X/...` → `X:\...` (drive mount paths)
79/// - `/home/user` → `\\wsl$\Distro\home\user` (native Linux paths)
80///
81/// # Examples
82///
83/// ```
84/// use rusty_stack::platform::path_bridge::wsl_to_windows;
85///
86/// assert_eq!(wsl_to_windows("/mnt/c/Users/alice"), r"C:\Users\alice");
87/// assert_eq!(wsl_to_windows("/home/alice/.mlstack"), r"\\wsl$\Ubuntu\home\alice\.mlstack");
88/// ```
89pub fn wsl_to_windows(path: &str) -> String {
90    wsl_to_windows_with_distro(path, DEFAULT_WSL_DISTRO)
91}
92
93/// Translate a WSL2 Linux path to a Windows path with a specified distro name.
94///
95/// Same as [`wsl_to_windows`] but allows customizing the WSL distribution name.
96pub fn wsl_to_windows_with_distro(path: &str, distro: &str) -> String {
97    let path = path.trim();
98
99    // Handle /mnt/X/... paths → X:\...
100    if let Some(result) = try_mnt_to_windows(path) {
101        return result;
102    }
103
104    // All other paths → \\wsl$\Distro\path
105    format!("\\\\wsl$\\{}{}", distro, normalize_to_windows(path))
106}
107
108/// Result of a path translation attempt.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct PathTranslation {
111    /// The translated path.
112    pub translated: String,
113    /// The direction of translation.
114    pub direction: TranslationDirection,
115}
116
117/// Direction of path translation.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119pub enum TranslationDirection {
120    /// Windows path → WSL2/Linux path.
121    WindowsToWsl,
122    /// WSL2/Linux path → Windows path.
123    WslToWindows,
124}
125
126/// Translate a path auto-detecting direction.
127///
128/// If the path starts with a drive letter (e.g., `C:\`) or a UNC prefix
129/// (`\\wsl$`), it is treated as a Windows path. Otherwise, it is treated
130/// as a Linux path.
131pub fn translate_path(path: &str) -> PathTranslation {
132    let path = path.trim();
133
134    if is_windows_path(path) {
135        PathTranslation {
136            translated: windows_to_wsl(path),
137            direction: TranslationDirection::WindowsToWsl,
138        }
139    } else {
140        PathTranslation {
141            translated: wsl_to_windows(path),
142            direction: TranslationDirection::WslToWindows,
143        }
144    }
145}
146
147/// Check if a path looks like a Windows path.
148///
149/// Returns true for:
150/// - Drive letter paths: `C:\...`
151/// - UNC paths: `\\wsl$\...`, `\\server\share\...`
152pub fn is_windows_path(path: &str) -> bool {
153    let path = path.trim();
154
155    // Drive letter: C:\ or C:/ (single char + colon + separator)
156    if path.len() >= 3 {
157        let bytes = path.as_bytes();
158        if bytes[0].is_ascii_alphabetic()
159            && bytes[1] == b':'
160            && (bytes[2] == b'\\' || bytes[2] == b'/')
161        {
162            return true;
163        }
164    }
165
166    // UNC: \\ prefix
167    path.starts_with(r"\\") || path.starts_with("//")
168}
169
170// ===========================================================================
171// Private Helpers
172// ===========================================================================
173
174/// Try to strip a UNC WSL prefix from a path.
175///
176/// Handles: `\\wsl$\Distro\path`, `\\wsl.localhost\Distro\path`
177/// Returns the remaining path after the distro name if matched.
178fn try_strip_unc_wsl_prefix(path: &str) -> Option<String> {
179    // Normalize forward slashes to backslashes for matching
180    let normalized = path.replace('/', r"\");
181
182    // Try \\wsl$\Distro\ prefix
183    if let Some(rest) = normalized.to_lowercase().strip_prefix(r"\\wsl$\") {
184        // Find the next backslash (end of distro name)
185        if let Some(slash_pos) = rest.find('\\') {
186            let distro = &rest[..slash_pos];
187            let remaining = &normalized[r"\\wsl$\".len() + distro.len()..];
188            return Some(remaining.to_string());
189        }
190        // Path is just \\wsl$\Distro (no trailing path)
191        return Some(String::new());
192    }
193
194    // Try \\wsl.localhost\Distro\ prefix
195    if let Some(rest) = normalized.to_lowercase().strip_prefix(r"\\wsl.localhost\") {
196        if let Some(slash_pos) = rest.find('\\') {
197            let distro = &rest[..slash_pos];
198            let remaining = &normalized[r"\\wsl.localhost\".len() + distro.len()..];
199            return Some(remaining.to_string());
200        }
201        return Some(String::new());
202    }
203
204    None
205}
206
207/// Try to convert a drive-letter Windows path to WSL format.
208///
209/// `C:\path` → `/mnt/c/path`
210/// `C:` → `/mnt/c`
211fn try_drive_letter_to_wsl(path: &str) -> Option<String> {
212    let path = path.trim();
213
214    if path.len() < 2 {
215        return None;
216    }
217
218    let bytes = path.as_bytes();
219    if !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' {
220        return None;
221    }
222
223    let drive = bytes[0].to_ascii_lowercase();
224
225    if path.len() == 2 {
226        // Just "C:" → "/mnt/c"
227        return Some(format!("/mnt/{}", drive as char));
228    }
229
230    let rest = &path[2..]; // After "C:"
231    let rest = normalize_to_posix(rest);
232
233    Some(format!("/mnt/{}{}", drive as char, rest))
234}
235
236/// Try to convert a /mnt/X/... path to a Windows drive-letter path.
237///
238/// `/mnt/c/Users` → `C:\Users`
239fn try_mnt_to_windows(path: &str) -> Option<String> {
240    let path = path.trim();
241
242    if !path.starts_with("/mnt/") {
243        return None;
244    }
245
246    let rest = &path[5..]; // After "/mnt/"
247
248    // Extract drive letter (first character after /mnt/)
249    let drive = rest.chars().next()?;
250    if !drive.is_ascii_alphabetic() {
251        return None;
252    }
253
254    let after_drive = &rest[drive.len_utf8()..];
255
256    // Drive letter must be followed by / or end of string
257    if after_drive.is_empty() {
258        return Some(format!("{}:\\", drive.to_ascii_uppercase()));
259    }
260
261    if !after_drive.starts_with('/') {
262        return None;
263    }
264
265    let windows_rest = normalize_to_windows(after_drive);
266    Some(format!("{}:{}", drive.to_ascii_uppercase(), windows_rest))
267}
268
269/// Normalize path separators to POSIX (forward slash).
270fn normalize_to_posix(path: &str) -> String {
271    let result = path.replace('\\', "/");
272
273    // Collapse multiple consecutive slashes
274    let mut collapsed = String::with_capacity(result.len());
275    let mut last_was_slash = false;
276    for ch in result.chars() {
277        if ch == '/' {
278            if !last_was_slash {
279                collapsed.push('/');
280            }
281            last_was_slash = true;
282        } else {
283            collapsed.push(ch);
284            last_was_slash = false;
285        }
286    }
287
288    collapsed
289}
290
291/// Normalize path separators to Windows (backslash).
292fn normalize_to_windows(path: &str) -> String {
293    path.replace('/', r"\")
294}
295
296// ===========================================================================
297// Tests
298// ===========================================================================
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    // ================================================================
305    // VAL-WIN-012: Windows → WSL2 Translation
306    // ================================================================
307
308    #[test]
309    fn test_windows_to_wsl_basic_drive() {
310        assert_eq!(
311            windows_to_wsl(r"C:\Users\alice\mlstack"),
312            "/mnt/c/Users/alice/mlstack"
313        );
314    }
315
316    #[test]
317    fn test_windows_to_wsl_drive_d() {
318        assert_eq!(windows_to_wsl(r"D:\data\file.txt"), "/mnt/d/data/file.txt");
319    }
320
321    #[test]
322    fn test_windows_to_wsl_root_drive() {
323        assert_eq!(windows_to_wsl(r"C:\"), "/mnt/c/");
324    }
325
326    #[test]
327    fn test_windows_to_wsl_drive_only() {
328        assert_eq!(windows_to_wsl(r"C:"), "/mnt/c");
329    }
330
331    #[test]
332    fn test_windows_to_wsl_forward_slashes() {
333        // Windows paths sometimes use forward slashes
334        assert_eq!(
335            windows_to_wsl("C:/Users/alice/mlstack"),
336            "/mnt/c/Users/alice/mlstack"
337        );
338    }
339
340    #[test]
341    fn test_windows_to_wsl_unc_wsl_path() {
342        assert_eq!(
343            windows_to_wsl(r"\\wsl$\Ubuntu\home\alice\.mlstack"),
344            "/home/alice/.mlstack"
345        );
346    }
347
348    #[test]
349    fn test_windows_to_wsl_unc_wsl_path_case_insensitive() {
350        // UNC prefix is case-insensitive
351        let result = windows_to_wsl(r"\\WSL$\ubuntu\home\test");
352        assert!(result.contains("home"), "Should extract path after distro");
353    }
354
355    #[test]
356    fn test_windows_to_wsl_unc_wsl_localhost() {
357        let result = windows_to_wsl(r"\\wsl.localhost\Ubuntu\home\alice");
358        assert!(
359            result.contains("home"),
360            "Should handle wsl.localhost UNC prefix"
361        );
362    }
363
364    #[test]
365    fn test_windows_to_wsl_unc_wsl_distro_only() {
366        // Path is just \\wsl$\Distro with no trailing path → root
367        let result = windows_to_wsl(r"\\wsl$\Ubuntu");
368        assert_eq!(result, "/");
369    }
370
371    // ================================================================
372    // VAL-WIN-013: WSL2 → Windows Translation
373    // ================================================================
374
375    #[test]
376    fn test_wsl_to_windows_mnt_path() {
377        assert_eq!(wsl_to_windows("/mnt/c/Users/alice"), r"C:\Users\alice");
378    }
379
380    #[test]
381    fn test_wsl_to_windows_mnt_d() {
382        assert_eq!(wsl_to_windows("/mnt/d/data/file.txt"), r"D:\data\file.txt");
383    }
384
385    #[test]
386    fn test_wsl_to_windows_home_path() {
387        assert_eq!(
388            wsl_to_windows("/home/alice/.mlstack"),
389            r"\\wsl$\Ubuntu\home\alice\.mlstack"
390        );
391    }
392
393    #[test]
394    fn test_wsl_to_windows_custom_distro() {
395        assert_eq!(
396            wsl_to_windows_with_distro("/home/alice/.mlstack", "Debian"),
397            r"\\wsl$\Debian\home\alice\.mlstack"
398        );
399    }
400
401    #[test]
402    fn test_wsl_to_windows_mnt_root() {
403        assert_eq!(wsl_to_windows("/mnt/c/"), r"C:\");
404    }
405
406    #[test]
407    fn test_wsl_to_windows_mnt_drive_only() {
408        assert_eq!(wsl_to_windows("/mnt/c"), r"C:\");
409    }
410
411    // ================================================================
412    // VAL-WIN-014: Unicode and Spaces
413    // ================================================================
414
415    #[test]
416    fn test_windows_to_wsl_spaces() {
417        assert_eq!(
418            windows_to_wsl(r"C:\Program Files\ML Stack"),
419            "/mnt/c/Program Files/ML Stack"
420        );
421    }
422
423    #[test]
424    fn test_wsl_to_windows_spaces() {
425        assert_eq!(
426            wsl_to_windows("/mnt/c/Program Files/ML Stack"),
427            r"C:\Program Files\ML Stack"
428        );
429    }
430
431    #[test]
432    fn test_windows_to_wsl_unicode() {
433        assert_eq!(
434            windows_to_wsl(r"C:\Users\日本語\ドキュメント"),
435            "/mnt/c/Users/日本語/ドキュメント"
436        );
437    }
438
439    #[test]
440    fn test_wsl_to_windows_unicode() {
441        assert_eq!(
442            wsl_to_windows("/mnt/c/Users/日本語/ドキュメント"),
443            r"C:\Users\日本語\ドキュメント"
444        );
445    }
446
447    #[test]
448    fn test_unicode_roundtrip_windows_to_wsl_and_back() {
449        let original = r"C:\Users\alice\文档\ml-stack";
450        let wsl = windows_to_wsl(original);
451        let back = wsl_to_windows(&wsl);
452        assert_eq!(original, &back);
453    }
454
455    #[test]
456    fn test_spaces_roundtrip_windows_to_wsl_and_back() {
457        let original = r"C:\Users\My User\ML Stack Data";
458        let wsl = windows_to_wsl(original);
459        let back = wsl_to_windows(&wsl);
460        assert_eq!(original, &back);
461    }
462
463    #[test]
464    fn test_unicode_home_to_windows() {
465        let path = "/home/alice/文档/数据";
466        let win = wsl_to_windows(path);
467        assert_eq!(win, r"\\wsl$\Ubuntu\home\alice\文档\数据");
468    }
469
470    #[test]
471    fn test_unicode_home_roundtrip() {
472        let original = "/home/alice/文档/数据";
473        let win = wsl_to_windows(original);
474        assert_eq!(win, r"\\wsl$\Ubuntu\home\alice\文档\数据");
475        let back = windows_to_wsl(&win);
476        // UNC path → extracts path after distro, normalizes to POSIX
477        assert_eq!(back, "/home/alice/文档/数据");
478    }
479
480    // ================================================================
481    // Path Detection
482    // ================================================================
483
484    #[test]
485    fn test_is_windows_path_drive_letter() {
486        assert!(is_windows_path(r"C:\Users"));
487        assert!(is_windows_path("D:/data"));
488    }
489
490    #[test]
491    fn test_is_windows_path_unc() {
492        assert!(is_windows_path(r"\\wsl$\Ubuntu\home"));
493        assert!(is_windows_path("//server/share"));
494    }
495
496    #[test]
497    fn test_is_not_windows_path() {
498        assert!(!is_windows_path("/home/alice"));
499        assert!(!is_windows_path("/mnt/c/Users"));
500        assert!(!is_windows_path("relative/path"));
501    }
502
503    // ================================================================
504    // Auto-detection Translation
505    // ================================================================
506
507    #[test]
508    fn test_translate_path_auto_detect_windows() {
509        let result = translate_path(r"C:\Users\alice");
510        assert_eq!(result.direction, TranslationDirection::WindowsToWsl);
511        assert_eq!(result.translated, "/mnt/c/Users/alice");
512    }
513
514    #[test]
515    fn test_translate_path_auto_detect_linux() {
516        let result = translate_path("/home/alice");
517        assert_eq!(result.direction, TranslationDirection::WslToWindows);
518        assert!(result.translated.contains("alice"));
519    }
520
521    // ================================================================
522    // Serde Roundtrips
523    // ================================================================
524
525    #[test]
526    fn test_path_translation_serde_roundtrip() {
527        let t = PathTranslation {
528            translated: "/mnt/c/Users".to_string(),
529            direction: TranslationDirection::WindowsToWsl,
530        };
531        let json = serde_json::to_string(&t).unwrap();
532        let back: PathTranslation = serde_json::from_str(&json).unwrap();
533        assert_eq!(t, back);
534    }
535
536    #[test]
537    fn test_translation_direction_serde_roundtrip() {
538        for dir in [
539            TranslationDirection::WindowsToWsl,
540            TranslationDirection::WslToWindows,
541        ] {
542            let json = serde_json::to_string(&dir).unwrap();
543            let back: TranslationDirection = serde_json::from_str(&json).unwrap();
544            assert_eq!(dir, back);
545        }
546    }
547
548    // ================================================================
549    // Edge Cases
550    // ================================================================
551
552    #[test]
553    fn test_empty_path() {
554        assert_eq!(windows_to_wsl(""), "");
555        assert_eq!(wsl_to_windows(""), r"\\wsl$\Ubuntu");
556    }
557
558    #[test]
559    fn test_whitespace_trimming() {
560        assert_eq!(windows_to_wsl(r"  C:\Users\alice  "), "/mnt/c/Users/alice");
561        assert_eq!(
562            wsl_to_windows("  /home/alice  "),
563            r"\\wsl$\Ubuntu\home\alice"
564        );
565    }
566
567    #[test]
568    fn test_multiple_drives() {
569        assert_eq!(windows_to_wsl(r"E:\test"), "/mnt/e/test");
570        assert_eq!(windows_to_wsl(r"Z:\foo\bar"), "/mnt/z/foo/bar");
571    }
572
573    #[test]
574    fn test_mnt_uppercase_drive() {
575        // /mnt/C should also work (case-insensitive drive)
576        // Our implementation lowercases for mnt matching
577        let result = wsl_to_windows("/mnt/C/Users");
578        assert_eq!(result, r"C:\Users");
579    }
580
581    #[test]
582    fn test_deeply_nested_path() {
583        let path = r"C:\Users\alice\Documents\Projects\ML Stack\models\v1\checkpoint.bin";
584        let wsl = windows_to_wsl(path);
585        assert_eq!(
586            wsl,
587            "/mnt/c/Users/alice/Documents/Projects/ML Stack/models/v1/checkpoint.bin"
588        );
589        let back = wsl_to_windows(&wsl);
590        assert_eq!(path, &back);
591    }
592
593    #[test]
594    fn test_trailing_slash_handling() {
595        assert_eq!(windows_to_wsl(r"C:\Users\"), "/mnt/c/Users/");
596        assert_eq!(wsl_to_windows("/mnt/c/"), r"C:\");
597    }
598}