Skip to main content

compare_changes/
lib.rs

1mod convert;
2mod path;
3
4use regex::Regex;
5use std::fmt;
6
7#[derive(Debug)]
8pub enum Error {
9    Parse(Vec<String>), // convert to strings for simplicity
10    Regex(regex::Error),
11}
12
13impl From<regex::Error> for Error {
14    fn from(e: regex::Error) -> Self {
15        Error::Regex(e)
16    }
17}
18
19impl fmt::Display for Error {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Error::Parse(msgs) => write!(f, "{}", msgs.join("; ")),
23            Error::Regex(e) => write!(f, "{}", e),
24        }
25    }
26}
27
28impl std::error::Error for Error {
29    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
30        match self {
31            Error::Regex(e) => Some(e),
32            _ => None,
33        }
34    }
35}
36
37pub fn path_matches(path: &str, files: &[&str]) -> Result<Option<usize>, Error> {
38    if files.is_empty() {
39        return Ok(None);
40    }
41
42    // Remove leading '?', '+' since that's what GitHub does and also '!' because it's not meaningful when not part of a group
43    let pattern = match path.chars().next() {
44        Some(c) if matches!(c, '?' | '+' | '!') => &path[c.len_utf8()..],
45        _ => path,
46    };
47
48    let re = compile_pattern(pattern)?;
49
50    // Check if any file matches the compiled regex
51    Ok(files.iter().enumerate().find(|(_, file)| re.is_match(file)).map(|(i, _)| i))
52}
53
54/// Evaluate a set of patterns against a set of files with negation (`!`) support.
55///
56/// Unlike [`path_matches`], which evaluates a single pattern in isolation, this treats
57/// `paths` as an ordered group in the same way GitHub applies path filters: patterns
58/// are checked sequentially per file, and a pattern starting with `!` re-excludes any
59/// file previously matched by an earlier pattern. A later positive pattern can re-include
60/// a file that was excluded by a negation.
61///
62/// Returns `Ok(Some((path_index, file_index)))` for the first file that ends up included,
63/// where `path_index` is the index of the last positive pattern that matched it.
64/// Returns `Ok(None)` when no file remains included after applying all patterns.
65pub fn paths_match(paths: &[&str], files: &[&str]) -> Result<Option<(usize, usize)>, Error> {
66    if paths.is_empty() || files.is_empty() {
67        return Ok(None);
68    }
69
70    // Pre-compile every pattern once
71    let compiled: Vec<(bool, Regex)> = paths
72        .iter()
73        .map(|p| {
74            let (negated, rest) = match p.chars().next() {
75                Some('!') => (true, &p[1..]),
76                _ => (false, *p),
77            };
78            // Match the same leading-char stripping that path_matches performs, for parity
79            let pattern = match rest.chars().next() {
80                Some(c) if matches!(c, '?' | '+') => &rest[c.len_utf8()..],
81                _ => rest,
82            };
83            compile_pattern(pattern).map(|re| (negated, re))
84        })
85        .collect::<Result<_, _>>()?;
86
87    for (fi, file) in files.iter().enumerate() {
88        let mut matched_by: Option<usize> = None;
89        for (pi, (negated, re)) in compiled.iter().enumerate() {
90            if re.is_match(file) {
91                matched_by = if *negated { None } else { Some(pi) };
92            }
93        }
94        if let Some(pi) = matched_by {
95            return Ok(Some((pi, fi)));
96        }
97    }
98
99    Ok(None)
100}
101
102fn compile_pattern(pattern: &str) -> Result<Regex, Error> {
103    // Parse the single path — map chumsky Rich errors to strings
104    let parsed_path = match path::parse(pattern) {
105        Ok(p) => p,
106        Err(errs) => {
107            let msgs = errs.into_iter().map(|e| e.to_string()).collect();
108            return Err(Error::Parse(msgs));
109        }
110    };
111
112    // Build regex from the parsed path — propagate compilation errors
113    Ok(convert::path_to_regex(&parsed_path)?)
114}