ts-rust-helper 0.11.0

Various helper functions, structures, and traits for working on my Rust projects.
Documentation
//! Helpers for working with paths.

use core::{iter::repeat_n, slice};
use std::{
    borrow::Cow,
    env::current_dir,
    fs,
    path::{Component, MAIN_SEPARATOR_STR, Path, PathBuf, Prefix},
};

/// Extension trait for paths.
pub trait RelativePath {
    /// Returns the path relative to another path.
    fn relative_to<P: AsRef<Path>>(&self, source: P) -> PathBuf;

    /// Returns the path relative to the current path, falling back to the target path.
    fn relative_to_current_dir(&self) -> PathBuf;

    /// Converts the path to a string for display.
    fn opinionated_display(&self) -> String;
}

impl<P: AsRef<Path>> RelativePath for P {
    fn relative_to<P2: AsRef<Path>>(&self, source: P2) -> PathBuf {
        fn naive_normalize(path: &Path) -> Vec<Component<'_>> {
            let mut output = Vec::new();

            for component in path.components() {
                match &component {
                    Component::ParentDir => {
                        output.pop();
                    }
                    Component::CurDir => {}
                    _ => output.push(component),
                }
            }

            output
        }

        let source =
            fs::canonicalize(source.as_ref()).unwrap_or_else(|_| source.as_ref().to_path_buf());
        let target =
            fs::canonicalize(self.as_ref()).unwrap_or_else(|_| self.as_ref().to_path_buf());

        let source_components = naive_normalize(&source);
        let target_components = naive_normalize(&target);

        let diverge_index = {
            let mut index = 0;

            for source_component in source_components.iter() {
                let Some(target_component) = target_components.get(index) else {
                    break;
                };

                if source_component != target_component {
                    break;
                }

                index += 1;
            }

            index
        };

        let output_components: Vec<_> = repeat_n(
            &Component::ParentDir,
            source_components.len() - diverge_index,
        )
        .chain(target_components.get(diverge_index..).unwrap_or_default())
        .collect();

        if output_components.is_empty() {
            PathBuf::from_iter(&[Component::CurDir])
        } else {
            PathBuf::from_iter(output_components)
        }
    }

    fn relative_to_current_dir(&self) -> PathBuf {
        let Ok(current_dir) = current_dir() else {
            return self.as_ref().to_path_buf();
        };

        self.relative_to(current_dir)
    }

    fn opinionated_display(&self) -> String {
        if self.as_ref() == Path::new("") {
            ".".to_string()
        } else {
            let path = self.as_ref();
            let has_prefix = path
                .components()
                .any(|component| matches!(component, Component::Prefix(_)));

            let separator = if has_prefix { MAIN_SEPARATOR_STR } else { "/" };

            self.as_ref()
                .components()
                .filter_map(|component| match component {
                    Component::Prefix(prefix_component) => match prefix_component.kind() {
                        Prefix::VerbatimUNC(hostname, share) | Prefix::UNC(hostname, share) => {
                            Some(Cow::Owned(format!(
                                "\\\\{}\\{}",
                                hostname.to_string_lossy(),
                                share.to_string_lossy()
                            )))
                        }
                        Prefix::VerbatimDisk(disk) | Prefix::Disk(disk) => {
                            let letter = str::from_utf8(slice::from_ref(&disk)).unwrap_or("C");
                            Some(Cow::Owned(format!("{letter}:")))
                        }
                        Prefix::DeviceNS(namespace) => Some(Cow::Owned(format!(
                            "\\\\.\\{}",
                            namespace.to_string_lossy()
                        ))),
                        Prefix::Verbatim(os_str) => Some(os_str.to_string_lossy()),
                    },
                    Component::RootDir => {
                        if has_prefix {
                            None
                        } else {
                            Some(Cow::Borrowed(""))
                        }
                    }
                    Component::CurDir => Some(Cow::Borrowed(".")),
                    Component::ParentDir => Some(Cow::Borrowed("..")),
                    Component::Normal(os_str) => Some(os_str.to_string_lossy()),
                })
                .collect::<Vec<_>>()
                .join(separator)
        }
    }
}

#[cfg(test)]
mod test {

    use std::path::{Path, PathBuf};

    use crate::path::RelativePath;

    #[test]
    fn relative_to() {
        let source = Path::new("/root/dir-a/dir-b");
        let target = Path::new("/root/dir-c/dir-d");
        assert_eq!(
            PathBuf::from("../../dir-c/dir-d"),
            target.relative_to(source)
        );

        let source = Path::new("dir-a/dir-b");
        let target = Path::new("dir-a/dir-b");
        assert_eq!(PathBuf::from("."), target.relative_to(source));

        let source = Path::new("../dir-a/dir-b");
        let target = Path::new("./dir-a/./dir-b");
        assert_eq!(PathBuf::from("."), target.relative_to(source));
    }
}