1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct PathIdentityOptions {
26 pub normalize_lexically: bool,
28 pub normalize_separators: bool,
30 pub case: CaseNormalization,
32 pub resolve_existing_symlinks: bool,
36 pub strip_windows_verbatim_prefix: bool,
38 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 pub fn new() -> Self {
58 Self::default()
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PathRecord {
67 pub path: PathBuf,
69 pub display: String,
71 pub identity_key: Option<String>,
73}
74
75impl PathRecord {
76 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
95pub 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 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 key = trim_trailing_slash_keep_root(&key);
171 }
172
173 Ok(options.case.apply(&key))
174}
175
176pub 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
198pub fn path_display_string(path: &Path) -> String {
200 path.display().to_string()
201}
202
203fn collapse_interior_slashes(s: &str) -> String {
204 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 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 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}