1use std::fmt;
2use std::path::{Component, Path, PathBuf};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PathMapping {
7 pub root: PathBuf,
9 pub placeholder: String,
11}
12
13impl PathMapping {
14 pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
16 Self {
17 root: root.into(),
18 placeholder: placeholder.into(),
19 }
20 }
21
22 pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
24 let mut ordered = mappings.to_vec();
25 ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
26 ordered
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PathNormalizationError {
33 UnmappedAbsolutePath(PathBuf),
35 NonUtf8Path(PathBuf),
37}
38
39impl fmt::Display for PathNormalizationError {
40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::UnmappedAbsolutePath(path) => {
43 write!(
44 formatter,
45 "absolute path has no stable cache mapping: {}",
46 path.display()
47 )
48 }
49 Self::NonUtf8Path(path) => {
50 write!(
51 formatter,
52 "cache key paths must be valid UTF-8: {}",
53 path.display()
54 )
55 }
56 }
57 }
58}
59
60impl std::error::Error for PathNormalizationError {}
61
62pub fn normalize_mapped_path(
67 path: &Path,
68 working_dir: &Path,
69 mappings: &[PathMapping],
70) -> Result<String, PathNormalizationError> {
71 let mappings = resolve_path_mappings(mappings);
72 normalize_resolved_mapped_path(path, working_dir, &mappings)
73}
74
75pub fn resolve_path_mappings(mappings: &[PathMapping]) -> Vec<PathMapping> {
80 mappings
81 .iter()
82 .map(|mapping| PathMapping {
83 root: resolve_mapping_root(&mapping.root),
84 placeholder: mapping.placeholder.clone(),
85 })
86 .collect()
87}
88
89pub fn normalize_resolved_mapped_path(
92 path: &Path,
93 working_dir: &Path,
94 mappings: &[PathMapping],
95) -> Result<String, PathNormalizationError> {
96 normalize_resolved_mapped_path_with(&PathAliases::default(), path, working_dir, mappings)
97}
98
99#[derive(Debug, Default)]
109pub struct PathAliases {
110 #[cfg(unix)]
111 directories:
112 std::cell::RefCell<std::collections::HashMap<PathBuf, std::rc::Rc<ResolvedDirectory>>>,
113}
114
115#[cfg(unix)]
118#[derive(Debug)]
119struct ResolvedDirectory {
120 path: PathBuf,
121 entries: std::collections::HashSet<std::ffi::OsString>,
122}
123
124pub fn normalize_resolved_mapped_path_with(
127 aliases: &PathAliases,
128 path: &Path,
129 working_dir: &Path,
130 mappings: &[PathMapping],
131) -> Result<String, PathNormalizationError> {
132 let absolute = if path.is_absolute() {
133 normalize_components(path)
134 } else {
135 normalize_components(&working_dir.join(path))
136 };
137 let resolved = if absolute.is_absolute() {
138 resolve_path_aliases_with(aliases, &absolute)
139 } else {
140 absolute.clone()
141 };
142 for mapping in mappings {
143 if let Ok(relative) = resolved.strip_prefix(&mapping.root) {
144 let suffix = slash_path(relative)?;
145 return Ok(if suffix.is_empty() {
146 format!("${{{}}}", mapping.placeholder)
147 } else {
148 format!("${{{}}}/{suffix}", mapping.placeholder)
149 });
150 }
151 }
152 Err(PathNormalizationError::UnmappedAbsolutePath(absolute))
153}
154
155#[cfg(any(unix, windows))]
156fn resolve_path_aliases(path: &Path) -> PathBuf {
157 resolve_path_aliases_with(&PathAliases::default(), path)
158}
159
160#[cfg(any(unix, windows))]
161fn resolve_path_aliases_with(aliases: &PathAliases, path: &Path) -> PathBuf {
162 let mut existing = path;
163 let mut missing = Vec::new();
164 loop {
165 match aliases.canonicalize(existing) {
166 Ok(mut resolved) => {
167 for component in missing.iter().rev() {
168 resolved.push(component);
169 }
170 return normalize_components(&resolved);
171 }
172 Err(_) => {
173 let Some(name) = existing.file_name() else {
174 return path.to_path_buf();
175 };
176 missing.push(name.to_os_string());
177 let Some(parent) = existing.parent() else {
178 return path.to_path_buf();
179 };
180 existing = parent;
181 }
182 }
183 }
184}
185
186#[cfg(not(any(unix, windows)))]
187fn resolve_path_aliases(path: &Path) -> PathBuf {
188 path.to_path_buf()
189}
190
191#[cfg(not(any(unix, windows)))]
192fn resolve_path_aliases_with(_aliases: &PathAliases, path: &Path) -> PathBuf {
193 path.to_path_buf()
194}
195
196impl PathAliases {
197 #[cfg(unix)]
199 fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
200 let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
201 return std::fs::canonicalize(path);
202 };
203 if parent.as_os_str().is_empty() {
204 return std::fs::canonicalize(path);
205 }
206 let known = self.directories.borrow().get(parent).cloned();
209 let resolved_parent = match known {
210 Some(resolved) => resolved,
211 None => {
212 let resolved = self.canonicalize(parent)?;
213 let entries = std::fs::read_dir(&resolved)
216 .map(|listing| listing.flatten().map(|entry| entry.file_name()).collect())
217 .unwrap_or_default();
218 let resolved = std::rc::Rc::new(ResolvedDirectory {
219 path: resolved,
220 entries,
221 });
222 self.directories
223 .borrow_mut()
224 .insert(parent.to_path_buf(), resolved.clone());
225 resolved
226 }
227 };
228 let candidate = resolved_parent.path.join(name);
229 if resolved_parent.entries.contains(name)
234 && !std::fs::symlink_metadata(&candidate)?
235 .file_type()
236 .is_symlink()
237 {
238 Ok(candidate)
239 } else {
240 std::fs::canonicalize(&candidate)
241 }
242 }
243
244 #[cfg(windows)]
247 fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
248 std::fs::canonicalize(path)
249 }
250}
251
252fn resolve_mapping_root(root: &Path) -> PathBuf {
253 let root = normalize_components(root);
254 if root.is_absolute() {
255 resolve_path_aliases(&root)
256 } else {
257 root
258 }
259}
260
261fn normalize_components(path: &Path) -> PathBuf {
262 let mut normalized = PathBuf::new();
263 for component in path.components() {
264 match component {
265 Component::CurDir => {}
266 Component::ParentDir => {
267 normalized.pop();
268 }
269 component => normalized.push(component.as_os_str()),
270 }
271 }
272 normalized
273}
274
275fn slash_path(path: &Path) -> Result<String, PathNormalizationError> {
276 path.components()
277 .filter_map(|component| match component {
278 Component::Normal(value) => Some(
279 value
280 .to_str()
281 .map(ToOwned::to_owned)
282 .ok_or_else(|| PathNormalizationError::NonUtf8Path(path.to_path_buf())),
283 ),
284 _ => None,
285 })
286 .collect::<Result<Vec<_>, _>>()
287 .map(|components| components.join("/"))
288}
289
290#[cfg(all(test, windows))]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn maps_verbatim_windows_paths_to_non_verbatim_roots() {
296 let directory = tempfile::tempdir().unwrap();
297 let input = directory.path().join("clippy.toml");
298 std::fs::write(&input, "disallowed-methods = []").unwrap();
299 let verbatim = std::fs::canonicalize(&input).unwrap();
300 assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
301
302 let normalized = normalize_mapped_path(
303 &verbatim,
304 directory.path(),
305 &[PathMapping::new(directory.path(), "workspace")],
306 )
307 .unwrap();
308
309 assert_eq!(normalized, "${workspace}/clippy.toml");
310 }
311}
312
313#[cfg(all(test, unix))]
314mod unix_tests {
315 use super::*;
316
317 #[test]
318 fn resolves_aliases_through_remembered_parents() {
319 let directory = tempfile::tempdir().unwrap();
320 let aliases = PathAliases::default();
321 let root = std::fs::canonicalize(directory.path()).unwrap();
322 let physical = root.join("physical");
323 std::fs::create_dir_all(physical.join("src")).unwrap();
324 std::fs::write(physical.join("src").join("lib.rs"), "").unwrap();
325 std::fs::write(physical.join("real.rs"), "").unwrap();
326 std::os::unix::fs::symlink(&physical, root.join("alias")).unwrap();
327 std::os::unix::fs::symlink(physical.join("real.rs"), physical.join("link.rs")).unwrap();
328 let mappings = [PathMapping::new(&physical, "workspace")];
329
330 let through_alias = root.join("alias").join("src").join("lib.rs");
332 assert_eq!(
333 normalize_resolved_mapped_path_with(&aliases, &through_alias, &root, &mappings)
334 .unwrap(),
335 "${workspace}/src/lib.rs"
336 );
337 let sibling = root.join("alias").join("src").join("missing.rs");
338 assert_eq!(
339 normalize_resolved_mapped_path_with(&aliases, &sibling, &root, &mappings).unwrap(),
340 "${workspace}/src/missing.rs"
341 );
342 assert_eq!(
344 normalize_resolved_mapped_path_with(
345 &aliases,
346 &physical.join("link.rs"),
347 &root,
348 &mappings
349 )
350 .unwrap(),
351 "${workspace}/real.rs"
352 );
353 let unborn = root
355 .join("alias")
356 .join("target")
357 .join("deps")
358 .join("out.rlib");
359 assert_eq!(
360 normalize_resolved_mapped_path_with(&aliases, &unborn, &root, &mappings).unwrap(),
361 "${workspace}/target/deps/out.rlib"
362 );
363 std::fs::write(physical.join("src").join("late.rs"), "").unwrap();
366 assert_eq!(
367 normalize_resolved_mapped_path_with(
368 &aliases,
369 &root.join("alias").join("src").join("late.rs"),
370 &root,
371 &mappings
372 )
373 .unwrap(),
374 "${workspace}/src/late.rs"
375 );
376 }
377
378 #[test]
379 fn a_retargeted_alias_is_seen_by_the_next_caller() {
380 let directory = tempfile::tempdir().unwrap();
381 let root = std::fs::canonicalize(directory.path()).unwrap();
382 for name in ["one", "two"] {
383 std::fs::create_dir_all(root.join(name).join("src")).unwrap();
384 std::fs::write(root.join(name).join("src").join("lib.rs"), "").unwrap();
385 }
386 let alias = root.join("alias");
387 std::os::unix::fs::symlink(root.join("one"), &alias).unwrap();
388 let mappings = [
389 PathMapping::new(root.join("one"), "one"),
390 PathMapping::new(root.join("two"), "two"),
391 ];
392 let input = alias.join("src").join("lib.rs");
393
394 let first = PathAliases::default();
395 assert_eq!(
396 normalize_resolved_mapped_path_with(&first, &input, &root, &mappings).unwrap(),
397 "${one}/src/lib.rs"
398 );
399 std::fs::remove_file(&alias).unwrap();
400 std::os::unix::fs::symlink(root.join("two"), &alias).unwrap();
401 assert_eq!(
404 normalize_resolved_mapped_path_with(&PathAliases::default(), &input, &root, &mappings)
405 .unwrap(),
406 "${two}/src/lib.rs"
407 );
408 }
409}