Skip to main content

logical_path/
lib.rs

1#![deny(missing_docs)]
2
3//! Translate canonical (symlink-resolved) filesystem paths back to their
4//! logical (symlink-preserving) equivalents.
5//!
6//! When a shell's current directory traverses a symlink (Unix) or an NTFS
7//! junction, directory symlink, or subst drive (Windows), two different paths
8//! refer to the same location: the **logical** path (preserving the
9//! indirection) and the **canonical** path (with all indirections resolved).
10//! This crate detects that mapping and provides bidirectional translation.
11//!
12//! The primary entry point is [`LogicalPathContext::detect()`], which inspects
13//! the process environment and returns a context for translating paths in both
14//! directions.
15//!
16//! # Quick Start
17//!
18//! ```no_run
19//! use logical_path::LogicalPathContext;
20//! use std::path::Path;
21//!
22//! let ctx = LogicalPathContext::detect();
23//!
24//! if ctx.has_mapping() {
25//!     let canonical = Path::new("/mnt/wsl/workspace/project/src/main.rs");
26//!     let logical = ctx.to_logical(canonical);
27//!     println!("Logical: {}", logical.display());
28//! }
29//! ```
30//!
31//! # Platform Behavior
32//!
33//! - **Linux/macOS**: Compares `$PWD` against `getcwd()`.
34//! - **Windows**: Compares `current_dir()` against `canonicalize()` with
35//!   `\\?\` prefix stripped.
36//!
37//! See [`LogicalPathContext`] for full platform-specific details.
38
39#[cfg(not(windows))]
40use std::ffi::OsStr;
41use std::path::{Path, PathBuf};
42
43/// A context that holds zero or one active prefix mappings between
44/// canonical (symlink-resolved) and logical (symlink-preserving) paths.
45///
46/// Created via [`LogicalPathContext::detect()`]. Immutable after construction.
47///
48/// # Thread Safety
49///
50/// `LogicalPathContext` is `Send + Sync` — it can be shared across threads.
51///
52/// # Platform Behavior
53///
54/// - **Linux/macOS**: Reads `$PWD` and compares against `getcwd()` to detect
55///   symlink prefix mappings.
56/// - **Windows**: Compares `current_dir()` (preserves junctions, subst drives,
57///   mapped drives) against `canonicalize()` (resolves to physical path) to
58///   detect NTFS junction, directory symlink, subst drive, and mapped drive
59///   mappings. The `\\?\` prefix is stripped before comparison.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct LogicalPathContext {
62    mapping: Option<PrefixMapping>,
63}
64
65/// An internal type representing the divergence point between the logical
66/// and canonical paths.
67#[derive(Debug, Clone, PartialEq, Eq)]
68struct PrefixMapping {
69    canonical_prefix: PathBuf,
70    logical_prefix: PathBuf,
71}
72
73impl Default for LogicalPathContext {
74    /// Returns a context with no active mapping.
75    ///
76    /// Equivalent to calling [`detect()`](LogicalPathContext::detect) in an
77    /// environment with no path indirections or mappings in effect. All
78    /// translations return their input unchanged.
79    fn default() -> Self {
80        LogicalPathContext { mapping: None }
81    }
82}
83
84impl LogicalPathContext {
85    /// Detect the active prefix mapping by comparing logical and canonical
86    /// current working directory paths.
87    ///
88    /// - **Unix**: Compares `$PWD` (logical) against `getcwd()` (canonical).
89    /// - **Windows**: Compares `current_dir()` (logical, preserves junctions/subst)
90    ///   against `canonicalize(current_dir())` (canonical, physical path) with
91    ///   `\\?\` prefix stripped.
92    ///
93    /// Returns a context with no active mapping (all translations become
94    /// no-ops) when:
95    /// - `$PWD` is unset (Unix)
96    /// - The logical and canonical CWD are identical (no indirection in effect)
97    /// - `$PWD` is stale (Unix: points to a non-existent directory)
98    /// - The current directory cannot be determined
99    ///
100    /// This function is infallible — detection failures are handled by
101    /// returning a no-op context.
102    ///
103    /// # Panics
104    ///
105    /// This function never panics.
106    ///
107    /// # Examples
108    ///
109    /// ```no_run
110    /// use logical_path::LogicalPathContext;
111    ///
112    /// let ctx = LogicalPathContext::detect();
113    /// if ctx.has_mapping() {
114    ///     println!("Path mapping detected");
115    /// }
116    /// ```
117    #[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    /// Internal helper for testability: takes `$PWD` and canonical CWD as
166    /// parameters instead of reading from global process state.
167    #[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 and canonical CWD are identical, no mapping needed
178        if pwd == canonical_cwd {
179            log::trace!("detect_from: PWD == canonical CWD, no mapping");
180            return LogicalPathContext { mapping: None };
181        }
182
183        // Validate that pwd resolves to canonical_cwd. This rejects stale $PWD
184        // values (non-existent directories) and divergent $PWD assignments.
185        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    /// Internal helper for Windows testability: takes the CWD and its
215    /// canonicalized form as parameters instead of reading from global
216    /// process state.
217    #[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    /// Returns `true` if an active prefix mapping was detected.
246    ///
247    /// When this returns `false`, [`to_logical()`](Self::to_logical) and
248    /// [`to_canonical()`](Self::to_canonical) will always return their input
249    /// unchanged.
250    ///
251    /// # Examples
252    ///
253    /// ```no_run
254    /// use logical_path::LogicalPathContext;
255    ///
256    /// let ctx = LogicalPathContext::detect();
257    /// if ctx.has_mapping() {
258    ///     println!("Will translate paths");
259    /// } else {
260    ///     println!("No path mapping detected — paths returned unchanged");
261    /// }
262    /// ```
263    #[must_use]
264    pub fn has_mapping(&self) -> bool {
265        self.mapping.is_some()
266    }
267
268    /// Translate a canonical (symlink-resolved) path to its logical
269    /// (symlink-preserving) equivalent.
270    ///
271    /// If the context has an active mapping and the path starts with the
272    /// canonical prefix, the canonical prefix is replaced with the logical
273    /// prefix. The translation is validated via round-trip canonicalization
274    /// before being returned.
275    ///
276    /// Returns the input path unchanged when:
277    /// - No active mapping exists
278    /// - The path does not start with the canonical prefix
279    /// - The path is relative (not absolute)
280    /// - Round-trip validation fails
281    /// - Canonicalization of the translated path fails (e.g., path doesn't exist on disk)
282    ///
283    /// # Panics
284    ///
285    /// This function never panics, even with non-UTF-8 path components.
286    ///
287    /// # Examples
288    ///
289    /// ```no_run
290    /// use logical_path::LogicalPathContext;
291    /// use std::path::Path;
292    ///
293    /// let ctx = LogicalPathContext::detect();
294    /// let canonical = Path::new("/mnt/wsl/workspace/project/src/main.rs");
295    /// let logical = ctx.to_logical(canonical);
296    /// // Display the logical path to the user
297    /// println!("{}", logical.display());
298    /// ```
299    #[must_use]
300    pub fn to_logical(&self, path: &Path) -> PathBuf {
301        self.translate(path, TranslationDirection::ToLogical)
302    }
303
304    /// Translate a logical (symlink-preserving) path to its canonical
305    /// (symlink-resolved) equivalent.
306    ///
307    /// If the context has an active mapping and the path starts with the
308    /// logical prefix, the logical prefix is replaced with the canonical
309    /// prefix. The translation is validated via round-trip canonicalization
310    /// before being returned.
311    ///
312    /// Returns the input path unchanged when:
313    /// - No active mapping exists
314    /// - The path does not start with the logical prefix
315    /// - The path is relative (not absolute)
316    /// - Round-trip validation fails
317    /// - Canonicalization of the translated path fails (e.g., path doesn't exist on disk)
318    ///
319    /// # Panics
320    ///
321    /// This function never panics, even with non-UTF-8 path components.
322    ///
323    /// # Examples
324    ///
325    /// ```no_run
326    /// use logical_path::LogicalPathContext;
327    /// use std::path::Path;
328    ///
329    /// let ctx = LogicalPathContext::detect();
330    /// let logical = Path::new("/workspace/project/src/main.rs");
331    /// let canonical = ctx.to_canonical(logical);
332    /// // Use the canonical path for filesystem operations
333    /// if canonical.exists() {
334    ///     println!("File exists at {}", canonical.display());
335    /// }
336    /// ```
337    #[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        // No mapping → return input unchanged
346        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        // Relative paths → return input unchanged
355        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        // On Windows, strip the \\?\ prefix only for prefix matching.
368        // Keep the original `path` and `fallback` unchanged so callers get
369        // back the exact input on no-op paths, and so any later operations
370        // in this function can still use the original path.
371        #[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        // Path must start with the source prefix
379        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        // Round-trip validation: canonicalize both and compare
393        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        // On Windows, strip \\?\ prefix from canonicalized paths before comparison
415        #[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/// Strip the `\\?\` Extended Length Path prefix from Windows paths.
439///
440/// - `\\?\C:\...` → `C:\...`
441/// - `\\?\UNC\server\share\...` → `\\server\share\...`
442/// - All other paths → returned unchanged
443#[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        // Only strip if followed by a drive letter and colon
456        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
469/// Compare two path components for equality.
470///
471/// - **Unix**: Case-sensitive comparison (`==`)
472/// - **Windows**: Ordinal case-insensitive comparison (`eq_ignore_ascii_case`)
473fn 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
484/// Find the divergence point between a canonical path and a logical path
485/// by comparing path components from the end (longest common suffix).
486///
487/// Returns `Some((canonical_prefix, logical_prefix))` if the paths differ.
488/// When the paths share a common suffix, returns the differing prefixes.
489/// When no common suffix exists, returns the full paths as the mapping
490/// (supporting cases where CWD is exactly at the mapping point).
491/// Returns `None` if the paths are identical.
492fn 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    // Find the longest common suffix
497    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        // No common suffix — the entire paths form the mapping.
510        // This happens on Windows when CWD is exactly at the mapping point
511        // (e.g., junction root, subst drive root, directory symlink root).
512        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 both prefixes are empty, paths are identical
522    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    // Both prefixes must be non-empty absolute paths, and they must differ
532    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    // T007: has_mapping() tests
544    #[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    // T007a: Send + Sync compile-time assertion
562    #[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    // T007b: Default returns no-mapping context
569    #[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    // T009: find_divergence_point tests
577    #[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        // "workspace", "project", "src" are the common suffix;
595        // prefixes are what remain: /mnt/wsl vs /
596        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        // When paths share no common suffix, the entire paths form the mapping.
606        // This supports cases where CWD is exactly at the mapping point
607        // (e.g., a symlink root on Unix or a junction root on Windows).
608        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        // Path::components() normalizes trailing slashes
619        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        // Path::components() normalizes `.` (CurDir)
633        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        // `..` is preserved as a component by Path::components() — it doesn't
647        // resolve against the filesystem. Paths with `..` will appear as
648        // distinct components.
649        let result = find_divergence_point(
650            Path::new("/real/base/../base/project"),
651            Path::new("/link/project"),
652        );
653        // components: [/, real, base, .., base, project] vs [/, link, project]
654        // common suffix from end: project matches, then base != link → stop
655        // canonical prefix: [/, real, base, .., base] = /real/base/../base
656        // logical prefix: [/, link] = /link
657        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        // Path::components() normalizes redundant separators
667        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    // T010: detect_from() with pwd matching canonical CWD → no mapping
691    #[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    // T011: detect_from() with pwd as None → no mapping
701    #[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    // T013: detect_from() with stale pwd (non-existent path) → no mapping
710    #[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        // canonicalize("/nonexistent/stale/path/project") fails → no mapping
720        assert!(!ctx.has_mapping());
721    }
722
723    // T033: detect_from() with corrupted/partially-resolved pwd → no mapping
724    #[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    // T037: detect_from() with macOS /var → /private/var system symlink pattern
734    #[cfg(target_os = "macos")]
735    #[test]
736    fn detect_from_macos_private_prefix_has_mapping() {
737        // On macOS, /var is a symlink to /private/var.
738        // Validate that a $PWD path under /var is detected as a mapping.
739        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; // Skip if /var/folders doesn't exist
743        };
744        if canonical_cwd == logical_path {
745            return; // Skip if /var is not a symlink on this system
746        }
747        let ctx = LogicalPathContext::detect_from(Some(OsStr::new("/var/folders")), &canonical_cwd);
748        assert!(ctx.has_mapping());
749    }
750
751    // Helper to build a context with a known mapping for unit tests
752    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    // ===== US2: to_logical() tests =====
769
770    // T017: to_logical() with active mapping and path under canonical prefix
771    #[cfg(unix)]
772    #[test]
773    fn to_logical_translates_path_under_canonical_prefix() {
774        // Use real filesystem paths so canonicalize() works for round-trip
775        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    // T018: to_logical() with active mapping and path NOT under canonical prefix
791    #[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    // T019: to_logical() with no active mapping returns input unchanged
800    #[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    // T019a: to_logical() with a relative path returns input unchanged
809    #[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    // ===== US3: to_canonical() tests =====
818
819    // T024: to_canonical() with active mapping and path under logical prefix
820    #[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    // T025: to_canonical() with active mapping and path NOT under logical prefix
839    #[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    // T026: to_canonical() with no active mapping returns input unchanged
848    #[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    // T026a: to_canonical() with a relative path returns input unchanged
857    #[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    // ===== US4: Fallback guarantee tests =====
866
867    // T031: to_logical() and to_canonical() return input when round-trip would fail
868    #[cfg(unix)]
869    #[test]
870    fn to_logical_falls_back_when_roundtrip_fails() {
871        // Create a mapping that is syntactically valid but the translated path
872        // doesn't exist, so canonicalize() fails → fallback
873        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        // No symlink created, so canonicalize of bogus_link/src will fail
878
879        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        // Translated path doesn't exist → fallback to input
884        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        // No real directory behind bogus_canonical
895
896        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    // T032: non-UTF-8 paths don't panic
904    #[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        // to_logical with non-utf8 path — should not panic
914        let input = Path::new(non_utf8);
915        let result = ctx.to_logical(input);
916        assert_eq!(result, input.to_path_buf());
917
918        // to_canonical with non-utf8 path — should not panic
919        let result = ctx.to_canonical(input);
920        assert_eq!(result, input.to_path_buf());
921
922        // detect_from with non-utf8 pwd — should not panic
923        let ctx2 = LogicalPathContext::detect_from(Some(non_utf8), Path::new("/home/user"));
924        let _ = ctx2;
925    }
926
927    // T034: Idempotence — to_logical on an already-logical path returns it unchanged
928    #[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        // Applying to_logical to an already-logical path should return it unchanged
943        // because the logical prefix doesn't start with the canonical prefix.
944        let logical_path = logical_base.join("src");
945        let result = ctx.to_logical(&logical_path);
946        assert_eq!(result, logical_path);
947    }
948
949    // T034a: Idempotence — to_canonical on an already-canonical path returns it unchanged
950    #[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        // Applying to_canonical to an already-canonical path should return it unchanged
965        // because the canonical prefix doesn't start with the logical prefix.
966        let canonical_path = canonical_base.join("src");
967        let result = ctx.to_canonical(&canonical_path);
968        assert_eq!(result, canonical_path);
969    }
970
971    // T035: detect_from with divergent $PWD (valid dir that canonicalizes elsewhere)
972    #[cfg(not(windows))]
973    #[test]
974    fn detect_from_divergent_pwd_returns_no_mapping() {
975        // Create two real directories — $PWD points to dir_a but CWD is dir_b
976        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        // $PWD is valid but canonicalizes to dir_a, not dir_b → no mapping
986        let ctx = LogicalPathContext::detect_from(Some(canonical_a.as_os_str()), &canonical_b);
987        assert!(!ctx.has_mapping());
988    }
989
990    // T035a: Translation works on file paths, not just directories
991    #[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        // Create a file so canonicalize() succeeds during round-trip validation
1002        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        // And back
1012        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    // T030a: Parameterised round-trip test covering ≥10 distinct path structures
1018    #[cfg(unix)]
1019    #[test]
1020    fn roundtrip_parameterized_test() {
1021        use std::os::unix::fs::symlink;
1022
1023        let dir = tempfile::tempdir().unwrap();
1024        // Canonicalize the temp dir base so that detect_from's pwd validation
1025        // succeeds on systems where the temp directory is under a symlink
1026        // (e.g., macOS where /tmp → /private/tmp).
1027        let base = std::fs::canonicalize(dir.path()).unwrap();
1028        let real_base = base.join("real");
1029        let link_base = base.join("link");
1030
1031        // Create a directory tree for testing
1032        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        // Test canonical → logical → canonical round-trip for each subdir
1056        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    // ===== Windows-specific unit tests =====
1076
1077    // T003: strip_extended_length_prefix tests
1078    #[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    // T005: case-insensitive find_divergence_point on Windows
1107    #[cfg(windows)]
1108    #[test]
1109    fn divergence_case_insensitive_matching_components() {
1110        // Same components differing only in case should match → no divergence
1111        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        // Junction-like: D:\Projects\Workspace\src vs C:\workspace\src
1122        // Common suffix (case-insensitive): workspace\src
1123        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    // T007: detect_from_cwd tests
1144    #[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        // When paths share no common suffix (e.g., subst drive root mapped to
1168        // a deep physical path), the entire paths form the prefix mapping.
1169        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    // T010a: to_logical with \\?\-prefixed canonical path on Windows
1177    #[cfg(windows)]
1178    #[test]
1179    fn to_logical_strips_extended_prefix_from_input() {
1180        // Use real filesystem paths so canonicalize() round-trip succeeds.
1181        // On Windows, std::fs::canonicalize() returns \\?\-prefixed paths,
1182        // so we strip that prefix for the mapping (as detect_from_cwd does).
1183        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        // Strip \\?\ for the mapping (detect_from_cwd uses stripped paths)
1189        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        // Create an NTFS junction: link_dir -> real_dir
1195        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        // The caller provides a \\?\-prefixed canonical path (FR-008).
1206        // real_dir already has the \\?\ prefix from canonicalize().
1207        let input = real_dir.join("src");
1208        let result = ctx.to_logical(&input);
1209        assert_eq!(result, link_dir_stripped.join("src"));
1210
1211        // Clean up junction
1212        let _ = std::process::Command::new("cmd")
1213            .args(["/C", "rd"])
1214            .arg(&link_dir)
1215            .output();
1216    }
1217
1218    // T021: detect_from_cwd fallback with identical paths
1219    #[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        // to_logical and to_canonical return input unchanged
1227        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    // T022: relative paths on Windows return unchanged
1233    #[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    // Pins current behavior: Windows component comparison uses
1244    // `eq_ignore_ascii_case`, so non-ASCII letters that differ only in case
1245    // (e.g., `Ä` vs `ä`) are treated as distinct components. If this is ever
1246    // changed to full Unicode case folding, this test will fail and force a
1247    // deliberate update.
1248    #[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        // Common ASCII suffix is just `project`; the non-ASCII components
1256        // differ under ASCII-only case folding, so they form the divergence.
1257        assert_eq!(
1258            result,
1259            Some((PathBuf::from(r"C:\Users\Ä"), PathBuf::from(r"C:\Users\ä"),))
1260        );
1261    }
1262
1263    // Pins current behavior: `\\?\Volume{GUID}\...` paths are not stripped
1264    // because the prefix is followed by `V` then `o`, not a drive letter and
1265    // colon. Such paths flow through unchanged.
1266    #[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}