Skip to main content

zccache_core/
path.rs

1//! Cross-platform path utilities.
2//!
3//! Handles path normalization, case sensitivity, and platform differences.
4
5use std::cmp::Ordering;
6use std::ffi::OsStr;
7use std::hash::{Hash, Hasher};
8use std::ops::Deref;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13/// A normalized, platform-aware path representation.
14///
15/// On case-insensitive filesystems (Windows, default macOS), paths are
16/// stored in a canonical form for consistent cache keying.
17#[derive(Debug, Clone)]
18pub struct NormalizedPath {
19    /// The original path, normalized but preserving original casing.
20    path: PathBuf,
21    /// Lowercased version for case-insensitive comparison, if applicable.
22    case_key: Option<String>,
23}
24
25impl PartialEq for NormalizedPath {
26    fn eq(&self, other: &Self) -> bool {
27        normalize_for_key(&self.path) == normalize_for_key(&other.path)
28    }
29}
30
31impl PartialEq<PathBuf> for NormalizedPath {
32    fn eq(&self, other: &PathBuf) -> bool {
33        self == &Self::new(other)
34    }
35}
36
37impl PartialEq<NormalizedPath> for PathBuf {
38    fn eq(&self, other: &NormalizedPath) -> bool {
39        other == self
40    }
41}
42
43impl PartialEq<Path> for NormalizedPath {
44    fn eq(&self, other: &Path) -> bool {
45        self == &Self::new(other)
46    }
47}
48
49impl PartialEq<&Path> for NormalizedPath {
50    fn eq(&self, other: &&Path) -> bool {
51        self == *other
52    }
53}
54
55impl PartialEq<NormalizedPath> for Path {
56    fn eq(&self, other: &NormalizedPath) -> bool {
57        other == self
58    }
59}
60
61impl PartialEq<&NormalizedPath> for Path {
62    fn eq(&self, other: &&NormalizedPath) -> bool {
63        *other == self
64    }
65}
66
67impl PartialEq<&PathBuf> for NormalizedPath {
68    fn eq(&self, other: &&PathBuf) -> bool {
69        self == *other
70    }
71}
72
73impl PartialEq<&NormalizedPath> for PathBuf {
74    fn eq(&self, other: &&NormalizedPath) -> bool {
75        *other == self
76    }
77}
78
79impl Eq for NormalizedPath {}
80
81impl Hash for NormalizedPath {
82    fn hash<H: Hasher>(&self, state: &mut H) {
83        normalize_for_key(&self.path).hash(state);
84    }
85}
86
87impl PartialOrd for NormalizedPath {
88    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
89        Some(self.cmp(other))
90    }
91}
92
93impl Ord for NormalizedPath {
94    fn cmp(&self, other: &Self) -> Ordering {
95        normalize_for_key(&self.path).cmp(&normalize_for_key(&other.path))
96    }
97}
98
99impl NormalizedPath {
100    /// Create a new normalized path.
101    ///
102    /// On Windows, this also computes a lowercase key for case-insensitive matching.
103    pub fn new(path: impl AsRef<Path>) -> Self {
104        let path = normalize(path.as_ref());
105        let case_key = if cfg!(windows) || cfg!(target_os = "macos") {
106            Some(normalize_for_key(&path))
107        } else {
108            None
109        };
110        Self { path, case_key }
111    }
112
113    /// Returns the underlying path.
114    #[must_use]
115    pub fn as_path(&self) -> &Path {
116        &self.path
117    }
118
119    /// Returns the case-insensitive comparison key, if applicable.
120    #[must_use]
121    pub fn case_key(&self) -> Option<&str> {
122        self.case_key.as_deref()
123    }
124
125    /// Convert back to an owned normalized `PathBuf`.
126    #[must_use]
127    pub fn into_path_buf(self) -> PathBuf {
128        self.path
129    }
130
131    /// Join a path segment onto this normalized path.
132    #[must_use]
133    pub fn join(&self, path: impl AsRef<Path>) -> Self {
134        Self::new(self.path.join(path))
135    }
136}
137
138impl AsRef<Path> for NormalizedPath {
139    fn as_ref(&self) -> &Path {
140        self.as_path()
141    }
142}
143
144impl AsRef<OsStr> for NormalizedPath {
145    fn as_ref(&self) -> &OsStr {
146        self.as_path().as_os_str()
147    }
148}
149
150impl Deref for NormalizedPath {
151    type Target = Path;
152
153    fn deref(&self) -> &Self::Target {
154        self.as_path()
155    }
156}
157
158impl From<PathBuf> for NormalizedPath {
159    fn from(path: PathBuf) -> Self {
160        Self::new(path)
161    }
162}
163
164impl From<&Path> for NormalizedPath {
165    fn from(path: &Path) -> Self {
166        Self::new(path)
167    }
168}
169
170impl From<String> for NormalizedPath {
171    fn from(path: String) -> Self {
172        Self::new(path)
173    }
174}
175
176impl From<&str> for NormalizedPath {
177    fn from(path: &str) -> Self {
178        Self::new(path)
179    }
180}
181
182impl From<&String> for NormalizedPath {
183    fn from(path: &String) -> Self {
184        Self::new(path)
185    }
186}
187
188impl Serialize for NormalizedPath {
189    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
190    where
191        S: Serializer,
192    {
193        self.path.serialize(serializer)
194    }
195}
196
197impl<'de> Deserialize<'de> for NormalizedPath {
198    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
199    where
200        D: Deserializer<'de>,
201    {
202        PathBuf::deserialize(deserializer).map(Self::new)
203    }
204}
205
206/// Normalize a path by resolving `.` and `..` components without
207/// touching the filesystem (no symlink resolution).
208///
209/// This is intentionally not `canonicalize()` --- we avoid filesystem
210/// access and symlink resolution for performance and determinism.
211#[must_use]
212pub fn normalize(path: &Path) -> PathBuf {
213    use std::path::Component;
214
215    let mut components = Vec::new();
216    for component in path.components() {
217        match component {
218            Component::CurDir => {}
219            Component::ParentDir => {
220                if let Some(Component::Normal(_)) = components.last() {
221                    components.pop();
222                } else {
223                    components.push(component);
224                }
225            }
226            _ => components.push(component),
227        }
228    }
229    components.iter().collect()
230}
231
232/// Normalize a path into a stable string key for hashing and comparisons.
233///
234/// This is the shared representation for path-based cache keys. It avoids
235/// filesystem access, strips Windows extended-length prefixes, normalizes
236/// separators, and folds case on case-insensitive platforms.
237#[must_use]
238pub fn normalize_for_key(path: &Path) -> String {
239    let normalized = normalize(path);
240
241    #[cfg(windows)]
242    {
243        let mut s = normalized.to_string_lossy().replace('\\', "/");
244        if let Some(stripped) = s.strip_prefix("//?/") {
245            s = stripped.to_string();
246        }
247        s.make_ascii_lowercase();
248        s
249    }
250
251    #[cfg(target_os = "macos")]
252    {
253        normalized.to_string_lossy().to_lowercase()
254    }
255
256    #[cfg(not(any(windows, target_os = "macos")))]
257    {
258        normalized.to_string_lossy().into_owned()
259    }
260}
261
262/// Return a compact, stable identifier for a path.
263///
264/// This is intended for filesystem-derived runtime names such as Windows named
265/// pipes where the full normalized path may be too long or contain invalid
266/// characters. It is not a cryptographic digest.
267#[must_use]
268pub fn stable_path_id(path: &Path) -> String {
269    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
270    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
271
272    let key = normalize_for_key(path);
273    let mut hash = FNV_OFFSET;
274    for byte in key.as_bytes() {
275        hash ^= u64::from(*byte);
276        hash = hash.wrapping_mul(FNV_PRIME);
277    }
278    format!("{hash:016x}")
279}
280
281/// Convert an MSYS2/Git Bash style path to a native Windows path.
282///
283/// `/c/Users/foo` → `C:\Users\foo`
284///
285/// On non-Windows platforms, returns the input unchanged.
286/// On Windows, only converts paths matching the MSYS pattern `/<letter>/...`.
287/// Already-native paths (e.g., `C:\...`) pass through unchanged.
288#[must_use]
289pub fn normalize_msys_path(path: &str) -> String {
290    #[cfg(windows)]
291    {
292        let bytes = path.as_bytes();
293        // Match pattern: /X/ or /X (end of string) where X is a-zA-Z
294        if bytes.len() >= 2
295            && bytes[0] == b'/'
296            && bytes[1].is_ascii_alphabetic()
297            && (bytes.len() == 2 || bytes[2] == b'/')
298        {
299            let drive = (bytes[1] as char).to_ascii_uppercase();
300            let rest = if bytes.len() > 2 { &path[2..] } else { "" };
301            return format!("{drive}:{rest}").replace('/', "\\");
302        }
303        path.to_string()
304    }
305    #[cfg(not(windows))]
306    {
307        path.to_string()
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn normalize_removes_dot() {
317        let p = normalize(Path::new("a/./b/c"));
318        assert_eq!(p, PathBuf::from("a/b/c"));
319    }
320
321    #[test]
322    fn normalize_resolves_dotdot() {
323        let p = normalize(Path::new("a/b/../c"));
324        assert_eq!(p, PathBuf::from("a/c"));
325    }
326
327    #[cfg(windows)]
328    #[test]
329    fn normalize_for_key_windows_equivalent_spellings_match() {
330        let a = normalize_for_key(Path::new(r"\\?\C:\Work\src\..\src\main.cpp"));
331        let b = normalize_for_key(Path::new("c:/work/src/main.cpp"));
332        assert_eq!(a, b);
333    }
334
335    #[test]
336    fn msys_path_drive_letter() {
337        let result = normalize_msys_path("/c/Users/foo/bar");
338        #[cfg(windows)]
339        assert_eq!(result, r"C:\Users\foo\bar");
340        #[cfg(not(windows))]
341        assert_eq!(result, "/c/Users/foo/bar");
342    }
343
344    #[test]
345    fn msys_path_uppercase_drive() {
346        let result = normalize_msys_path("/D/project/build");
347        #[cfg(windows)]
348        assert_eq!(result, r"D:\project\build");
349        #[cfg(not(windows))]
350        assert_eq!(result, "/D/project/build");
351    }
352
353    #[test]
354    fn msys_path_bare_drive() {
355        let result = normalize_msys_path("/c");
356        #[cfg(windows)]
357        assert_eq!(result, "C:");
358        #[cfg(not(windows))]
359        assert_eq!(result, "/c");
360    }
361
362    #[test]
363    fn native_windows_path_unchanged() {
364        let result = normalize_msys_path(r"C:\Users\foo\bar");
365        assert_eq!(result, r"C:\Users\foo\bar");
366    }
367
368    #[test]
369    fn relative_path_unchanged() {
370        let result = normalize_msys_path("relative/path");
371        assert_eq!(result, "relative/path");
372    }
373
374    #[test]
375    fn empty_path_unchanged() {
376        let result = normalize_msys_path("");
377        assert_eq!(result, "");
378    }
379
380    #[test]
381    fn unix_absolute_path_not_drive() {
382        // /usr/bin/gcc — bytes[2] is 's', not '/', so NOT a drive letter path
383        let result = normalize_msys_path("/usr/bin/gcc");
384        assert_eq!(result, "/usr/bin/gcc");
385    }
386
387    #[test]
388    fn stable_path_id_is_compact_and_deterministic() {
389        let path = Path::new("a/./b/../cache");
390        assert_eq!(stable_path_id(path), stable_path_id(path));
391        assert_eq!(stable_path_id(path).len(), 16);
392    }
393}