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    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, IdentityError> {
39        let requested_path = path.as_ref().to_path_buf();
40        match fs::canonicalize(path.as_ref()) {
41            Ok(canonical_path) => Ok(Self(platform_project_root_path(canonical_path))),
42            Err(err) if err.kind() == io::ErrorKind::NotFound => {
43                Err(IdentityError::NonExistentPath {
44                    path: requested_path,
45                })
46            }
47            Err(source) => Err(IdentityError::CanonicalizePath {
48                path: requested_path,
49                source,
50            }),
51        }
52    }
53
54    /// Borrow the canonical path backing this identity.
55    pub fn as_path(&self) -> &Path {
56        &self.0
57    }
58
59    /// Consume the identity and return its canonical path representation.
60    pub fn into_path_buf(self) -> PathBuf {
61        self.0
62    }
63}
64
65impl AsRef<Path> for ProjectRootId {
66    fn as_ref(&self) -> &Path {
67        self.as_path()
68    }
69}
70
71impl fmt::Display for ProjectRootId {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "{}", self.0.display())
74    }
75}
76
77impl From<ProjectRootId> for PathBuf {
78    fn from(value: ProjectRootId) -> Self {
79        value.into_path_buf()
80    }
81}
82
83impl TryFrom<&Path> for ProjectRootId {
84    type Error = IdentityError;
85
86    fn try_from(value: &Path) -> Result<Self, Self::Error> {
87        Self::from_path(value)
88    }
89}
90
91impl TryFrom<PathBuf> for ProjectRootId {
92    type Error = IdentityError;
93
94    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
95        Self::from_path(value)
96    }
97}
98
99impl TryFrom<&str> for ProjectRootId {
100    type Error = IdentityError;
101
102    fn try_from(value: &str) -> Result<Self, Self::Error> {
103        Self::from_path(Path::new(value))
104    }
105}
106
107impl TryFrom<String> for ProjectRootId {
108    type Error = IdentityError;
109
110    fn try_from(value: String) -> Result<Self, Self::Error> {
111        Self::from_path(PathBuf::from(value))
112    }
113}
114
115/// Typed identity-resolution failures.
116#[derive(Debug)]
117pub enum IdentityError {
118    /// The requested project root does not exist, or a path component cannot be
119    /// resolved through an existing symlink chain.
120    NonExistentPath { path: PathBuf },
121    /// The OS rejected canonicalization for a reason other than non-existence.
122    CanonicalizePath { path: PathBuf, source: io::Error },
123}
124
125impl fmt::Display for IdentityError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            Self::NonExistentPath { path } => {
129                write!(f, "project root does not exist: {}", path.display())
130            }
131            Self::CanonicalizePath { path, source } => {
132                write!(
133                    f,
134                    "failed to canonicalize project root {}: {source}",
135                    path.display()
136                )
137            }
138        }
139    }
140}
141
142impl Error for IdentityError {
143    fn source(&self) -> Option<&(dyn Error + 'static)> {
144        match self {
145            Self::NonExistentPath { .. } => None,
146            Self::CanonicalizePath { source, .. } => Some(source),
147        }
148    }
149}
150
151#[cfg(not(windows))]
152fn platform_project_root_path(canonical_path: PathBuf) -> PathBuf {
153    canonical_path
154}
155
156#[cfg(windows)]
157fn platform_project_root_path(canonical_path: PathBuf) -> PathBuf {
158    windows_non_verbatim_path(canonical_path)
159}
160
161#[cfg(windows)]
162fn windows_non_verbatim_path(path: PathBuf) -> PathBuf {
163    use std::{
164        ffi::OsString,
165        os::windows::ffi::{OsStrExt, OsStringExt},
166    };
167
168    const SEPARATOR: u16 = b'\\' as u16;
169    const DRIVE_SEPARATOR: u16 = b':' as u16;
170    const LOWER_A: u16 = b'a' as u16;
171    const LOWER_Z: u16 = b'z' as u16;
172    const ASCII_CASE_DELTA: u16 = (b'a' - b'A') as u16;
173    const VERBATIM_PREFIX: [u16; 4] = [SEPARATOR, SEPARATOR, b'?' as u16, SEPARATOR];
174    const VERBATIM_UNC_PREFIX: [u16; 8] = [
175        SEPARATOR,
176        SEPARATOR,
177        b'?' as u16,
178        SEPARATOR,
179        b'U' as u16,
180        b'N' as u16,
181        b'C' as u16,
182        SEPARATOR,
183    ];
184
185    let encoded: Vec<u16> = path.as_os_str().encode_wide().collect();
186    let mut normalized = if encoded.starts_with(&VERBATIM_UNC_PREFIX) {
187        let mut non_verbatim = Vec::with_capacity(encoded.len() - VERBATIM_UNC_PREFIX.len() + 2);
188        non_verbatim.extend_from_slice(&[SEPARATOR, SEPARATOR]);
189        non_verbatim.extend_from_slice(&encoded[VERBATIM_UNC_PREFIX.len()..]);
190        non_verbatim
191    } else if encoded.starts_with(&VERBATIM_PREFIX) {
192        encoded[VERBATIM_PREFIX.len()..].to_vec()
193    } else {
194        encoded
195    };
196
197    if normalized.len() >= 2
198        && normalized[1] == DRIVE_SEPARATOR
199        && (LOWER_A..=LOWER_Z).contains(&normalized[0])
200    {
201        normalized[0] -= ASCII_CASE_DELTA;
202    }
203
204    PathBuf::from(OsString::from_wide(&normalized))
205}
206
207#[cfg(test)]
208mod tests {
209    use std::{
210        collections::HashMap,
211        fs,
212        path::PathBuf,
213        sync::atomic::{AtomicUsize, Ordering},
214        time::{SystemTime, UNIX_EPOCH},
215    };
216
217    use super::*;
218
219    static NEXT_TEST_DIR: AtomicUsize = AtomicUsize::new(0);
220
221    struct TestDir {
222        path: PathBuf,
223    }
224
225    impl TestDir {
226        fn new(label: &str) -> Self {
227            let unique = format!(
228                "cortexkit-paths-project-root-id-{label}-{}-{}-{}",
229                std::process::id(),
230                SystemTime::now()
231                    .duration_since(UNIX_EPOCH)
232                    .expect("system time should not be before the Unix epoch")
233                    .as_nanos(),
234                NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed)
235            );
236            let path = std::env::temp_dir().join(unique);
237            fs::create_dir(&path).expect("create temporary project-root-id test directory");
238            Self { path }
239        }
240
241        fn child(&self, name: &str) -> PathBuf {
242            self.path.join(name)
243        }
244    }
245
246    impl Drop for TestDir {
247        fn drop(&mut self) {
248            let _ = fs::remove_dir_all(&self.path);
249        }
250    }
251
252    #[test]
253    fn path_spellings_to_same_root_have_equal_project_root_ids() {
254        let temp = TestDir::new("spellings");
255        let root = temp.child("project");
256        let nested = root.join("nested");
257        fs::create_dir(&root).expect("create project root");
258        fs::create_dir(&nested).expect("create nested directory");
259
260        let trailing = PathBuf::from(format!("{}{}", root.display(), std::path::MAIN_SEPARATOR));
261        let direct = ProjectRootId::from_path(&root).expect("canonicalize direct root");
262        let with_trailing = ProjectRootId::from_path(trailing).expect("canonicalize trailing root");
263        let with_dot = ProjectRootId::from_path(root.join(".")).expect("canonicalize dot root");
264        let round_trip =
265            ProjectRootId::from_path(nested.join("..")).expect("canonicalize round-trip root");
266
267        assert_eq!(direct, with_trailing);
268        assert_eq!(direct, with_dot);
269        assert_eq!(direct, round_trip);
270    }
271
272    #[cfg(unix)]
273    #[test]
274    fn symlinked_project_root_has_same_id_as_target() {
275        use std::os::unix::fs::symlink;
276
277        let temp = TestDir::new("symlink");
278        let target = temp.child("target");
279        let link = temp.child("link");
280        fs::create_dir(&target).expect("create symlink target");
281        symlink(&target, &link).expect("create symlink to project root");
282
283        let target_id = ProjectRootId::from_path(&target).expect("canonicalize target");
284        let link_id = ProjectRootId::from_path(&link).expect("canonicalize symlink");
285
286        assert_eq!(target_id, link_id);
287    }
288
289    #[test]
290    fn git_worktree_checkout_path_is_distinct_from_main_checkout_path() {
291        let temp = TestDir::new("worktree");
292        let main_checkout = temp.child("main-checkout");
293        let linked_worktree = temp.child("linked-worktree");
294        let main_gitdir = main_checkout.join(".git");
295        let worktree_gitdir = main_gitdir.join("worktrees").join("linked-worktree");
296
297        fs::create_dir(&main_checkout).expect("create main checkout");
298        fs::create_dir(&linked_worktree).expect("create linked worktree checkout");
299        fs::create_dir_all(&worktree_gitdir).expect("create simulated worktree gitdir");
300        fs::write(
301            linked_worktree.join(".git"),
302            format!("gitdir: {}\n", worktree_gitdir.display()),
303        )
304        .expect("write simulated linked-worktree .git file");
305
306        let main_id = ProjectRootId::from_path(&main_checkout).expect("canonicalize main checkout");
307        let worktree_id =
308            ProjectRootId::from_path(&linked_worktree).expect("canonicalize linked worktree");
309
310        assert_ne!(main_id, worktree_id);
311    }
312
313    #[test]
314    fn non_existent_project_root_returns_typed_error() {
315        let temp = TestDir::new("missing");
316        let missing_root = temp.child("missing-project");
317
318        match ProjectRootId::from_path(&missing_root) {
319            Err(IdentityError::NonExistentPath { path }) => assert_eq!(path, missing_root),
320            Err(other) => panic!("expected NonExistentPath error, got {other}"),
321            Ok(id) => panic!("expected missing project root to fail, got {id}"),
322        }
323    }
324
325    #[cfg(target_os = "macos")]
326    #[test]
327    fn macos_var_symlink_resolves_to_private_var() {
328        let id = ProjectRootId::from_path("/var").expect("canonicalize /var");
329
330        assert_eq!(id.as_path(), std::path::Path::new("/private/var"));
331    }
332
333    #[test]
334    fn realpath_preserves_stored_case_on_case_insensitive_filesystems() {
335        let temp = TestDir::new("stored-case");
336        let stored_case = temp.child("SUB");
337        let alternate_case = temp.child("sub");
338        fs::create_dir(&stored_case).expect("create stored-case project root");
339
340        let stored_id =
341            ProjectRootId::from_path(&stored_case).expect("canonicalize stored-case root");
342        match ProjectRootId::from_path(&alternate_case) {
343            Ok(alternate_id) => {
344                assert_eq!(stored_id, alternate_id);
345                assert!(alternate_id.as_path().ends_with("SUB"));
346            }
347            Err(IdentityError::NonExistentPath { path }) if path == alternate_case => {
348                // This filesystem is case-sensitive; the seed vector is not applicable here.
349            }
350            Err(other) => {
351                panic!("expected alternate case to canonicalize or be absent, got {other}")
352            }
353        }
354    }
355
356    #[test]
357    fn project_root_id_is_hashable_as_hash_map_key() {
358        let temp = TestDir::new("hashmap");
359        let root = temp.child("project");
360        let other_root = temp.child("other-project");
361        fs::create_dir(&root).expect("create project root");
362        fs::create_dir(&other_root).expect("create other project root");
363
364        let id = ProjectRootId::from_path(&root).expect("canonicalize project root");
365        let same_id =
366            ProjectRootId::from_path(root.join(".")).expect("canonicalize equivalent root");
367        let other_id = ProjectRootId::from_path(&other_root).expect("canonicalize different root");
368
369        let mut entries = HashMap::new();
370        entries.insert(id.clone(), "project state");
371
372        assert_eq!(entries.get(&same_id), Some(&"project state"));
373        assert_eq!(entries.get(&other_id), None);
374    }
375
376    #[cfg(windows)]
377    #[test]
378    fn windows_drive_verbatim_prefix_is_stripped() {
379        let path = windows_non_verbatim_path(PathBuf::from(r"\\?\C:\existing"));
380
381        assert_eq!(path, PathBuf::from(r"C:\existing"));
382    }
383
384    #[cfg(windows)]
385    #[test]
386    fn windows_unc_verbatim_prefix_is_stripped() {
387        let path = windows_non_verbatim_path(PathBuf::from(r"\\?\UNC\server\share\existing"));
388
389        assert_eq!(path, PathBuf::from(r"\\server\share\existing"));
390    }
391
392    #[cfg(windows)]
393    #[test]
394    fn windows_lowercase_drive_letter_is_uppercased() {
395        let path = windows_non_verbatim_path(PathBuf::from(r"c:\existing"));
396
397        assert_eq!(path, PathBuf::from(r"C:\existing"));
398    }
399}