travelagent-core 1.10.3

Core library for travelagent code review tool
Documentation
//! Minimal glob matcher shared by `risk` and `auto_collapse`.
//!
//! Supports three pattern tokens:
//! * literal characters
//! * `*` — matches zero-or-more characters within a single path segment
//!   (does not cross `/`)
//! * `**` — matches zero-or-more characters including `/` (spans segments)
//!
//! Matching is case-sensitive. Paths are normalised to forward slashes before
//! comparison so Windows-style separators match the same globs.

use std::path::Path;

/// Normalise a path for glob matching — lossy-decode to UTF-8 and convert
/// backslashes to forward slashes so Windows paths work with `**/...` globs.
pub fn path_str(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

/// Return true when any glob in `globs` matches `path`. Uses [`glob_match`]
/// under the hood.
pub fn any_glob_matches(globs: &[String], path: &Path) -> bool {
    let p = path_str(path);
    globs.iter().any(|g| glob_match(g, &p))
}

/// Minimal glob matcher supporting `*` (within a segment), `**` (spans
/// segments), and literal characters. Case-sensitive.
pub fn glob_match(pattern: &str, path: &str) -> bool {
    #[derive(Debug, Clone)]
    enum Tok {
        Char(char),
        Star,       // matches anything except `/`, zero-or-more chars
        DoubleStar, // matches anything including `/`, zero-or-more chars
    }

    let mut tokens: Vec<Tok> = Vec::with_capacity(pattern.len());
    let bytes: Vec<char> = pattern.chars().collect();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == '*' {
            if i + 1 < bytes.len() && bytes[i + 1] == '*' {
                tokens.push(Tok::DoubleStar);
                i += 2;
                // Eat an optional trailing '/' so `docs/**/x.md` matches
                // both `docs/x.md` (no intermediate segment) and
                // `docs/a/x.md`.
                if i < bytes.len() && bytes[i] == '/' {
                    i += 1;
                }
            } else {
                tokens.push(Tok::Star);
                i += 1;
            }
        } else {
            tokens.push(Tok::Char(bytes[i]));
            i += 1;
        }
    }

    let chars: Vec<char> = path.chars().collect();
    // DP[i][j] = pattern tokens[..i] matches path chars[..j].
    let m = tokens.len();
    let n = chars.len();
    let mut dp = vec![vec![false; n + 1]; m + 1];
    dp[0][0] = true;
    // A leading run of Star/DoubleStar can match an empty path.
    for i in 1..=m {
        match tokens[i - 1] {
            Tok::Star | Tok::DoubleStar => dp[i][0] = dp[i - 1][0],
            Tok::Char(_) => {}
        }
    }
    for i in 1..=m {
        for j in 1..=n {
            match &tokens[i - 1] {
                Tok::Char(c) => {
                    if chars[j - 1] == *c {
                        dp[i][j] = dp[i - 1][j - 1];
                    }
                }
                Tok::Star => {
                    // Either match zero chars (advance token) or consume
                    // one non-'/' char (stay on this token).
                    let skip = dp[i - 1][j];
                    let stay = dp[i][j - 1] && chars[j - 1] != '/';
                    dp[i][j] = skip || stay;
                }
                Tok::DoubleStar => {
                    let skip = dp[i - 1][j];
                    let stay = dp[i][j - 1];
                    dp[i][j] = skip || stay;
                }
            }
        }
    }
    dp[m][n]
}

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

    #[test]
    fn double_star_matches_deep_paths() {
        assert!(glob_match("**/*.rs", "src/foo.rs"));
        assert!(glob_match("**/*.rs", "deep/nested/bar.rs"));
        assert!(glob_match("docs/**", "docs/a.md"));
        assert!(glob_match("docs/**", "docs/sub/a.md"));
    }

    #[test]
    fn single_star_does_not_cross_slash() {
        assert!(!glob_match("*.rs", "src/foo.rs"));
        assert!(glob_match("*.rs", "foo.rs"));
    }

    #[test]
    fn star_suffix_works_on_dotfiles() {
        assert!(glob_match("*.env*", ".env.local"));
    }

    #[test]
    fn any_glob_matches_short_circuits_empty_list() {
        assert!(!any_glob_matches(&[], &std::path::PathBuf::from("foo.rs")));
    }

    #[test]
    fn path_str_normalises_backslashes() {
        assert_eq!(path_str(&std::path::PathBuf::from("a\\b\\c")), "a/b/c");
    }
}