Skip to main content

path_rs/
identity.rs

1//! Path identity keys, deduplication, and display records.
2//!
3//! An identity key is a **comparison key**, not proof that two paths refer to
4//! the same filesystem object. Lexical identity and filesystem identity differ.
5
6use crate::error::PathError;
7use crate::internal::validation::reject_nul_path;
8use crate::normalize::normalize;
9use crate::platform::{is_verbatim, simplify_for_display, translate_wsl_path};
10use crate::text::CaseNormalization;
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14/// Options controlling how a path identity key is produced.
15///
16/// # Defaults
17///
18/// - Lexical normalize
19/// - Normalize separators to `/`
20/// - Platform default case (lowercase on Windows, preserve on Unix)
21/// - Do **not** resolve symlinks
22/// - Do **not** strip Windows verbatim prefixes
23/// - Do **not** translate WSL paths
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct PathIdentityOptions {
26    /// Apply lexical [`crate::normalize`] before keying.
27    pub normalize_lexically: bool,
28    /// Normalize path separators to `/` in the key string.
29    pub normalize_separators: bool,
30    /// Case policy for the key.
31    pub case: CaseNormalization,
32    /// When true, attempt `canonicalize` if the path exists (resolves symlinks).
33    ///
34    /// Failed canonicalize falls back to the lexical form.
35    pub resolve_existing_symlinks: bool,
36    /// When true, simplify Windows verbatim prefixes via `dunce` for the key.
37    pub strip_windows_verbatim_prefix: bool,
38    /// When true and the path looks like `/mnt/<drive>/...`, translate to Windows form first.
39    pub translate_wsl_paths: bool,
40}
41
42impl Default for PathIdentityOptions {
43    fn default() -> Self {
44        Self {
45            normalize_lexically: true,
46            normalize_separators: true,
47            case: CaseNormalization::PlatformDefault,
48            resolve_existing_symlinks: false,
49            strip_windows_verbatim_prefix: false,
50            translate_wsl_paths: false,
51        }
52    }
53}
54
55impl PathIdentityOptions {
56    /// Create default options (`Self::default()`).
57    pub fn new() -> Self {
58        Self::default()
59    }
60}
61
62/// A path together with optional display text and identity key.
63///
64/// The identity key must never be used as a filesystem path.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PathRecord {
67    /// Path suitable for filesystem access (when valid on the host).
68    pub path: PathBuf,
69    /// User-facing display string (not an identity key).
70    pub display: String,
71    /// Optional comparison key; never use for filesystem I/O.
72    pub identity_key: Option<String>,
73}
74
75impl PathRecord {
76    /// Build a record from a path, optionally computing an identity key.
77    pub fn from_path(
78        path: impl AsRef<Path>,
79        identity: Option<PathIdentityOptions>,
80    ) -> Result<Self, PathError> {
81        let path = path.as_ref().to_path_buf();
82        let display = path_display_string(&path);
83        let identity_key = match identity {
84            Some(opts) => Some(path_identity_key(&path, opts)?),
85            None => None,
86        };
87        Ok(Self {
88            path,
89            display,
90            identity_key,
91        })
92    }
93}
94
95/// Produce a deterministic path identity / comparison key.
96///
97/// # Filesystem access
98///
99/// Only when `resolve_existing_symlinks` is true (may call `canonicalize`).
100/// Does not expand environment variables. Does not use a cache.
101///
102/// # Returns
103///
104/// A `String` **unsuitable** for direct filesystem access. It is a comparison
105/// key only.
106///
107/// # Platform behavior
108///
109/// - Windows default case folding is ASCII-lowercase.
110/// - Linux default preserves case.
111/// - Symlink resolution changes identity semantics when enabled.
112/// - Unicode normalization is not applied unless the `unicode` feature helpers
113///   are used separately.
114///
115/// # Examples
116///
117/// ```
118/// use path_rs::{path_identity_key, CaseNormalization, PathIdentityOptions};
119///
120/// let opts = PathIdentityOptions {
121///     case: CaseNormalization::AsciiLowercase,
122///     ..PathIdentityOptions::default()
123/// };
124/// let a = path_identity_key(r"C:\Users\Floris\Repo", opts).unwrap();
125/// let b = path_identity_key(r"c:/users/floris/repo/", opts).unwrap();
126/// assert_eq!(a, b);
127/// ```
128pub fn path_identity_key(
129    path: impl AsRef<Path>,
130    options: PathIdentityOptions,
131) -> Result<String, PathError> {
132    let path = path.as_ref();
133    reject_nul_path(path)?;
134
135    if path.as_os_str().is_empty() {
136        return Err(PathError::EmptyInput);
137    }
138
139    let mut working = path.to_path_buf();
140
141    if options.translate_wsl_paths {
142        if let Some(s) = path.to_str() {
143            if let Some(translated) = translate_wsl_path(s)? {
144                working = translated;
145            }
146        }
147    }
148
149    if options.resolve_existing_symlinks {
150        if let Ok(canon) = std::fs::canonicalize(&working) {
151            working = canon;
152        }
153    }
154
155    if options.strip_windows_verbatim_prefix && is_verbatim(&working) {
156        working = simplify_for_display(&working);
157    }
158
159    if options.normalize_lexically {
160        working = normalize(&working)?;
161    }
162
163    // Prefer lossy only for the comparison key when the path is non-UTF-8.
164    let mut key = working.to_string_lossy().into_owned();
165
166    if options.normalize_separators {
167        key = key.replace('\\', "/");
168        key = collapse_interior_slashes(&key);
169        // Trim trailing slash except for root forms like `C:/` or `/`.
170        key = trim_trailing_slash_keep_root(&key);
171    }
172
173    Ok(options.case.apply(&key))
174}
175
176/// Deduplicate paths using identity keys; preserve first occurrence order.
177///
178/// Paths that fail key generation produce an error (fail-fast).
179///
180/// # Filesystem access
181///
182/// Same as [`path_identity_key`] for each path (only if options request it).
183pub fn deduplicate_paths(
184    paths: impl IntoIterator<Item = PathBuf>,
185    options: PathIdentityOptions,
186) -> Result<Vec<PathBuf>, PathError> {
187    let mut seen = HashSet::new();
188    let mut out = Vec::new();
189    for path in paths {
190        let key = path_identity_key(&path, options)?;
191        if seen.insert(key) {
192            out.push(path);
193        }
194    }
195    Ok(out)
196}
197
198/// User-facing display string for a path (not an identity key).
199pub fn path_display_string(path: &Path) -> String {
200    path.display().to_string()
201}
202
203fn collapse_interior_slashes(s: &str) -> String {
204    // Keep a leading "//" for UNC-ish keys; collapse only the remainder so we
205    // never loop forever on a preserved prefix.
206    if let Some(rest) = s.strip_prefix("//") {
207        let mut rem = rest.to_owned();
208        while rem.contains("//") {
209            rem = rem.replace("//", "/");
210        }
211        format!("//{rem}")
212    } else {
213        let mut out = s.to_owned();
214        while out.contains("//") {
215            out = out.replace("//", "/");
216        }
217        out
218    }
219}
220
221fn trim_trailing_slash_keep_root(s: &str) -> String {
222    if s == "/" || s == "//" {
223        return s.to_owned();
224    }
225    // Drive root `C:/` or `c:/`
226    if s.len() == 3 && s.as_bytes()[1] == b':' && (s.ends_with('/') || s.ends_with('\\')) {
227        return s.to_owned();
228    }
229    let mut out = s.to_owned();
230    while out.len() > 1 && (out.ends_with('/') || out.ends_with('\\')) {
231        // Don't strip to empty; don't strip drive root.
232        if out.len() == 3 && out.as_bytes()[1] == b':' {
233            break;
234        }
235        out.pop();
236    }
237    out
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn case_and_separators() {
246        let opts = PathIdentityOptions {
247            case: CaseNormalization::AsciiLowercase,
248            ..PathIdentityOptions::default()
249        };
250        let a = path_identity_key(r"C:\Users\Floris\Repo", opts).unwrap();
251        let b = path_identity_key(r"c:/users/floris/repo/", opts).unwrap();
252        let c = path_identity_key(r"C:/Users/Floris/Repo/.", opts).unwrap();
253        assert_eq!(a, b);
254        assert_eq!(a, c);
255    }
256
257    #[test]
258    fn dedup_preserves_first() {
259        let opts = PathIdentityOptions {
260            case: CaseNormalization::AsciiLowercase,
261            ..PathIdentityOptions::default()
262        };
263        let paths = vec![
264            PathBuf::from(r"C:\Users\Floris\Repo"),
265            PathBuf::from(r"c:/users/floris/repo"),
266            PathBuf::from(r"D:\other"),
267        ];
268        let d = deduplicate_paths(paths, opts).unwrap();
269        assert_eq!(d.len(), 2);
270        assert_eq!(d[0], PathBuf::from(r"C:\Users\Floris\Repo"));
271    }
272}