prek 0.5.3

A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.
use std::fmt::Display;
use std::path::Path;

use anyhow::Result;
use fancy_regex::{BytesMode, Regex, RegexBuilder};
use globset::{Glob, GlobSet};
use itertools::Itertools;
use serde::de::{DeserializeSeed, Error as DeError, MapAccess, Visitor};
use serde::{Deserialize, Deserializer};

#[derive(Clone)]
pub(crate) struct GlobPatterns {
    patterns: Vec<Glob>,
    set: GlobSet,
}

impl GlobPatterns {
    pub(crate) fn new(patterns: Vec<String>) -> Result<Self, globset::Error> {
        let patterns = patterns
            .into_iter()
            .map(|pattern| pattern.parse())
            .collect::<Result<Vec<_>, _>>()?;
        Self::from_globs(patterns)
    }

    pub(crate) fn from_globs(patterns: Vec<Glob>) -> Result<Self, globset::Error> {
        let mut builder = GlobSet::builder();
        for pattern in &patterns {
            builder.add(pattern.clone());
        }
        let set = builder.build()?;
        Ok(Self { patterns, set })
    }

    pub(crate) fn is_empty(&self) -> bool {
        self.patterns.is_empty()
    }

    pub(crate) fn is_match(&self, path: &Path) -> bool {
        self.set.is_match(path)
    }
}

pub(super) fn debug_globs(globs: &[Glob]) -> impl std::fmt::Debug + '_ {
    std::fmt::from_fn(|f| {
        f.debug_list()
            .entries(globs.iter().map(Glob::glob))
            .finish()
    })
}

impl std::fmt::Debug for GlobPatterns {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GlobPatterns")
            .field("patterns", &debug_globs(&self.patterns))
            .finish_non_exhaustive()
    }
}

enum FilePatternWire {
    Glob { glob: String },
    GlobList { glob: Vec<String> },
    Regex(String),
}

impl<'de> Deserialize<'de> for FilePatternWire {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct FilePatternVisitor;
        struct GlobFieldVisitor;

        impl<'de> DeserializeSeed<'de> for GlobFieldVisitor {
            type Value = FilePatternWire;

            fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
            where
                D: Deserializer<'de>,
            {
                deserializer.deserialize_any(self)
            }
        }

        impl<'de> Visitor<'de> for GlobFieldVisitor {
            type Value = FilePatternWire;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str("a string or a list of strings")
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Glob {
                    glob: value.to_owned(),
                })
            }

            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Glob { glob: value })
            }

            fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
            where
                A: serde::de::SeqAccess<'de>,
            {
                Ok(FilePatternWire::GlobList {
                    glob: Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(
                        seq,
                    ))?,
                })
            }
        }

        impl<'de> Visitor<'de> for FilePatternVisitor {
            type Value = FilePatternWire;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str(
                    "a regex string or a mapping with `glob` set to a string or list of strings",
                )
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Regex(value.to_owned()))
            }

            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                Ok(FilePatternWire::Regex(value))
            }

            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
            where
                M: MapAccess<'de>,
            {
                let mut glob = None;

                while let Some(key) = map.next_key::<String>()? {
                    match key.as_str() {
                        "glob" => {
                            if glob.is_some() {
                                return Err(M::Error::duplicate_field("glob"));
                            }
                            glob = Some(map.next_value_seed(GlobFieldVisitor)?);
                        }
                        _ => {
                            return Err(M::Error::unknown_field(&key, &["glob"]));
                        }
                    }
                }

                glob.ok_or_else(|| M::Error::missing_field("glob"))
            }
        }

        deserializer.deserialize_any(FilePatternVisitor)
    }
}

#[derive(Debug, thiserror::Error)]
enum FilePatternWireError {
    #[error(transparent)]
    Glob(#[from] globset::Error),

    #[error(transparent)]
    Regex(#[from] fancy_regex::Error),
}

#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "FilePatternWire")]
pub(crate) enum FilePattern {
    Never,
    Regex(Regex),
    Glob(GlobPatterns),
}

impl FilePattern {
    pub(crate) fn glob(patterns: Vec<String>) -> Result<Self, globset::Error> {
        Ok(Self::Glob(GlobPatterns::new(patterns)?))
    }

    pub(crate) fn regex(pattern: &str) -> Result<Self, fancy_regex::Error> {
        let regex = RegexBuilder::new(pattern)
            .bytes_mode(BytesMode::UnicodeBytes)
            .build()?;
        Ok(Self::Regex(regex))
    }

    pub(crate) fn is_match(&self, path: &Path) -> bool {
        match self {
            FilePattern::Never => false,
            FilePattern::Regex(regex) => regex
                .is_match(path.as_os_str().as_encoded_bytes())
                .unwrap_or(false),
            FilePattern::Glob(globs) => globs.is_match(path),
        }
    }
}

impl Display for FilePattern {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            FilePattern::Never => f.write_str("never"),
            FilePattern::Regex(regex) => write!(f, "regex: {}", regex.as_str()),
            FilePattern::Glob(globs) => {
                let patterns = globs.patterns.iter().join(", ");
                write!(f, "glob: [{patterns}]")
            }
        }
    }
}

impl TryFrom<FilePatternWire> for FilePattern {
    type Error = FilePatternWireError;

    fn try_from(value: FilePatternWire) -> Result<Self, Self::Error> {
        match value {
            FilePatternWire::Glob { glob } => Ok(Self::glob(vec![glob])?),
            FilePatternWire::GlobList { glob } => Ok(Self::glob(glob)?),
            FilePatternWire::Regex(pattern) => Ok(Self::regex(&pattern)?),
        }
    }
}

#[cfg(test)]
mod tests {
    use std::assert_matches;

    use super::*;

    #[test]
    fn parse_file_patterns_regex_and_glob() {
        #[derive(Debug, Deserialize)]
        struct Wrapper {
            files: FilePattern,
            exclude: FilePattern,
        }

        let regex_yaml = indoc::indoc! {r"
            files: ^src/
            exclude: ^target/
        "};
        let parsed: Wrapper =
            serde_saphyr::from_str(regex_yaml).expect("regex patterns should parse");
        assert_matches!(
            parsed.files,
            FilePattern::Regex(_),
            "expected regex pattern"
        );
        assert!(parsed.files.is_match(Path::new("src/main.rs")));
        assert!(!parsed.files.is_match(Path::new("other/main.rs")));
        assert!(parsed.exclude.is_match(Path::new("target/debug/app")));

        let glob_yaml = indoc::indoc! {r"
            files:
              glob: src/**/*.rs
            exclude:
              glob: target/**
        "};
        let parsed: Wrapper =
            serde_saphyr::from_str(glob_yaml).expect("glob patterns should parse");
        assert_matches!(parsed.files, FilePattern::Glob(_), "expected glob pattern");
        assert!(parsed.files.is_match(Path::new("src/lib/main.rs")));
        assert!(!parsed.files.is_match(Path::new("src/lib/main.py")));
        assert!(parsed.exclude.is_match(Path::new("target/debug/app")));
        assert!(!parsed.exclude.is_match(Path::new("src/lib/main.rs")));

        let glob_list_yaml = indoc::indoc! {r"
            files:
              glob:
                - src/**/*.rs
                - crates/**/src/**/*.rs
            exclude:
              glob:
                - target/**
                - dist/**
        "};
        let parsed: Wrapper =
            serde_saphyr::from_str(glob_list_yaml).expect("glob list patterns should parse");
        assert!(parsed.files.is_match(Path::new("src/lib/main.rs")));
        assert!(parsed.files.is_match(Path::new("crates/foo/src/lib.rs")));
        assert!(!parsed.files.is_match(Path::new("tests/main.rs")));
        assert!(parsed.exclude.is_match(Path::new("target/debug/app")));
        assert!(parsed.exclude.is_match(Path::new("dist/app")));
    }

    #[test]
    fn file_patterns_expose_sources_and_display() {
        let pattern: FilePattern = serde_saphyr::from_str(indoc::indoc! {r"
            glob:
              - src/**/*.rs
              - crates/**/src/**/*.rs
        "})
        .expect("glob list should parse");
        assert_eq!(
            pattern.to_string(),
            "glob: [src/**/*.rs, crates/**/src/**/*.rs]"
        );
        assert!(pattern.is_match(Path::new("src/main.rs")));
        assert!(pattern.is_match(Path::new("crates/foo/src/lib.rs")));
        assert!(!pattern.is_match(Path::new("tests/main.rs")));
    }

    #[test]
    fn file_pattern_never_matches() {
        let pattern = FilePattern::Never;
        assert!(!pattern.is_match(Path::new("")));
        assert!(!pattern.is_match(Path::new("foo.txt")));
        assert!(!pattern.is_match(Path::new("nested/path.rs")));
    }

    #[test]
    fn empty_glob_list_matches_nothing() {
        let pattern = serde_saphyr::from_str::<FilePattern>("glob: []").unwrap();
        assert!(!pattern.is_match(Path::new("any/file.rs")));
        assert!(!pattern.is_match(Path::new("")));
    }

    #[test]
    fn invalid_glob_pattern_errors() {
        let err = serde_saphyr::from_str::<FilePattern>("glob: \"[\"")
            .expect_err("invalid glob should fail");
        let msg = err.to_string().to_lowercase();
        assert!(
            msg.contains("glob"),
            "error should mention glob issues: {msg}"
        );
    }
}