1#![forbid(unsafe_code)]
9
10use std::{
11 error::Error,
12 fmt, fs, io,
13 path::{Path, PathBuf},
14};
15
16#[derive(Clone, Debug, PartialEq, Eq, Hash)]
29pub struct ProjectRootId(PathBuf);
30
31impl ProjectRootId {
32 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 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 pub fn as_path(&self) -> &Path {
90 &self.0
91 }
92
93 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#[derive(Debug)]
151pub enum IdentityError {
152 NonExistentPath { path: PathBuf },
155 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
190const MAX_SYMLINK_HOPS: u32 = 40;
193
194fn 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 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 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 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 #[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 #[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 #[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 #[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 }
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}