use crate::error::PathError;
use crate::internal::validation::reject_nul_path;
use crate::normalize::normalize;
use crate::platform::{is_verbatim, simplify_for_display, translate_wsl_path};
use crate::text::CaseNormalization;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathIdentityOptions {
pub normalize_lexically: bool,
pub normalize_separators: bool,
pub case: CaseNormalization,
pub resolve_existing_symlinks: bool,
pub strip_windows_verbatim_prefix: bool,
pub translate_wsl_paths: bool,
}
impl Default for PathIdentityOptions {
fn default() -> Self {
Self {
normalize_lexically: true,
normalize_separators: true,
case: CaseNormalization::PlatformDefault,
resolve_existing_symlinks: false,
strip_windows_verbatim_prefix: false,
translate_wsl_paths: false,
}
}
}
impl PathIdentityOptions {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathRecord {
pub path: PathBuf,
pub display: String,
pub identity_key: Option<String>,
}
impl PathRecord {
pub fn from_path(
path: impl AsRef<Path>,
identity: Option<PathIdentityOptions>,
) -> Result<Self, PathError> {
let path = path.as_ref().to_path_buf();
let display = path_display_string(&path);
let identity_key = match identity {
Some(opts) => Some(path_identity_key(&path, opts)?),
None => None,
};
Ok(Self {
path,
display,
identity_key,
})
}
}
pub fn path_identity_key(
path: impl AsRef<Path>,
options: PathIdentityOptions,
) -> Result<String, PathError> {
let path = path.as_ref();
reject_nul_path(path)?;
if path.as_os_str().is_empty() {
return Err(PathError::EmptyInput);
}
let mut working = path.to_path_buf();
if options.translate_wsl_paths {
if let Some(s) = path.to_str() {
if let Some(translated) = translate_wsl_path(s)? {
working = translated;
}
}
}
if options.resolve_existing_symlinks {
if let Ok(canon) = std::fs::canonicalize(&working) {
working = canon;
}
}
if options.strip_windows_verbatim_prefix && is_verbatim(&working) {
working = simplify_for_display(&working);
}
if options.normalize_lexically {
working = normalize(&working)?;
}
let mut key = working.to_string_lossy().into_owned();
if options.normalize_separators {
key = key.replace('\\', "/");
key = collapse_interior_slashes(&key);
key = trim_trailing_slash_keep_root(&key);
}
Ok(options.case.apply(&key))
}
pub fn deduplicate_paths(
paths: impl IntoIterator<Item = PathBuf>,
options: PathIdentityOptions,
) -> Result<Vec<PathBuf>, PathError> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for path in paths {
let key = path_identity_key(&path, options)?;
if seen.insert(key) {
out.push(path);
}
}
Ok(out)
}
pub fn path_display_string(path: &Path) -> String {
path.display().to_string()
}
fn collapse_interior_slashes(s: &str) -> String {
if let Some(rest) = s.strip_prefix("//") {
let mut rem = rest.to_owned();
while rem.contains("//") {
rem = rem.replace("//", "/");
}
format!("//{rem}")
} else {
let mut out = s.to_owned();
while out.contains("//") {
out = out.replace("//", "/");
}
out
}
}
fn trim_trailing_slash_keep_root(s: &str) -> String {
if s == "/" || s == "//" {
return s.to_owned();
}
if s.len() == 3 && s.as_bytes()[1] == b':' && (s.ends_with('/') || s.ends_with('\\')) {
return s.to_owned();
}
let mut out = s.to_owned();
while out.len() > 1 && (out.ends_with('/') || out.ends_with('\\')) {
if out.len() == 3 && out.as_bytes()[1] == b':' {
break;
}
out.pop();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_and_separators() {
let opts = PathIdentityOptions {
case: CaseNormalization::AsciiLowercase,
..PathIdentityOptions::default()
};
let a = path_identity_key(r"C:\Users\Floris\Repo", opts).unwrap();
let b = path_identity_key(r"c:/users/floris/repo/", opts).unwrap();
let c = path_identity_key(r"C:/Users/Floris/Repo/.", opts).unwrap();
assert_eq!(a, b);
assert_eq!(a, c);
}
#[test]
fn dedup_preserves_first() {
let opts = PathIdentityOptions {
case: CaseNormalization::AsciiLowercase,
..PathIdentityOptions::default()
};
let paths = vec![
PathBuf::from(r"C:\Users\Floris\Repo"),
PathBuf::from(r"c:/users/floris/repo"),
PathBuf::from(r"D:\other"),
];
let d = deduplicate_paths(paths, opts).unwrap();
assert_eq!(d.len(), 2);
assert_eq!(d[0], PathBuf::from(r"C:\Users\Floris\Repo"));
}
}