luff 0.2.1

Print files with formatting
Documentation
//! Glob pattern matching utilities
//!
//! This module provides a wrapper around `globset` to handle glob pattern matching
//! for file ignores. It encapsulates the complexity of compiling glob sets and
//! provides a thread-safe, shareable matcher.

use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::Path;
use std::sync::Arc;
use thiserror::Error;

/// Errors that can occur when compiling glob patterns
#[derive(Debug, Error)]
pub enum GlobError {
    /// A glob pattern string was syntactically invalid
    #[error("invalid glob pattern '{pattern}': {source}")]
    InvalidPattern {
        /// The pattern that failed to parse
        pattern: String,
        /// The underlying globset parse error
        source: globset::Error,
    },

    /// The compiled glob set could not be built (internal globset error)
    #[error("failed to build glob set: {0}")]
    BuildFailed(#[from] globset::Error),
}

/// A thread-safe glob pattern matcher
///
/// Wraps a `GlobSet` in an `Arc` to allow cheap cloning and sharing across threads.
/// This is essential for passing the matcher between configuration, walkers, and printers.
#[derive(Debug, Clone)]
pub struct GlobMatcher {
    /// The compiled glob set
    matcher: Arc<GlobSet>,
}

impl GlobMatcher {
    /// Create a new matcher from a list of glob patterns
    ///
    /// Accepts any slice of types that can be borrowed as `&str`, so callers
    /// can pass `&[String]`, `&[&str]`, `&[Cow<str>]`, etc. without
    /// intermediate allocation.
    ///
    /// # Arguments
    ///
    /// * `patterns` - A list of glob pattern strings (e.g., "*.rs", "target/**")
    ///
    /// # Errors
    ///
    /// Returns [`GlobError::InvalidPattern`] if any pattern is syntactically
    /// invalid, or [`GlobError::BuildFailed`] if the compiled set cannot be
    /// constructed.
    pub fn new<S: AsRef<str>>(patterns: &[S]) -> Result<Self, GlobError> {
        let mut builder = GlobSetBuilder::new();
        for pattern in patterns {
            let pat = pattern.as_ref();
            let glob = Glob::new(pat).map_err(|source| GlobError::InvalidPattern {
                pattern: pat.to_owned(),
                source,
            })?;
            let _ = builder.add(glob);
        }
        let matcher = builder.build()?;
        Ok(Self {
            matcher: Arc::new(matcher),
        })
    }

    /// Create an empty matcher that matches nothing
    #[must_use]
    pub fn empty() -> Self {
        Self {
            matcher: Arc::new(GlobSet::empty()),
        }
    }

    /// Check if a path matches any of the configured patterns
    ///
    /// # Arguments
    ///
    /// * `path` - The path to check
    #[must_use]
    pub fn is_match(&self, path: &Path) -> bool {
        self.matcher.is_match(path)
    }
}

impl Default for GlobMatcher {
    fn default() -> Self {
        Self::empty()
    }
}

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

    #[test]
    fn test_empty_matcher() {
        let matcher = GlobMatcher::empty();
        assert!(!matcher.is_match(Path::new("test.txt")));
    }

    #[test]
    fn test_extension_match() {
        let matcher = GlobMatcher::new(&["*.rs"]).unwrap();
        assert!(matcher.is_match(Path::new("main.rs")));
        assert!(matcher.is_match(Path::new("src/lib.rs")));
        assert!(!matcher.is_match(Path::new("README.md")));
    }

    #[test]
    fn test_directory_match() {
        let matcher = GlobMatcher::new(&["target/**"]).unwrap();
        assert!(matcher.is_match(Path::new("target/debug/luff")));
        assert!(!matcher.is_match(Path::new("src/main.rs")));
    }

    #[test]
    fn test_multiple_patterns() {
        let matcher = GlobMatcher::new(&["*.rs", "*.md"]).unwrap();
        assert!(matcher.is_match(Path::new("main.rs")));
        assert!(matcher.is_match(Path::new("README.md")));
        assert!(!matcher.is_match(Path::new("config.toml")));
    }

    #[test]
    fn test_accepts_owned_strings() {
        let patterns = vec!["*.rs".to_string(), "*.md".to_string()];
        let matcher = GlobMatcher::new(&patterns).unwrap();
        assert!(matcher.is_match(Path::new("main.rs")));
    }

    #[test]
    fn test_accepts_str_slices() {
        let matcher = GlobMatcher::new(&["*.rs", "*.md"]).unwrap();
        assert!(matcher.is_match(Path::new("main.rs")));
    }

    #[test]
    fn test_invalid_pattern() {
        let result = GlobMatcher::new(&["["]);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(matches!(err, GlobError::InvalidPattern { .. }));
        // Verify the error message contains the offending pattern
        let msg = err.to_string();
        assert!(msg.contains('['), "error should contain the pattern: {msg}");
    }
}