globmatch 0.2.3

Cross platform glob matching with relative path components, against list of candidate paths determined by recursive iteration from a root directory.
Documentation
use std::io;
use std::path;

/// Resolves the root for the pattern and the given path prefix.
///
/// E.g., for the prefix `/home/some/folder` and pattern `../../*.c` this function will resolve
/// the root folder to `/home/some/folder/../../` and removes the relative path components from
/// the pattern, resulting in the remainder `*.c`.
///
/// Both, the resolved root path and the remaining pattern are provided as tuple `Some(root, rest)`.
/// If the provided `prefix` is not a valid path this function returns an `io::Error`.
#[allow(clippy::needless_lifetimes)]
pub fn resolve_root<'a, P>(
    prefix: P,
    pattern: &'a str,
) -> Result<(path::PathBuf, &'a str), io::Error>
where
    P: AsRef<path::Path>,
{
    // TODO: is there such a thing as Cow for Path?
    let mut root = path::PathBuf::from(prefix.as_ref());
    let mut rest = path::PathBuf::new();

    if pattern.is_empty() {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty pattern"));
    }

    if !root.as_path().exists() {
        return Err(io::Error::from(io::ErrorKind::NotFound));
    }

    if path::Path::new(pattern).is_absolute() {
        return Err(io::Error::new(
            io::ErrorKind::Unsupported,
            format!("'{pattern}' is an absolute path"),
        ));
    }

    // try to found a common root path from which the recursive search would start. notice that
    // it may happen that the relative path component of the pattern is not a valid path, e.g.,
    // the prefix might go back many levels and then to a folder that doesn not exist. such
    // an error is not caught here since we do not differ between path names and patterns and will
    // only lead to zero matches during the matching procedure.

    // println!("resolve root for {:?} -> {}", prefix.as_ref(), pattern);
    let mut push_root = true;
    path::Path::new(pattern).components().for_each(|c| {
        if push_root {
            root.push(c);

            // notice that a path exists even if the number of "../" is beyond the root.
            // thus all superfluous "../" will simply be consumed by this iterator.
            if !root.exists() {
                root.pop();
                rest.push(c);
                push_root = false;
            }
        } else {
            rest.push(c);
        }
    });

    // Workaround for empty patterns: Keep the path component within the pattern such that
    // it will be matched. globset is not able to match empty patterns.
    if rest.components().count() == 0 {
        if let Some(c) = root.components().next_back() {
            if let path::Component::Normal(_) = c {
                rest.push(c);
                root.pop();
            }
        }
    }

    // do not canonicalize the root directory, the user can decide to do this. otherwise
    // matching against patterns that also use relative paths will be impossible.
    // let root = root.canonicalize()?;
    // println!(" -- root {:?}\n    rest {}", root, rest.to_str().unwrap());

    // patterns can have no relative paths (after selectors) since it would be possible to
    // "move" out of the pattern using "../" and changing into a directory outside of root.
    // do not allow such patterns (though the levels could be checked).
    if rest
        .components()
        .any(|c| matches!(c, path::Component::ParentDir))
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!(
                "pattern remainder '{}' contains unresolved relative path components",
                rest.to_str().unwrap()
            ),
        ));
    }

    // notice that calling unwrap() is safe since we created the PathBuf from the pattern,
    let rest = &pattern[pattern.len() - rest.to_str().unwrap().len()..];
    Ok((root, rest))
}

/// Transforms the first character of a string to uppercase.
pub(crate) fn to_upper(s: String) -> String {
    let mut c = s.chars();
    match c.next() {
        None => s,
        Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
    }
}

/// Checks if the provided path is a hidden "entry".
///
/// An entry is hidden if its final path component (filename or directory name) starts with a dot,
/// e.g., `.git` or `.clang-format` are hidden entries.
///
/// This function can be used in [IterAll::filter_entry](./struct.IterAll.html#method.filter_entry)
/// to avoid iterating through hidden paths.
pub fn is_hidden_entry<P>(path: P) -> bool
where
    P: AsRef<path::Path>,
{
    let is_hidden = path
        .as_ref()
        .file_name()
        .unwrap_or_else(|| path.as_ref().as_os_str())
        .to_str()
        .map(|s| s.starts_with('.'))
        .unwrap_or(false);
    is_hidden
}

/// Checks if the provided path has a hidden path component.
///
/// A path is hidden if one of its path component (filename or directory name) starts with a dot.
pub fn is_hidden_path<P>(path: P) -> bool
where
    P: AsRef<path::Path>,
{
    let has_hidden = path.as_ref().components().find(|c| {
        c.as_os_str()
            .to_str()
            .map(|s| s.starts_with('.'))
            .unwrap_or(false)
    });

    !matches!(has_hidden, None)
}

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

    use super::resolve_root;
    use std::{io, path};

    #[test]
    /// This test just demonstrates that this crate "gracefully" handles relative paths that
    /// would go outside of the file system (go back more levels than exist in the actual path)
    /// just like `ls` does: `ls` will return the root path (`/` on unix) in case a relative
    /// path goes back too many levels.
    ///
    /// On windows
    #[cfg_attr(target_os = "windows", ignore)]
    fn outside_root() -> Result<(), std::io::Error> {
        let root = path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let levels = vec!["../"; root.components().count() * 2];
        let pattern = levels.join("") + "*.txt";

        // let root_first = path::Path::new(root.components().next().unwrap().as_os_str());
        // let root_first = root_first
        //     .to_str()
        //     .ok_or(io::Error::from(io::ErrorKind::Other))?;

        let (root, rest) = resolve_root(root, pattern.as_str())?;
        let root = root.canonicalize()?;
        let root = root
            .to_str()
            .ok_or_else(|| io::Error::from(io::ErrorKind::Other))?;

        if !cfg!(windows) {
            // assert_eq!(root, root_first); // cannot test against "/" on windows
            // test demonstrates that we still don't get a panic.
            assert_eq!(root, "/");
            assert_eq!(rest, "*.txt");
        }
        Ok(())
    }

    #[test]
    fn patterns() -> Result<(), String> {
        fn tst(root: &str, pattern: &str, exp_root: &str, exp_pattern: &str) -> Result<(), String> {
            let root = format!("{}/{}", env!("CARGO_MANIFEST_DIR"), root);

            let (root, pattern) = resolve_root(root, pattern).map_err(|err| err.to_string())?;

            let root = root.canonicalize().map_err(|err| err.to_string())?;
            let root = root
                .to_str()
                .ok_or_else(|| io::Error::from(io::ErrorKind::Other))
                .map_err(|err| err.to_string())?;

            // forward slash won't work
            // let exp_root = format!(
            //     "{}{}",
            //     env!("CARGO_MANIFEST_DIR"),
            //     match exp_root {
            //         "" => "".to_string(),
            //         p => format!("/{}", p),
            //     }
            // );

            let exp_root = format!(
                "{}{}",
                env!("CARGO_MANIFEST_DIR"),
                match exp_root {
                    "" => "".to_string(),
                    p => format!("/{p}"),
                }
            );
            let exp_root = path::PathBuf::from(exp_root)
                .canonicalize()
                .map_err(|err| err.to_string())?;
            let exp_root = exp_root
                .to_str()
                .ok_or_else(|| io::Error::from(io::ErrorKind::Other))
                .map_err(|err| err.to_string())?;

            assert_eq!(root, exp_root);
            assert_eq!(pattern, exp_pattern);
            Ok(())
        }
        // notice how a relative path can result in an empty pattern, workaround implemented!
        // err(tst("test-files/c-simple", "../test-files/c-simple", "test-files/c-simple", ""))?;
        tst("test-files/c-simple/a", "../a", "test-files/c-simple", "a")?;

        // if !cfg!(windows) {
        tst(
            "test-files/c-simple",
            "*.txt",
            "test-files/c-simple",
            "*.txt",
        )?;
        // }

        tst("test-files/c-simple/a/a0", "../../../../*.txt", "", "*.txt")?;

        tst(
            "test-files/c-simple/a/a0",
            "a0_0.txt",
            "test-files/c-simple/a/a0",
            "a0_0.txt",
        )?;

        tst(
            "test-files/c-simple/a/a0",
            "../a0/a0_0.txt",
            "test-files/c-simple/a/a0",
            "a0_0.txt",
        )?;
        Ok(())
    }
}