1#![deny(missing_docs)]
2
3#[cfg(not(windows))]
40use std::ffi::OsStr;
41use std::path::{Path, PathBuf};
42
43#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct LogicalPathContext {
62 mapping: Option<PrefixMapping>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
68struct PrefixMapping {
69 canonical_prefix: PathBuf,
70 logical_prefix: PathBuf,
71}
72
73impl Default for LogicalPathContext {
74 fn default() -> Self {
80 LogicalPathContext { mapping: None }
81 }
82}
83
84impl LogicalPathContext {
85 #[must_use]
118 pub fn detect() -> LogicalPathContext {
119 #[cfg(windows)]
120 {
121 let cwd = match std::env::current_dir() {
122 Ok(cwd) => cwd,
123 Err(e) => {
124 log::debug!("detect: current_dir() failed: {e}");
125 return LogicalPathContext { mapping: None };
126 }
127 };
128
129 let canonical_cwd = match std::fs::canonicalize(&cwd) {
130 Ok(c) => strip_extended_length_prefix(&c),
131 Err(e) => {
132 log::debug!("detect: canonicalize({}) failed: {e}", cwd.display());
133 return LogicalPathContext { mapping: None };
134 }
135 };
136
137 log::trace!(
138 "detect (Windows): cwd={}, canonical_cwd={}",
139 cwd.display(),
140 canonical_cwd.display()
141 );
142
143 Self::detect_from_cwd(&cwd, &canonical_cwd)
144 }
145
146 #[cfg(not(windows))]
147 {
148 let pwd = std::env::var_os("PWD");
149 let canonical_cwd = match std::env::current_dir() {
150 Ok(cwd) => cwd,
151 Err(e) => {
152 log::debug!("detect: current_dir() failed: {e}");
153 return LogicalPathContext { mapping: None };
154 }
155 };
156 log::trace!(
157 "detect (Unix): PWD={:?}, canonical_cwd={}",
158 pwd,
159 canonical_cwd.display()
160 );
161 Self::detect_from(pwd.as_deref(), &canonical_cwd)
162 }
163 }
164
165 #[cfg(not(windows))]
168 pub(crate) fn detect_from(pwd: Option<&OsStr>, canonical_cwd: &Path) -> LogicalPathContext {
169 let pwd = match pwd {
170 Some(p) if !p.is_empty() => Path::new(p),
171 _ => {
172 log::trace!("detect_from: PWD is unset or empty, no mapping");
173 return LogicalPathContext { mapping: None };
174 }
175 };
176
177 if pwd == canonical_cwd {
179 log::trace!("detect_from: PWD == canonical CWD, no mapping");
180 return LogicalPathContext { mapping: None };
181 }
182
183 match std::fs::canonicalize(pwd) {
186 Ok(canonical_pwd) if canonical_pwd == canonical_cwd => {}
187 _ => {
188 log::trace!("detect_from: PWD validation failed (stale or divergent), no mapping");
189 return LogicalPathContext { mapping: None };
190 }
191 }
192
193 match find_divergence_point(canonical_cwd, pwd) {
194 Some((canonical_prefix, logical_prefix)) => {
195 log::debug!(
196 "detect_from: mapping detected: {} → {}",
197 canonical_prefix.display(),
198 logical_prefix.display()
199 );
200 LogicalPathContext {
201 mapping: Some(PrefixMapping {
202 canonical_prefix,
203 logical_prefix,
204 }),
205 }
206 }
207 None => {
208 log::trace!("detect_from: no divergence found");
209 LogicalPathContext { mapping: None }
210 }
211 }
212 }
213
214 #[cfg(windows)]
218 pub(crate) fn detect_from_cwd(cwd: &Path, canonical_cwd: &Path) -> LogicalPathContext {
219 if cwd == canonical_cwd {
220 log::trace!("detect_from_cwd: cwd == canonical_cwd, no mapping");
221 return LogicalPathContext { mapping: None };
222 }
223
224 match find_divergence_point(canonical_cwd, cwd) {
225 Some((canonical_prefix, logical_prefix)) => {
226 log::debug!(
227 "detect_from_cwd: mapping detected: {} → {}",
228 canonical_prefix.display(),
229 logical_prefix.display()
230 );
231 LogicalPathContext {
232 mapping: Some(PrefixMapping {
233 canonical_prefix,
234 logical_prefix,
235 }),
236 }
237 }
238 None => {
239 log::trace!("detect_from_cwd: no divergence found");
240 LogicalPathContext { mapping: None }
241 }
242 }
243 }
244
245 #[must_use]
264 pub fn has_mapping(&self) -> bool {
265 self.mapping.is_some()
266 }
267
268 #[must_use]
300 pub fn to_logical(&self, path: &Path) -> PathBuf {
301 self.translate(path, TranslationDirection::ToLogical)
302 }
303
304 #[must_use]
338 pub fn to_canonical(&self, path: &Path) -> PathBuf {
339 self.translate(path, TranslationDirection::ToCanonical)
340 }
341
342 fn translate(&self, path: &Path, direction: TranslationDirection) -> PathBuf {
343 let fallback = path.to_path_buf();
344
345 let mapping = match &self.mapping {
347 Some(m) => m,
348 None => {
349 log::trace!("translate: no mapping, returning input unchanged");
350 return fallback;
351 }
352 };
353
354 if path.is_relative() {
356 log::trace!("translate: relative path, returning input unchanged");
357 return fallback;
358 }
359
360 let (from_prefix, to_prefix) = match direction {
361 TranslationDirection::ToLogical => (&mapping.canonical_prefix, &mapping.logical_prefix),
362 TranslationDirection::ToCanonical => {
363 (&mapping.logical_prefix, &mapping.canonical_prefix)
364 }
365 };
366
367 #[cfg(windows)]
372 let path_for_match_buf = strip_extended_length_prefix(path);
373 #[cfg(windows)]
374 let path_for_match = path_for_match_buf.as_path();
375 #[cfg(not(windows))]
376 let path_for_match = path;
377
378 let suffix = match path_for_match.strip_prefix(from_prefix) {
380 Ok(s) => s,
381 Err(_) => {
382 log::trace!(
383 "translate: path does not start with source prefix ({}), returning unchanged",
384 from_prefix.display()
385 );
386 return fallback;
387 }
388 };
389
390 let translated = to_prefix.join(suffix);
391
392 let original_canonical = match std::fs::canonicalize(path) {
394 Ok(c) => c,
395 Err(e) => {
396 log::trace!(
397 "translate: canonicalize({}) failed: {e}, returning unchanged",
398 path.display()
399 );
400 return fallback;
401 }
402 };
403 let translated_canonical = match std::fs::canonicalize(&translated) {
404 Ok(c) => c,
405 Err(e) => {
406 log::trace!(
407 "translate: canonicalize({}) failed: {e}, returning unchanged",
408 translated.display()
409 );
410 return fallback;
411 }
412 };
413
414 #[cfg(windows)]
416 let original_canonical = strip_extended_length_prefix(&original_canonical);
417 #[cfg(windows)]
418 let translated_canonical = strip_extended_length_prefix(&translated_canonical);
419
420 if original_canonical == translated_canonical {
421 translated
422 } else {
423 log::trace!(
424 "translate: round-trip validation failed ({} != {}), returning unchanged",
425 original_canonical.display(),
426 translated_canonical.display()
427 );
428 fallback
429 }
430 }
431}
432
433enum TranslationDirection {
434 ToLogical,
435 ToCanonical,
436}
437
438#[cfg(windows)]
444fn strip_extended_length_prefix(path: &Path) -> PathBuf {
445 let s = match path.to_str() {
446 Some(s) => s,
447 None => return path.to_path_buf(),
448 };
449
450 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
451 return PathBuf::from(format!(r"\\{rest}"));
452 }
453
454 if let Some(rest) = s.strip_prefix(r"\\?\") {
455 let mut chars = rest.chars();
457 if let Some(drive) = chars.next() {
458 if drive.is_ascii_alphabetic() {
459 if let Some(':') = chars.next() {
460 return PathBuf::from(rest);
461 }
462 }
463 }
464 }
465
466 path.to_path_buf()
467}
468
469fn components_equal(a: &std::path::Component<'_>, b: &std::path::Component<'_>) -> bool {
474 #[cfg(windows)]
475 {
476 a.as_os_str().eq_ignore_ascii_case(b.as_os_str())
477 }
478 #[cfg(not(windows))]
479 {
480 a == b
481 }
482}
483
484fn find_divergence_point(canonical: &Path, logical: &Path) -> Option<(PathBuf, PathBuf)> {
493 let canonical_components: Vec<_> = canonical.components().collect();
494 let logical_components: Vec<_> = logical.components().collect();
495
496 let mut common_suffix_len = 0;
498 let mut c_iter = canonical_components.iter().rev();
499 let mut l_iter = logical_components.iter().rev();
500
501 loop {
502 match (c_iter.next(), l_iter.next()) {
503 (Some(c), Some(l)) if components_equal(c, l) => common_suffix_len += 1,
504 _ => break,
505 }
506 }
507
508 if common_suffix_len == 0 {
509 if canonical == logical {
513 return None;
514 }
515 return Some((canonical.to_path_buf(), logical.to_path_buf()));
516 }
517
518 let canonical_prefix_len = canonical_components.len() - common_suffix_len;
519 let logical_prefix_len = logical_components.len() - common_suffix_len;
520
521 if canonical_prefix_len == 0 && logical_prefix_len == 0 {
523 return None;
524 }
525
526 let canonical_prefix: PathBuf = canonical_components[..canonical_prefix_len]
527 .iter()
528 .collect();
529 let logical_prefix: PathBuf = logical_components[..logical_prefix_len].iter().collect();
530
531 if canonical_prefix == logical_prefix {
533 return None;
534 }
535
536 Some((canonical_prefix, logical_prefix))
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
545 fn has_mapping_returns_false_when_none() {
546 let ctx = LogicalPathContext { mapping: None };
547 assert!(!ctx.has_mapping());
548 }
549
550 #[test]
551 fn has_mapping_returns_true_when_some() {
552 let ctx = LogicalPathContext {
553 mapping: Some(PrefixMapping {
554 canonical_prefix: PathBuf::from("/mnt/wsl/workspace"),
555 logical_prefix: PathBuf::from("/workspace"),
556 }),
557 };
558 assert!(ctx.has_mapping());
559 }
560
561 #[test]
563 fn logical_path_context_is_send_and_sync() {
564 fn assert_send_sync<T: Send + Sync>() {}
565 assert_send_sync::<LogicalPathContext>();
566 }
567
568 #[test]
570 fn default_returns_no_mapping() {
571 let ctx = LogicalPathContext::default();
572 assert!(!ctx.has_mapping());
573 assert_eq!(ctx, LogicalPathContext { mapping: None });
574 }
575
576 #[cfg(unix)]
578 #[test]
579 fn divergence_identical_paths_returns_none() {
580 let result = find_divergence_point(
581 Path::new("/home/user/project"),
582 Path::new("/home/user/project"),
583 );
584 assert_eq!(result, None);
585 }
586
587 #[cfg(unix)]
588 #[test]
589 fn divergence_common_suffix_different_prefixes() {
590 let result = find_divergence_point(
591 Path::new("/mnt/wsl/workspace/project/src"),
592 Path::new("/workspace/project/src"),
593 );
594 assert_eq!(
597 result,
598 Some((PathBuf::from("/mnt/wsl"), PathBuf::from("/")))
599 );
600 }
601
602 #[cfg(unix)]
603 #[test]
604 fn divergence_no_common_components_returns_full_paths() {
605 let result = find_divergence_point(Path::new("/a/b/c"), Path::new("/x/y/z"));
609 assert_eq!(
610 result,
611 Some((PathBuf::from("/a/b/c"), PathBuf::from("/x/y/z")))
612 );
613 }
614
615 #[cfg(unix)]
616 #[test]
617 fn divergence_trailing_slashes() {
618 let result = find_divergence_point(
620 Path::new("/real/base/project/"),
621 Path::new("/link/project/"),
622 );
623 assert_eq!(
624 result,
625 Some((PathBuf::from("/real/base"), PathBuf::from("/link")))
626 );
627 }
628
629 #[cfg(unix)]
630 #[test]
631 fn divergence_dot_components() {
632 let result = find_divergence_point(
634 Path::new("/real/./base/project"),
635 Path::new("/link/./project"),
636 );
637 assert_eq!(
638 result,
639 Some((PathBuf::from("/real/base"), PathBuf::from("/link")))
640 );
641 }
642
643 #[cfg(unix)]
644 #[test]
645 fn divergence_dotdot_components() {
646 let result = find_divergence_point(
650 Path::new("/real/base/../base/project"),
651 Path::new("/link/project"),
652 );
653 assert_eq!(
658 result,
659 Some((PathBuf::from("/real/base/../base"), PathBuf::from("/link")))
660 );
661 }
662
663 #[cfg(unix)]
664 #[test]
665 fn divergence_redundant_separators() {
666 let result = find_divergence_point(
668 Path::new("/real///base//project"),
669 Path::new("/link//project"),
670 );
671 assert_eq!(
672 result,
673 Some((PathBuf::from("/real/base"), PathBuf::from("/link")))
674 );
675 }
676
677 #[cfg(unix)]
678 #[test]
679 fn divergence_macos_private_prefix() {
680 let result = find_divergence_point(
681 Path::new("/private/var/folders/tmp"),
682 Path::new("/var/folders/tmp"),
683 );
684 assert_eq!(
685 result,
686 Some((PathBuf::from("/private"), PathBuf::from("/")))
687 );
688 }
689
690 #[cfg(not(windows))]
692 #[test]
693 fn detect_from_pwd_matches_canonical_returns_no_mapping() {
694 use std::ffi::OsStr;
695 let cwd = Path::new("/home/user/project");
696 let ctx = LogicalPathContext::detect_from(Some(OsStr::new("/home/user/project")), cwd);
697 assert!(!ctx.has_mapping());
698 }
699
700 #[cfg(not(windows))]
702 #[test]
703 fn detect_from_pwd_none_returns_no_mapping() {
704 let cwd = Path::new("/home/user/project");
705 let ctx = LogicalPathContext::detect_from(None, cwd);
706 assert!(!ctx.has_mapping());
707 }
708
709 #[cfg(not(windows))]
711 #[test]
712 fn detect_from_stale_pwd_returns_no_mapping() {
713 use std::ffi::OsStr;
714 let cwd = Path::new("/home/user/project");
715 let ctx = LogicalPathContext::detect_from(
716 Some(OsStr::new("/nonexistent/stale/path/project")),
717 cwd,
718 );
719 assert!(!ctx.has_mapping());
721 }
722
723 #[cfg(not(windows))]
725 #[test]
726 fn detect_from_corrupted_pwd_returns_no_mapping() {
727 use std::ffi::OsStr;
728 let cwd = Path::new("/home/user/project");
729 let ctx = LogicalPathContext::detect_from(Some(OsStr::new("")), cwd);
730 assert!(!ctx.has_mapping());
731 }
732
733 #[cfg(target_os = "macos")]
735 #[test]
736 fn detect_from_macos_private_prefix_has_mapping() {
737 use std::ffi::OsStr;
740 let logical_path = Path::new("/var/folders");
741 let Ok(canonical_cwd) = std::fs::canonicalize(logical_path) else {
742 return; };
744 if canonical_cwd == logical_path {
745 return; }
747 let ctx = LogicalPathContext::detect_from(Some(OsStr::new("/var/folders")), &canonical_cwd);
748 assert!(ctx.has_mapping());
749 }
750
751 fn ctx_with_mapping(
753 canonical: impl AsRef<Path>,
754 logical: impl AsRef<Path>,
755 ) -> LogicalPathContext {
756 LogicalPathContext {
757 mapping: Some(PrefixMapping {
758 canonical_prefix: canonical.as_ref().to_path_buf(),
759 logical_prefix: logical.as_ref().to_path_buf(),
760 }),
761 }
762 }
763
764 fn ctx_no_mapping() -> LogicalPathContext {
765 LogicalPathContext { mapping: None }
766 }
767
768 #[cfg(unix)]
772 #[test]
773 fn to_logical_translates_path_under_canonical_prefix() {
774 let dir = tempfile::tempdir().unwrap();
776 let canonical_base = dir.path().join("real");
777 let logical_base = dir.path().join("link");
778
779 std::fs::create_dir_all(canonical_base.join("src")).unwrap();
780 #[cfg(unix)]
781 std::os::unix::fs::symlink(&canonical_base, &logical_base).unwrap();
782
783 let ctx = ctx_with_mapping(&canonical_base, &logical_base);
784
785 let input = canonical_base.join("src");
786 let result = ctx.to_logical(&input);
787 assert_eq!(result, logical_base.join("src"));
788 }
789
790 #[test]
792 fn to_logical_returns_input_when_not_under_prefix() {
793 let ctx = ctx_with_mapping("/mnt/wsl/workspace", "/workspace");
794 let input = Path::new("/some/other/path");
795 let result = ctx.to_logical(input);
796 assert_eq!(result, input.to_path_buf());
797 }
798
799 #[test]
801 fn to_logical_returns_input_when_no_mapping() {
802 let ctx = ctx_no_mapping();
803 let input = Path::new("/home/user/project/src/main.rs");
804 let result = ctx.to_logical(input);
805 assert_eq!(result, input.to_path_buf());
806 }
807
808 #[test]
810 fn to_logical_returns_input_for_relative_path() {
811 let ctx = ctx_with_mapping("/mnt/wsl/workspace", "/workspace");
812 let input = Path::new("src/main.rs");
813 let result = ctx.to_logical(input);
814 assert_eq!(result, input.to_path_buf());
815 }
816
817 #[cfg(unix)]
821 #[test]
822 fn to_canonical_translates_path_under_logical_prefix() {
823 let dir = tempfile::tempdir().unwrap();
824 let canonical_base = dir.path().join("real");
825 let logical_base = dir.path().join("link");
826
827 std::fs::create_dir_all(canonical_base.join("src")).unwrap();
828 #[cfg(unix)]
829 std::os::unix::fs::symlink(&canonical_base, &logical_base).unwrap();
830
831 let ctx = ctx_with_mapping(&canonical_base, &logical_base);
832
833 let input = logical_base.join("src");
834 let result = ctx.to_canonical(&input);
835 assert_eq!(result, canonical_base.join("src"));
836 }
837
838 #[test]
840 fn to_canonical_returns_input_when_not_under_prefix() {
841 let ctx = ctx_with_mapping("/mnt/wsl/workspace", "/workspace");
842 let input = Path::new("/some/other/path");
843 let result = ctx.to_canonical(input);
844 assert_eq!(result, input.to_path_buf());
845 }
846
847 #[test]
849 fn to_canonical_returns_input_when_no_mapping() {
850 let ctx = ctx_no_mapping();
851 let input = Path::new("/home/user/project/src/main.rs");
852 let result = ctx.to_canonical(input);
853 assert_eq!(result, input.to_path_buf());
854 }
855
856 #[test]
858 fn to_canonical_returns_input_for_relative_path() {
859 let ctx = ctx_with_mapping("/mnt/wsl/workspace", "/workspace");
860 let input = Path::new("../foo/bar.rs");
861 let result = ctx.to_canonical(input);
862 assert_eq!(result, input.to_path_buf());
863 }
864
865 #[cfg(unix)]
869 #[test]
870 fn to_logical_falls_back_when_roundtrip_fails() {
871 let dir = tempfile::tempdir().unwrap();
874 let real_base = dir.path().join("real");
875 let bogus_logical = dir.path().join("bogus_link");
876 std::fs::create_dir_all(real_base.join("src")).unwrap();
877 let ctx = ctx_with_mapping(&real_base, &bogus_logical);
880
881 let input = real_base.join("src");
882 let result = ctx.to_logical(&input);
883 assert_eq!(result, input);
885 }
886
887 #[cfg(unix)]
888 #[test]
889 fn to_canonical_falls_back_when_roundtrip_fails() {
890 let dir = tempfile::tempdir().unwrap();
891 let bogus_canonical = dir.path().join("bogus_real");
892 let link_base = dir.path().join("link");
893 std::fs::create_dir_all(link_base.join("src")).unwrap();
894 let ctx = ctx_with_mapping(&bogus_canonical, &link_base);
897
898 let input = link_base.join("src");
899 let result = ctx.to_canonical(&input);
900 assert_eq!(result, input);
901 }
902
903 #[cfg(unix)]
905 #[test]
906 fn non_utf8_paths_dont_panic() {
907 use std::ffi::OsStr;
908 use std::os::unix::ffi::OsStrExt;
909
910 let non_utf8 = OsStr::from_bytes(&[0xff, 0xfe]);
911 let ctx = ctx_with_mapping("/mnt/wsl/workspace", "/workspace");
912
913 let input = Path::new(non_utf8);
915 let result = ctx.to_logical(input);
916 assert_eq!(result, input.to_path_buf());
917
918 let result = ctx.to_canonical(input);
920 assert_eq!(result, input.to_path_buf());
921
922 let ctx2 = LogicalPathContext::detect_from(Some(non_utf8), Path::new("/home/user"));
924 let _ = ctx2;
925 }
926
927 #[cfg(unix)]
929 #[test]
930 fn to_logical_idempotent_on_logical_path() {
931 use std::os::unix::fs::symlink;
932
933 let dir = tempfile::tempdir().unwrap();
934 let canonical_base = dir.path().join("real");
935 let logical_base = dir.path().join("link");
936
937 std::fs::create_dir_all(canonical_base.join("src")).unwrap();
938 symlink(&canonical_base, &logical_base).unwrap();
939
940 let ctx = ctx_with_mapping(&canonical_base, &logical_base);
941
942 let logical_path = logical_base.join("src");
945 let result = ctx.to_logical(&logical_path);
946 assert_eq!(result, logical_path);
947 }
948
949 #[cfg(unix)]
951 #[test]
952 fn to_canonical_idempotent_on_canonical_path() {
953 use std::os::unix::fs::symlink;
954
955 let dir = tempfile::tempdir().unwrap();
956 let canonical_base = dir.path().join("real");
957 let logical_base = dir.path().join("link");
958
959 std::fs::create_dir_all(canonical_base.join("src")).unwrap();
960 symlink(&canonical_base, &logical_base).unwrap();
961
962 let ctx = ctx_with_mapping(&canonical_base, &logical_base);
963
964 let canonical_path = canonical_base.join("src");
967 let result = ctx.to_canonical(&canonical_path);
968 assert_eq!(result, canonical_path);
969 }
970
971 #[cfg(not(windows))]
973 #[test]
974 fn detect_from_divergent_pwd_returns_no_mapping() {
975 let dir = tempfile::tempdir().unwrap();
977 let dir_a = dir.path().join("a");
978 let dir_b = dir.path().join("b");
979 std::fs::create_dir_all(&dir_a).unwrap();
980 std::fs::create_dir_all(&dir_b).unwrap();
981
982 let canonical_a = std::fs::canonicalize(&dir_a).unwrap();
983 let canonical_b = std::fs::canonicalize(&dir_b).unwrap();
984
985 let ctx = LogicalPathContext::detect_from(Some(canonical_a.as_os_str()), &canonical_b);
987 assert!(!ctx.has_mapping());
988 }
989
990 #[cfg(unix)]
992 #[test]
993 fn to_logical_translates_file_paths() {
994 use std::os::unix::fs::symlink;
995
996 let dir = tempfile::tempdir().unwrap();
997 let canonical_base = dir.path().join("real");
998 let logical_base = dir.path().join("link");
999
1000 std::fs::create_dir_all(canonical_base.join("src")).unwrap();
1001 std::fs::write(canonical_base.join("src").join("main.rs"), b"fn main() {}").unwrap();
1003 symlink(&canonical_base, &logical_base).unwrap();
1004
1005 let ctx = ctx_with_mapping(&canonical_base, &logical_base);
1006
1007 let canonical_file = canonical_base.join("src").join("main.rs");
1008 let result = ctx.to_logical(&canonical_file);
1009 assert_eq!(result, logical_base.join("src").join("main.rs"));
1010
1011 let logical_file = logical_base.join("src").join("main.rs");
1013 let back = ctx.to_canonical(&logical_file);
1014 assert_eq!(back, canonical_base.join("src").join("main.rs"));
1015 }
1016
1017 #[cfg(unix)]
1019 #[test]
1020 fn roundtrip_parameterized_test() {
1021 use std::os::unix::fs::symlink;
1022
1023 let dir = tempfile::tempdir().unwrap();
1024 let base = std::fs::canonicalize(dir.path()).unwrap();
1028 let real_base = base.join("real");
1029 let link_base = base.join("link");
1030
1031 let subdirs = [
1033 "src",
1034 "src/main",
1035 "src/lib",
1036 "tests",
1037 "tests/unit",
1038 "docs",
1039 "docs/api",
1040 "build",
1041 "build/debug",
1042 "config",
1043 ];
1044
1045 for subdir in &subdirs {
1046 std::fs::create_dir_all(real_base.join(subdir)).unwrap();
1047 }
1048 symlink(&real_base, &link_base).unwrap();
1049
1050 let ctx = LogicalPathContext::detect_from(
1051 Some(link_base.join("src").as_os_str()),
1052 &real_base.join("src"),
1053 );
1054
1055 for subdir in &subdirs {
1057 let canonical = real_base.join(subdir);
1058 let logical = ctx.to_logical(&canonical);
1059 let expected_logical = link_base.join(subdir);
1060 assert_eq!(
1061 logical, expected_logical,
1062 "to_logical failed for {}",
1063 subdir
1064 );
1065
1066 let back_to_canonical = ctx.to_canonical(&logical);
1067 assert_eq!(
1068 back_to_canonical, canonical,
1069 "to_canonical round-trip failed for {}",
1070 subdir
1071 );
1072 }
1073 }
1074
1075 #[cfg(windows)]
1079 #[test]
1080 fn strip_prefix_drive_letter() {
1081 let result = strip_extended_length_prefix(Path::new(r"\\?\C:\Users\dev"));
1082 assert_eq!(result, PathBuf::from(r"C:\Users\dev"));
1083 }
1084
1085 #[cfg(windows)]
1086 #[test]
1087 fn strip_prefix_unc() {
1088 let result = strip_extended_length_prefix(Path::new(r"\\?\UNC\server\share\folder"));
1089 assert_eq!(result, PathBuf::from(r"\\server\share\folder"));
1090 }
1091
1092 #[cfg(windows)]
1093 #[test]
1094 fn strip_prefix_no_prefix_unchanged() {
1095 let result = strip_extended_length_prefix(Path::new(r"C:\Users\dev"));
1096 assert_eq!(result, PathBuf::from(r"C:\Users\dev"));
1097 }
1098
1099 #[cfg(windows)]
1100 #[test]
1101 fn strip_prefix_empty_unchanged() {
1102 let result = strip_extended_length_prefix(Path::new(""));
1103 assert_eq!(result, PathBuf::from(""));
1104 }
1105
1106 #[cfg(windows)]
1108 #[test]
1109 fn divergence_case_insensitive_matching_components() {
1110 let result = find_divergence_point(
1112 Path::new(r"C:\Users\Dev\Project"),
1113 Path::new(r"C:\users\dev\project"),
1114 );
1115 assert_eq!(result, None);
1116 }
1117
1118 #[cfg(windows)]
1119 #[test]
1120 fn divergence_windows_junction_like_paths() {
1121 let result = find_divergence_point(
1124 Path::new(r"D:\Projects\Workspace\src"),
1125 Path::new(r"C:\workspace\src"),
1126 );
1127 assert_eq!(
1128 result,
1129 Some((PathBuf::from(r"D:\Projects"), PathBuf::from(r"C:\")))
1130 );
1131 }
1132
1133 #[cfg(windows)]
1134 #[test]
1135 fn divergence_windows_identical_paths() {
1136 let result = find_divergence_point(
1137 Path::new(r"C:\Users\dev\project"),
1138 Path::new(r"C:\Users\dev\project"),
1139 );
1140 assert_eq!(result, None);
1141 }
1142
1143 #[cfg(windows)]
1145 #[test]
1146 fn detect_from_cwd_equal_paths_no_mapping() {
1147 let ctx = LogicalPathContext::detect_from_cwd(
1148 Path::new(r"C:\Users\dev\project"),
1149 Path::new(r"C:\Users\dev\project"),
1150 );
1151 assert!(!ctx.has_mapping());
1152 }
1153
1154 #[cfg(windows)]
1155 #[test]
1156 fn detect_from_cwd_different_paths_with_common_suffix() {
1157 let ctx = LogicalPathContext::detect_from_cwd(
1158 Path::new(r"S:\workspace\src"),
1159 Path::new(r"D:\projects\workspace\src"),
1160 );
1161 assert!(ctx.has_mapping());
1162 }
1163
1164 #[cfg(windows)]
1165 #[test]
1166 fn detect_from_cwd_different_paths_no_common_suffix() {
1167 let ctx = LogicalPathContext::detect_from_cwd(
1170 Path::new(r"X:\completely\different"),
1171 Path::new(r"Y:\totally\unrelated"),
1172 );
1173 assert!(ctx.has_mapping());
1174 }
1175
1176 #[cfg(windows)]
1178 #[test]
1179 fn to_logical_strips_extended_prefix_from_input() {
1180 let dir = tempfile::tempdir().unwrap();
1184 let canonical_base = std::fs::canonicalize(dir.path()).unwrap();
1185 let real_dir = canonical_base.join("real");
1186 let link_dir = canonical_base.join("link");
1187
1188 let real_dir_stripped = strip_extended_length_prefix(&real_dir);
1190 let link_dir_stripped = strip_extended_length_prefix(&link_dir);
1191
1192 std::fs::create_dir_all(real_dir.join("src")).unwrap();
1193
1194 let status = std::process::Command::new("cmd")
1196 .args(["/C", "mklink", "/J"])
1197 .arg(&link_dir)
1198 .arg(&real_dir)
1199 .output()
1200 .expect("mklink /J");
1201 assert!(status.status.success(), "mklink /J failed");
1202
1203 let ctx = ctx_with_mapping(&real_dir_stripped, &link_dir_stripped);
1204
1205 let input = real_dir.join("src");
1208 let result = ctx.to_logical(&input);
1209 assert_eq!(result, link_dir_stripped.join("src"));
1210
1211 let _ = std::process::Command::new("cmd")
1213 .args(["/C", "rd"])
1214 .arg(&link_dir)
1215 .output();
1216 }
1217
1218 #[cfg(windows)]
1220 #[test]
1221 fn detect_from_cwd_identical_returns_fallback() {
1222 let path = Path::new(r"C:\Users\dev\project");
1223 let ctx = LogicalPathContext::detect_from_cwd(path, path);
1224 assert!(!ctx.has_mapping());
1225
1226 let input = Path::new(r"C:\Users\dev\project\src\main.rs");
1228 assert_eq!(ctx.to_logical(input), input.to_path_buf());
1229 assert_eq!(ctx.to_canonical(input), input.to_path_buf());
1230 }
1231
1232 #[cfg(windows)]
1234 #[test]
1235 fn windows_relative_path_returns_unchanged() {
1236 let ctx = ctx_with_mapping(r"D:\projects\workspace", r"C:\workspace");
1237
1238 let input = Path::new(r"src\main.rs");
1239 assert_eq!(ctx.to_logical(input), input.to_path_buf());
1240 assert_eq!(ctx.to_canonical(input), input.to_path_buf());
1241 }
1242
1243 #[cfg(windows)]
1249 #[test]
1250 fn divergence_non_ascii_case_is_not_folded() {
1251 let result = find_divergence_point(
1252 Path::new(r"C:\Users\Ä\project"),
1253 Path::new(r"C:\Users\ä\project"),
1254 );
1255 assert_eq!(
1258 result,
1259 Some((PathBuf::from(r"C:\Users\Ä"), PathBuf::from(r"C:\Users\ä"),))
1260 );
1261 }
1262
1263 #[cfg(windows)]
1267 #[test]
1268 fn strip_prefix_volume_guid_unchanged() {
1269 let input = r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\Users\dev";
1270 let result = strip_extended_length_prefix(Path::new(input));
1271 assert_eq!(result, PathBuf::from(input));
1272 }
1273}