Skip to main content

mbx_cache_core/
path_mapping.rs

1use std::fmt;
2use std::path::{Component, Path, PathBuf};
3
4/// Mapping from a host-specific absolute root to a stable key placeholder.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PathMapping {
7    /// Absolute host path to replace.
8    pub root: PathBuf,
9    /// Placeholder name without the surrounding `${...}` syntax.
10    pub placeholder: String,
11}
12
13impl PathMapping {
14    /// Map an absolute host path to a stable cache-key placeholder.
15    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    /// Order mappings deepest root first, which is what normalization needs.
23    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/// Why a path could not be represented with the configured mappings.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PathNormalizationError {
33    /// An absolute path has no matching stable placeholder.
34    UnmappedAbsolutePath(PathBuf),
35    /// A path component cannot be represented in the UTF-8 cache key.
36    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
62/// Map an absolute path to its cache-key placeholder form.
63///
64/// `mappings` must already be ordered by [`PathMapping::ordered`]. Existing
65/// path aliases are resolved while a not-yet-created suffix is preserved.
66pub 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
75/// Resolve filesystem aliases in mapping roots once for repeated lookups.
76///
77/// The returned mappings retain the caller's order and can be passed to
78/// [`normalize_resolved_mapped_path`].
79pub 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
89/// Normalize a path against mapping roots already resolved by
90/// [`resolve_path_mappings`].
91pub fn normalize_resolved_mapped_path(
92    path: &Path,
93    working_dir: &Path,
94    mappings: &[PathMapping],
95) -> Result<String, PathNormalizationError> {
96    let absolute = if path.is_absolute() {
97        normalize_components(path)
98    } else {
99        normalize_components(&working_dir.join(path))
100    };
101    let resolved = if absolute.is_absolute() {
102        resolve_path_aliases(&absolute)
103    } else {
104        absolute.clone()
105    };
106    for mapping in mappings {
107        if let Ok(relative) = resolved.strip_prefix(&mapping.root) {
108            let suffix = slash_path(relative)?;
109            return Ok(if suffix.is_empty() {
110                format!("${{{}}}", mapping.placeholder)
111            } else {
112                format!("${{{}}}/{suffix}", mapping.placeholder)
113            });
114        }
115    }
116    Err(PathNormalizationError::UnmappedAbsolutePath(absolute))
117}
118
119#[cfg(unix)]
120fn resolve_path_aliases(path: &Path) -> PathBuf {
121    let mut existing = path;
122    let mut missing = Vec::new();
123    loop {
124        match std::fs::canonicalize(existing) {
125            Ok(mut resolved) => {
126                for component in missing.iter().rev() {
127                    resolved.push(component);
128                }
129                return normalize_components(&resolved);
130            }
131            Err(_) => {
132                let Some(name) = existing.file_name() else {
133                    return path.to_path_buf();
134                };
135                missing.push(name.to_os_string());
136                let Some(parent) = existing.parent() else {
137                    return path.to_path_buf();
138                };
139                existing = parent;
140            }
141        }
142    }
143}
144
145#[cfg(not(unix))]
146fn resolve_path_aliases(path: &Path) -> PathBuf {
147    path.to_path_buf()
148}
149
150fn resolve_mapping_root(root: &Path) -> PathBuf {
151    let root = normalize_components(root);
152    if root.is_absolute() {
153        resolve_path_aliases(&root)
154    } else {
155        root
156    }
157}
158
159fn normalize_components(path: &Path) -> PathBuf {
160    let mut normalized = PathBuf::new();
161    for component in path.components() {
162        match component {
163            Component::CurDir => {}
164            Component::ParentDir => {
165                normalized.pop();
166            }
167            component => normalized.push(component.as_os_str()),
168        }
169    }
170    normalized
171}
172
173fn slash_path(path: &Path) -> Result<String, PathNormalizationError> {
174    path.components()
175        .filter_map(|component| match component {
176            Component::Normal(value) => Some(
177                value
178                    .to_str()
179                    .map(ToOwned::to_owned)
180                    .ok_or_else(|| PathNormalizationError::NonUtf8Path(path.to_path_buf())),
181            ),
182            _ => None,
183        })
184        .collect::<Result<Vec<_>, _>>()
185        .map(|components| components.join("/"))
186}