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> {
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 pub fn as_path(&self) -> &Path {
56 &self.0
57 }
58
59 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#[derive(Debug)]
117pub enum IdentityError {
118 NonExistentPath { path: PathBuf },
121 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 }
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}