onpath 0.2.0

Get your tools on the PATH — cross-shell, cross-platform, zero fuss
Documentation
use std::path::{Component, Path, PathBuf};

/// Lexically normalize a path for comparison purposes.
///
/// Resolves `.` and `..` components without filesystem access,
/// strips trailing separators, and on Windows normalizes `/` to `\`.
/// The result is suitable for case-insensitive comparison (on Windows)
/// or exact comparison (on Unix).
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn normalize_for_comparison(path: &Path) -> PathBuf {
    let mut components = Vec::new();

    for component in path.components() {
        match component {
            Component::CurDir => {} // skip `.`
            Component::ParentDir => {
                // Pop the last normal component (don't pop past root/prefix)
                if matches!(components.last(), Some(Component::Normal(_))) {
                    components.pop();
                } else {
                    components.push(component);
                }
            }
            _ => components.push(component),
        }
    }

    if components.is_empty() {
        return PathBuf::from(".");
    }

    let result: PathBuf = components.iter().collect();

    // On Windows, normalize forward slashes to backslashes.
    // `Path::components()` already handles this on Windows, so the collected
    // PathBuf will use native separators. No extra work needed.

    result
}

/// Normalize a path string for comparison on Windows.
///
/// Strips trailing backslashes/forward slashes, normalizes `/` to `\`,
/// and resolves `.`/`..` components. Returns the normalized string
/// for case-insensitive comparison.
#[cfg(windows)]
pub(crate) fn normalize_windows_path_str(path: &str) -> String {
    // First normalize slashes
    let normalized = path.replace('/', "\\");

    // Strip trailing backslash (unless it's just the root like `C:\`)
    let trimmed = if normalized.len() > 3 && normalized.ends_with('\\') {
        &normalized[..normalized.len() - 1]
    } else {
        &normalized
    };

    // Use Path normalization for `.`/`..` resolution
    let path = Path::new(trimmed);
    normalize_for_comparison(path)
        .to_string_lossy()
        .into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_removes_dot_components() {
        let result = normalize_for_comparison(Path::new("/usr/./local/bin"));
        assert_eq!(result, PathBuf::from("/usr/local/bin"));
    }

    #[test]
    fn normalize_resolves_parent_components() {
        let result = normalize_for_comparison(Path::new("/home/user/../user/bin"));
        assert_eq!(result, PathBuf::from("/home/user/bin"));
    }

    #[test]
    fn normalize_resolves_multiple_parent_components() {
        let result = normalize_for_comparison(Path::new("/a/b/c/../../d"));
        assert_eq!(result, PathBuf::from("/a/d"));
    }

    #[test]
    fn normalize_preserves_absolute_path() {
        let result = normalize_for_comparison(Path::new("/usr/local/bin"));
        assert_eq!(result, PathBuf::from("/usr/local/bin"));
    }

    #[test]
    fn normalize_handles_root() {
        let result = normalize_for_comparison(Path::new("/"));
        assert_eq!(result, PathBuf::from("/"));
    }

    #[test]
    fn normalize_handles_relative_with_parent() {
        // `../foo` can't resolve `..` past the start
        let result = normalize_for_comparison(Path::new("../foo"));
        assert_eq!(result, PathBuf::from("../foo"));
    }

    #[test]
    fn normalize_empty_becomes_dot() {
        let result = normalize_for_comparison(Path::new(""));
        assert_eq!(result, PathBuf::from("."));
    }

    #[test]
    fn normalize_dot_only() {
        let result = normalize_for_comparison(Path::new("."));
        assert_eq!(result, PathBuf::from("."));
    }

    #[test]
    fn normalize_trailing_slash() {
        // Path::components() strips trailing slashes on Unix
        let result = normalize_for_comparison(Path::new("/usr/local/bin/"));
        assert_eq!(result, PathBuf::from("/usr/local/bin"));
    }
}