Skip to main content

cortexkit_paths/
lib.rs

1//! Shared path canonicalization primitives for CortexKit tooling.
2//!
3//! This crate deliberately owns only the dependency-light project-root identity
4//! primitive: resolving an existing filesystem path into a canonical path-backed
5//! [`ProjectRootId`]. It does not perform workspace discovery, Git inspection,
6//! transport serialization, or operation-target fallback handling.
7
8#![forbid(unsafe_code)]
9
10use std::{
11    error::Error,
12    fmt, fs, io,
13    path::{Path, PathBuf},
14};
15
16/// Stable canonical identity for a project root.
17///
18/// A `ProjectRootId` is represented by the canonical filesystem path of an
19/// existing project root. Construction uses [`std::fs::canonicalize`], so the
20/// stored path is absolute, has `.`/`..`/trailing separators collapsed, and has
21/// symlinks resolved.
22///
23/// Git worktrees are first-class roots: this crate does not ask Git for a
24/// repository common-dir and does not collapse linked worktrees back to their
25/// main checkout. Because a linked worktree has its own checkout directory, the
26/// canonical worktree path is a distinct id from the canonical main-checkout
27/// path while alternate spellings of either path still converge.
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
29pub struct ProjectRootId(PathBuf);
30
31impl ProjectRootId {
32    /// Resolve an existing filesystem path into a canonical project-root id.
33    ///
34    /// Non-existent paths are rejected with [`IdentityError::NonExistentPath`]
35    /// instead of being logically normalized. That policy avoids silently
36    /// aliasing roots whose future meaning could change when missing path
37    /// components or symlinks are later created.
38    ///
39    /// That rejection is load-bearing for callers who use it to DETECT a root
40    /// that has gone away, so this constructor keeps it. Callers that must still
41    /// address a vanished root -- ending or inspecting work that was admitted
42    /// while the root existed -- use [`Self::from_path_allowing_missing`], which
43    /// preserves the aliasing guarantee by a narrower means.
44    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, IdentityError> {
45        let requested_path = path.as_ref().to_path_buf();
46        match fs::canonicalize(path.as_ref()) {
47            Ok(canonical_path) => Ok(Self(platform_project_root_path(canonical_path))),
48            Err(err) if err.kind() == io::ErrorKind::NotFound => {
49                Err(IdentityError::NonExistentPath {
50                    path: requested_path,
51                })
52            }
53            Err(source) => Err(IdentityError::CanonicalizePath {
54                path: requested_path,
55                source,
56            }),
57        }
58    }
59
60    /// Resolve a path into a project-root id even when the path no longer exists.
61    ///
62    /// Resolves the longest prefix that still exists and re-appends the rest,
63    /// following any symlink encountered on the missing tail. This is the
64    /// behaviour of POSIX `realpath` on a non-existent path; [`fs::canonicalize`]
65    /// is the outlier in refusing partial resolution, so this matches a
66    /// documented reference rather than inventing a rule.
67    ///
68    /// WHY NOT LEXICAL NORMALIZATION: consumers key durable state on the
69    /// resolved string. On macOS every temporary directory is reached through a
70    /// symlink, so a lexically-normalized path is a DIFFERENT string from the id
71    /// minted while the root existed -- the caller would address an empty
72    /// lineage and receive a confident "no such thing" rather than an error.
73    /// That is one caller, one spelling, and two ids across time.
74    ///
75    /// WHAT THIS DOES NOT PROMISE: if a missing component later reappears as a
76    /// symlink pointing elsewhere, the id moves. [`Self::from_path`] does not
77    /// prevent that either -- it declines to answer while the component is
78    /// missing and then resolves through the new link exactly as this does, so
79    /// the hazard is shared rather than introduced here. The aliasing guarantee
80    /// the strict constructor exists for is preserved by refusing to create NEW
81    /// durable state under an id resolved this way; callers admit only
82    /// operations that read or end something already recorded.
83    pub fn from_path_allowing_missing(path: impl AsRef<Path>) -> Result<Self, IdentityError> {
84        let resolved = resolve_allowing_missing(path.as_ref(), 0)?;
85        Ok(Self(platform_project_root_path(resolved)))
86    }
87
88    /// Borrow the canonical path backing this identity.
89    pub fn as_path(&self) -> &Path {
90        &self.0
91    }
92
93    /// Consume the identity and return its canonical path representation.
94    pub fn into_path_buf(self) -> PathBuf {
95        self.0
96    }
97}
98
99impl AsRef<Path> for ProjectRootId {
100    fn as_ref(&self) -> &Path {
101        self.as_path()
102    }
103}
104
105impl fmt::Display for ProjectRootId {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "{}", self.0.display())
108    }
109}
110
111impl From<ProjectRootId> for PathBuf {
112    fn from(value: ProjectRootId) -> Self {
113        value.into_path_buf()
114    }
115}
116
117impl TryFrom<&Path> for ProjectRootId {
118    type Error = IdentityError;
119
120    fn try_from(value: &Path) -> Result<Self, Self::Error> {
121        Self::from_path(value)
122    }
123}
124
125impl TryFrom<PathBuf> for ProjectRootId {
126    type Error = IdentityError;
127
128    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
129        Self::from_path(value)
130    }
131}
132
133impl TryFrom<&str> for ProjectRootId {
134    type Error = IdentityError;
135
136    fn try_from(value: &str) -> Result<Self, Self::Error> {
137        Self::from_path(Path::new(value))
138    }
139}
140
141impl TryFrom<String> for ProjectRootId {
142    type Error = IdentityError;
143
144    fn try_from(value: String) -> Result<Self, Self::Error> {
145        Self::from_path(PathBuf::from(value))
146    }
147}
148
149/// Typed identity-resolution failures.
150#[derive(Debug)]
151pub enum IdentityError {
152    /// The requested project root does not exist, or a path component cannot be
153    /// resolved through an existing symlink chain.
154    NonExistentPath { path: PathBuf },
155    /// The OS rejected canonicalization for a reason other than non-existence.
156    CanonicalizePath { path: PathBuf, source: io::Error },
157}
158
159impl fmt::Display for IdentityError {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Self::NonExistentPath { path } => {
163                write!(f, "project root does not exist: {}", path.display())
164            }
165            Self::CanonicalizePath { path, source } => {
166                write!(
167                    f,
168                    "failed to canonicalize project root {}: {source}",
169                    path.display()
170                )
171            }
172        }
173    }
174}
175
176impl Error for IdentityError {
177    fn source(&self) -> Option<&(dyn Error + 'static)> {
178        match self {
179            Self::NonExistentPath { .. } => None,
180            Self::CanonicalizePath { source, .. } => Some(source),
181        }
182    }
183}
184
185#[cfg(not(windows))]
186fn platform_project_root_path(canonical_path: PathBuf) -> PathBuf {
187    canonical_path
188}
189
190/// The kernel's own ceiling on symlink hops is typically 40 (`ELOOP`); matching
191/// it means a chain this code refuses is one the OS would refuse too.
192const MAX_SYMLINK_HOPS: u32 = 40;
193
194/// Resolve the longest existing prefix of `path` and re-append the missing tail.
195///
196/// Recurses on the parent rather than looping so that following a symlink on the
197/// missing tail re-enters the same resolution from the link's target.
198fn resolve_allowing_missing(path: &Path, hops: u32) -> Result<PathBuf, IdentityError> {
199    match fs::canonicalize(path) {
200        Ok(canonical_path) => return Ok(canonical_path),
201        Err(err) if err.kind() == io::ErrorKind::NotFound => {}
202        Err(source) => {
203            return Err(IdentityError::CanonicalizePath {
204                path: path.to_path_buf(),
205                source,
206            });
207        }
208    }
209
210    // `..` and `.` tails have no file name to re-append, so there is no honest
211    // way to reconstruct them once the path has stopped existing. Refuse rather
212    // than return a path that differs from what the caller named.
213    let (Some(parent), Some(tail)) = (path.parent(), path.file_name()) else {
214        return Err(IdentityError::NonExistentPath {
215            path: path.to_path_buf(),
216        });
217    };
218    // `Path::parent` yields an empty path for a bare relative name; that is the
219    // current directory, not the absence of a parent.
220    let parent = if parent.as_os_str().is_empty() {
221        Path::new(".")
222    } else {
223        parent
224    };
225
226    let resolved_parent = resolve_allowing_missing(parent, hops)?;
227    let candidate = resolved_parent.join(tail);
228
229    // A DANGLING symlink reads as absent to `canonicalize` and to `Path::exists`,
230    // because both follow links. `symlink_metadata` is the only predicate that
231    // sees the link itself, and following it is what `realpath` does -- keeping
232    // the link's own name instead would move the id the moment someone repairs
233    // the link, so ordinary maintenance would silently strand whatever was
234    // admitted under it.
235    match fs::symlink_metadata(&candidate) {
236        Ok(metadata) if metadata.file_type().is_symlink() => {
237            if hops >= MAX_SYMLINK_HOPS {
238                return Err(IdentityError::CanonicalizePath {
239                    path: path.to_path_buf(),
240                    source: io::Error::new(
241                        io::ErrorKind::InvalidData,
242                        format!("symbolic link chain exceeded {MAX_SYMLINK_HOPS} hops"),
243                    ),
244                });
245            }
246            let target =
247                fs::read_link(&candidate).map_err(|source| IdentityError::CanonicalizePath {
248                    path: candidate.clone(),
249                    source,
250                })?;
251            let target = if target.is_absolute() {
252                target
253            } else {
254                resolved_parent.join(target)
255            };
256            resolve_allowing_missing(&target, hops.saturating_add(1))
257        }
258        _ => Ok(candidate),
259    }
260}
261
262#[cfg(windows)]
263fn platform_project_root_path(canonical_path: PathBuf) -> PathBuf {
264    windows_non_verbatim_path(canonical_path)
265}
266
267#[cfg(windows)]
268fn windows_non_verbatim_path(path: PathBuf) -> PathBuf {
269    use std::{
270        ffi::OsString,
271        os::windows::ffi::{OsStrExt, OsStringExt},
272    };
273
274    const SEPARATOR: u16 = b'\\' as u16;
275    const DRIVE_SEPARATOR: u16 = b':' as u16;
276    const LOWER_A: u16 = b'a' as u16;
277    const LOWER_Z: u16 = b'z' as u16;
278    const ASCII_CASE_DELTA: u16 = (b'a' - b'A') as u16;
279    const VERBATIM_PREFIX: [u16; 4] = [SEPARATOR, SEPARATOR, b'?' as u16, SEPARATOR];
280    const VERBATIM_UNC_PREFIX: [u16; 8] = [
281        SEPARATOR,
282        SEPARATOR,
283        b'?' as u16,
284        SEPARATOR,
285        b'U' as u16,
286        b'N' as u16,
287        b'C' as u16,
288        SEPARATOR,
289    ];
290
291    let encoded: Vec<u16> = path.as_os_str().encode_wide().collect();
292    let mut normalized = if encoded.starts_with(&VERBATIM_UNC_PREFIX) {
293        let mut non_verbatim = Vec::with_capacity(encoded.len() - VERBATIM_UNC_PREFIX.len() + 2);
294        non_verbatim.extend_from_slice(&[SEPARATOR, SEPARATOR]);
295        non_verbatim.extend_from_slice(&encoded[VERBATIM_UNC_PREFIX.len()..]);
296        non_verbatim
297    } else if encoded.starts_with(&VERBATIM_PREFIX) {
298        encoded[VERBATIM_PREFIX.len()..].to_vec()
299    } else {
300        encoded
301    };
302
303    if normalized.len() >= 2
304        && normalized[1] == DRIVE_SEPARATOR
305        && (LOWER_A..=LOWER_Z).contains(&normalized[0])
306    {
307        normalized[0] -= ASCII_CASE_DELTA;
308    }
309
310    PathBuf::from(OsString::from_wide(&normalized))
311}
312
313#[cfg(test)]
314mod tests {
315    use std::{
316        collections::HashMap,
317        fs,
318        path::PathBuf,
319        sync::atomic::{AtomicUsize, Ordering},
320        time::{SystemTime, UNIX_EPOCH},
321    };
322
323    use super::*;
324
325    static NEXT_TEST_DIR: AtomicUsize = AtomicUsize::new(0);
326
327    #[cfg(unix)]
328    fn symlink_dir(target: &Path, link: &Path) -> io::Result<()> {
329        std::os::unix::fs::symlink(target, link)
330    }
331
332    #[cfg(windows)]
333    fn symlink_dir(target: &Path, link: &Path) -> io::Result<()> {
334        std::os::windows::fs::symlink_dir(target, link)
335    }
336
337    struct TestDir {
338        path: PathBuf,
339    }
340
341    impl TestDir {
342        fn new(label: &str) -> Self {
343            let unique = format!(
344                "cortexkit-paths-project-root-id-{label}-{}-{}-{}",
345                std::process::id(),
346                SystemTime::now()
347                    .duration_since(UNIX_EPOCH)
348                    .expect("system time should not be before the Unix epoch")
349                    .as_nanos(),
350                NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed)
351            );
352            let path = std::env::temp_dir().join(unique);
353            fs::create_dir(&path).expect("create temporary project-root-id test directory");
354            Self { path }
355        }
356
357        fn child(&self, name: &str) -> PathBuf {
358            self.path.join(name)
359        }
360    }
361
362    impl Drop for TestDir {
363        fn drop(&mut self) {
364            let _ = fs::remove_dir_all(&self.path);
365        }
366    }
367
368    /// The property the whole constructor exists for: an id minted while the root
369    /// existed must still be reachable after it is gone.
370    ///
371    /// Written as an EQUALITY against the strict constructor's output rather than
372    /// against a hand-written expected string, because a literal would encode
373    /// whatever this author believed canonicalization does. The equality fails if
374    /// the fallback and the strict path ever disagree, which is the only thing
375    /// consumers keying durable state on the result actually require.
376    #[test]
377    fn id_survives_the_root_being_deleted() {
378        let temp = TestDir::new("vanished");
379        let root = temp.child("project");
380        fs::create_dir(&root).expect("create project root");
381
382        let while_present = ProjectRootId::from_path(&root).expect("canonicalize live root");
383        fs::remove_dir(&root).expect("remove project root");
384
385        assert!(
386            ProjectRootId::from_path(&root).is_err(),
387            "the strict constructor must still refuse a vanished root, or callers that \
388             use the refusal to DETECT a dead root would silently keep it"
389        );
390        assert_eq!(
391            ProjectRootId::from_path_allowing_missing(&root).expect("resolve vanished root"),
392            while_present,
393            "a root deleted after admission must resolve to the id it was admitted \
394             under, or the caller addresses an empty lineage and is told no such thing \
395             exists rather than being given an error"
396        );
397    }
398
399    /// macOS reaches every temp directory through a symlink, so this is the case
400    /// that distinguishes resolving from lexical normalization on this host --
401    /// and the one a lexical implementation silently gets wrong.
402    #[test]
403    fn missing_tail_resolves_through_a_symlinked_ancestor() {
404        let temp = TestDir::new("symlinked-ancestor");
405        let real = temp.child("real");
406        let link = temp.child("link");
407        fs::create_dir(&real).expect("create real directory");
408        symlink_dir(&real, &link).expect("create ancestor symlink");
409
410        let through_link = ProjectRootId::from_path_allowing_missing(link.join("gone"))
411            .expect("resolve through symlinked ancestor");
412        let through_real = ProjectRootId::from_path_allowing_missing(real.join("gone"))
413            .expect("resolve through real ancestor");
414
415        assert_eq!(
416            through_link, through_real,
417            "a missing tail must resolve through a live symlinked ancestor, or two \
418             spellings of one location mint two different ids"
419        );
420        assert_ne!(
421            through_link.as_path(),
422            link.join("gone"),
423            "non-vacuity: if this equals the input the implementation is normalizing \
424             lexically and the test above would pass for the wrong reason"
425        );
426    }
427
428    /// A dangling link reads as absent to both `canonicalize` and `Path::exists`,
429    /// so the naive walk-up stops one component too high and keeps the link's own
430    /// name. Following it is what `realpath` does, and it is the choice that
431    /// SURVIVES REPAIR: if someone later creates the target, the strict
432    /// constructor produces this same id, so ordinary maintenance cannot strand
433    /// work admitted while the link dangled.
434    #[test]
435    fn dangling_link_resolves_to_its_target_and_survives_the_link_being_repaired() {
436        let temp = TestDir::new("dangling");
437        let target = temp.child("target");
438        let link = temp.child("link");
439        symlink_dir(&target, &link).expect("create dangling symlink");
440
441        let while_dangling = ProjectRootId::from_path_allowing_missing(link.join("session"))
442            .expect("resolve through dangling link");
443
444        fs::create_dir(&target).expect("create link target");
445        fs::create_dir(target.join("session")).expect("create session directory");
446        let after_repair =
447            ProjectRootId::from_path(link.join("session")).expect("canonicalize repaired path");
448
449        assert_eq!(
450            while_dangling, after_repair,
451            "repairing a dangling link must not move the id, or an act of maintenance \
452             silently strands whatever was admitted while it dangled"
453        );
454    }
455
456    /// A link chain long enough to be an error must fail rather than recurse until
457    /// the stack gives out. Asserting the ERROR VARIANT, not merely that it failed:
458    /// a stack overflow is not a refusal.
459    #[test]
460    fn symlink_chain_beyond_the_hop_ceiling_is_refused() {
461        let temp = TestDir::new("loop");
462        let first = temp.child("a");
463        let second = temp.child("b");
464        symlink_dir(&second, &first).expect("create first link");
465        symlink_dir(&first, &second).expect("create second link");
466
467        let error = ProjectRootId::from_path_allowing_missing(first.join("gone"))
468            .expect_err("a symlink cycle must be refused");
469        assert!(
470            matches!(error, IdentityError::CanonicalizePath { .. }),
471            "a cycle is an unresolvable path, not a missing one: {error:?}"
472        );
473    }
474
475    #[test]
476    fn path_spellings_to_same_root_have_equal_project_root_ids() {
477        let temp = TestDir::new("spellings");
478        let root = temp.child("project");
479        let nested = root.join("nested");
480        fs::create_dir(&root).expect("create project root");
481        fs::create_dir(&nested).expect("create nested directory");
482
483        let trailing = PathBuf::from(format!("{}{}", root.display(), std::path::MAIN_SEPARATOR));
484        let direct = ProjectRootId::from_path(&root).expect("canonicalize direct root");
485        let with_trailing = ProjectRootId::from_path(trailing).expect("canonicalize trailing root");
486        let with_dot = ProjectRootId::from_path(root.join(".")).expect("canonicalize dot root");
487        let round_trip =
488            ProjectRootId::from_path(nested.join("..")).expect("canonicalize round-trip root");
489
490        assert_eq!(direct, with_trailing);
491        assert_eq!(direct, with_dot);
492        assert_eq!(direct, round_trip);
493    }
494
495    #[cfg(unix)]
496    #[test]
497    fn symlinked_project_root_has_same_id_as_target() {
498        use std::os::unix::fs::symlink;
499
500        let temp = TestDir::new("symlink");
501        let target = temp.child("target");
502        let link = temp.child("link");
503        fs::create_dir(&target).expect("create symlink target");
504        symlink(&target, &link).expect("create symlink to project root");
505
506        let target_id = ProjectRootId::from_path(&target).expect("canonicalize target");
507        let link_id = ProjectRootId::from_path(&link).expect("canonicalize symlink");
508
509        assert_eq!(target_id, link_id);
510    }
511
512    #[test]
513    fn git_worktree_checkout_path_is_distinct_from_main_checkout_path() {
514        let temp = TestDir::new("worktree");
515        let main_checkout = temp.child("main-checkout");
516        let linked_worktree = temp.child("linked-worktree");
517        let main_gitdir = main_checkout.join(".git");
518        let worktree_gitdir = main_gitdir.join("worktrees").join("linked-worktree");
519
520        fs::create_dir(&main_checkout).expect("create main checkout");
521        fs::create_dir(&linked_worktree).expect("create linked worktree checkout");
522        fs::create_dir_all(&worktree_gitdir).expect("create simulated worktree gitdir");
523        fs::write(
524            linked_worktree.join(".git"),
525            format!("gitdir: {}\n", worktree_gitdir.display()),
526        )
527        .expect("write simulated linked-worktree .git file");
528
529        let main_id = ProjectRootId::from_path(&main_checkout).expect("canonicalize main checkout");
530        let worktree_id =
531            ProjectRootId::from_path(&linked_worktree).expect("canonicalize linked worktree");
532
533        assert_ne!(main_id, worktree_id);
534    }
535
536    #[test]
537    fn non_existent_project_root_returns_typed_error() {
538        let temp = TestDir::new("missing");
539        let missing_root = temp.child("missing-project");
540
541        match ProjectRootId::from_path(&missing_root) {
542            Err(IdentityError::NonExistentPath { path }) => assert_eq!(path, missing_root),
543            Err(other) => panic!("expected NonExistentPath error, got {other}"),
544            Ok(id) => panic!("expected missing project root to fail, got {id}"),
545        }
546    }
547
548    #[cfg(target_os = "macos")]
549    #[test]
550    fn macos_var_symlink_resolves_to_private_var() {
551        let id = ProjectRootId::from_path("/var").expect("canonicalize /var");
552
553        assert_eq!(id.as_path(), std::path::Path::new("/private/var"));
554    }
555
556    #[test]
557    fn realpath_preserves_stored_case_on_case_insensitive_filesystems() {
558        let temp = TestDir::new("stored-case");
559        let stored_case = temp.child("SUB");
560        let alternate_case = temp.child("sub");
561        fs::create_dir(&stored_case).expect("create stored-case project root");
562
563        let stored_id =
564            ProjectRootId::from_path(&stored_case).expect("canonicalize stored-case root");
565        match ProjectRootId::from_path(&alternate_case) {
566            Ok(alternate_id) => {
567                assert_eq!(stored_id, alternate_id);
568                assert!(alternate_id.as_path().ends_with("SUB"));
569            }
570            Err(IdentityError::NonExistentPath { path }) if path == alternate_case => {
571                // This filesystem is case-sensitive; the seed vector is not applicable here.
572            }
573            Err(other) => {
574                panic!("expected alternate case to canonicalize or be absent, got {other}")
575            }
576        }
577    }
578
579    #[test]
580    fn project_root_id_is_hashable_as_hash_map_key() {
581        let temp = TestDir::new("hashmap");
582        let root = temp.child("project");
583        let other_root = temp.child("other-project");
584        fs::create_dir(&root).expect("create project root");
585        fs::create_dir(&other_root).expect("create other project root");
586
587        let id = ProjectRootId::from_path(&root).expect("canonicalize project root");
588        let same_id =
589            ProjectRootId::from_path(root.join(".")).expect("canonicalize equivalent root");
590        let other_id = ProjectRootId::from_path(&other_root).expect("canonicalize different root");
591
592        let mut entries = HashMap::new();
593        entries.insert(id.clone(), "project state");
594
595        assert_eq!(entries.get(&same_id), Some(&"project state"));
596        assert_eq!(entries.get(&other_id), None);
597    }
598
599    #[cfg(windows)]
600    #[test]
601    fn windows_drive_verbatim_prefix_is_stripped() {
602        let path = windows_non_verbatim_path(PathBuf::from(r"\\?\C:\existing"));
603
604        assert_eq!(path, PathBuf::from(r"C:\existing"));
605    }
606
607    #[cfg(windows)]
608    #[test]
609    fn windows_unc_verbatim_prefix_is_stripped() {
610        let path = windows_non_verbatim_path(PathBuf::from(r"\\?\UNC\server\share\existing"));
611
612        assert_eq!(path, PathBuf::from(r"\\server\share\existing"));
613    }
614
615    #[cfg(windows)]
616    #[test]
617    fn windows_lowercase_drive_letter_is_uppercased() {
618        let path = windows_non_verbatim_path(PathBuf::from(r"c:\existing"));
619
620        assert_eq!(path, PathBuf::from(r"C:\existing"));
621    }
622}