diff --git a/src/handlers/autolabel.rs b/src/handlers/autolabel.rs index e1722c941..fc275685c 100644 --- a/src/handlers/autolabel.rs +++ b/src/handlers/autolabel.rs @@ -2,6 +2,7 @@ use crate::{ config::AutolabelConfig, github::{IssuesAction, IssuesEvent, Label}, handlers::Context, + utils::ModifiedPathMatcher, }; use anyhow::Context as _; use tracing as log; @@ -87,11 +88,11 @@ pub(super) async fn parse_input( // This a PR with modified files. // Add the matching labels for the modified files paths - if cfg.trigger_files.iter().any(|f| { - files - .iter() - .any(|file_diff| file_diff.filename.starts_with(f)) - }) { + let matcher = ModifiedPathMatcher::new(&cfg.trigger_files); + if files + .iter() + .any(|file_diff| matcher.is_match(&file_diff.filename)) + { autolabels.push(Label { name: label.to_owned(), }); diff --git a/src/handlers/check_commits/validate_config.rs b/src/handlers/check_commits/validate_config.rs index 395948a9f..41de673d3 100644 --- a/src/handlers/check_commits/validate_config.rs +++ b/src/handlers/check_commits/validate_config.rs @@ -2,9 +2,10 @@ //! changes are a valid configuration file. use crate::{ - config::{CONFIG_FILE_NAME, MentionsEntryConfig, MentionsEntryType}, + config::{CONFIG_FILE_NAME, Config, MentionsEntryConfig, MentionsEntryType}, github::FileDiff, handlers::{Context, IssuesEvent}, + utils::{ModifiedPathMatcher, PathMatcherError}, }; use anyhow::{Context as _, bail}; @@ -55,43 +56,72 @@ pub(super) async fn validate_config( `````", ))) } - Ok(config) => { - // Error if `[assign.owners]` is not empty (ie auto-assign) and the custom welcome message for assignee isn't set. - if let Some(assign) = config.assign - && !assign.owners.is_empty() - && let Some(custom_messages) = &assign.custom_messages - && custom_messages.auto_assign_someone.is_none() - { - return Ok(Some( + Ok(config) => match validate_parsed_config(&config) { + Ok(()) => Ok(None), + Err(err) => Ok(Some(err)), + }, + } +} + +fn validate_parsed_config(config: &Config) -> Result<(), String> { + // Error if `[assign.owners]` is not empty (ie auto-assign) and the custom welcome message for assignee isn't set. + if let Some(assign) = &config.assign + && !assign.owners.is_empty() + && let Some(custom_messages) = &assign.custom_messages + && custom_messages.auto_assign_someone.is_none() + { + return Err("Invalid `triagebot.toml`:\n\ + `[assign.owners]` is populated but `[assign.custom_messages.auto-assign-someone]` \ + is not set!" + .to_string()); + } + + // Validate patterns everywhere we allow glob matching + let validate_pattern = + |pat: &str, location: &str| match ModifiedPathMatcher::validate_entry(&pat) { + Ok(()) => Ok(()), + Err(PathMatcherError::Glob(err)) => { + return Err(format!( "Invalid `triagebot.toml`:\n\ - `[assign.owners]` is populated but `[assign.custom_messages.auto-assign-someone]` is not set!".to_string() + {location} has an invalid glob syntax: {err}" )); } + Err(PathMatcherError::NonRelativePath) => { + return Err(format!( + "Invalid `triagebot.toml`:\n\ + {location} has an invalid pattern: path must be \ + relative (remove the `/` at the start)" + )); + } + }; - // Error if one the mentions entry is not a valid glob. - if let Some(mentions) = config.mentions { - for (entry, MentionsEntryConfig { type_, .. }) in mentions.entries { - if type_ == MentionsEntryType::Filename { - if let Err(err) = globset::Glob::new(&entry) { - return Ok(Some(format!( - "Invalid `triagebot.toml`:\n\ - `[mentions.\"{entry}\"]` has an invalid glob syntax: {err}" - ))); - } - - if entry.starts_with('/') { - return Ok(Some(format!( - "Invalid `triagebot.toml`:\n\ - `[mentions.\"{entry}\"]` has an invalid pattern: path must be relative (remove the `/` at the start)" - ))); - } - } - } + if let Some(mentions) = &config.mentions { + for (entry, MentionsEntryConfig { type_, .. }) in &mentions.entries { + if type_ == &MentionsEntryType::Filename { + validate_pattern(entry, &format!("`[mentions.\"{entry}\"]`"))?; } + } + } - Ok(None) + if let Some(autolabel) = &config.autolabel { + for (_label, cfg) in &autolabel.labels { + for pat in &cfg.trigger_files { + validate_pattern(pat, &format!("`autolabel.trigger_files` item `{pat}`"))?; + } } } + + if let Some(rendered_link) = &config.rendered_link { + for pat in &rendered_link.trigger_files { + validate_pattern(pat, &format!("`rendered_link.trigger_files` item `{pat}`"))?; + } + + for pat in &rendered_link.exclude_files { + validate_pattern(pat, &format!("`rendered_link.exclude_files` item `{pat}`"))?; + } + } + + Ok(()) } /// Helper to translate a toml span to a `(line_no, col_no)` (1-based). diff --git a/src/handlers/mentions.rs b/src/handlers/mentions.rs index 3dde7c626..2dc7b6154 100644 --- a/src/handlers/mentions.rs +++ b/src/handlers/mentions.rs @@ -7,6 +7,7 @@ use crate::{ db::issue_data::IssueData, github::{IssuesAction, IssuesEvent}, handlers::Context, + utils::ModifiedPathMatcher, }; use anyhow::Context as _; use itertools::Itertools; @@ -88,7 +89,13 @@ pub(super) async fn parse_input( let relevant_file_paths: Vec = match type_ { MentionsEntryType::Filename => { // Only mention matching paths. - modified_paths_matches(&modified_paths, entry) + let matcher = ModifiedPathMatcher::single(entry); + modified_paths + .iter() + .copied() + .filter(move |p| matcher.is_match(p)) + .map(|p| p.to_owned()) + .collect() } MentionsEntryType::Content => { // Only mentions byte-for-byte matching content inside the patch. @@ -191,49 +198,6 @@ pub(super) async fn handle_input( Ok(()) } -fn modified_paths_matches(modified_paths: &[&Path], entry: &str) -> Vec { - // Fast-path if entry has no glob components - if globset::escape(entry) == entry { - let path = Path::new(entry); - - // Return paths that starts with entry - return modified_paths - .iter() - .filter(|p| p.starts_with(path)) - .map(PathBuf::from) - .collect(); - } - - // Prepare the glob pattern from the entry. - // - // We first trim any excess `/` at the end of the pattern and then add an alternate - // to match on the path as is or in any sub-directories. - let pattern = entry.trim_end_matches('/'); - let pattern = format!("{pattern}{{,/*}}"); - - // Create the glob pattern and log an error (should have already been reported to - // the user). - let pattern = match globset::GlobBuilder::new(&pattern) - .empty_alternates(true) - .build() - { - Ok(pattern) => pattern, - Err(err) => { - tracing::error!("invalid glob pattern for [mentions.\"{entry}\"]: {err}"); - return Vec::new(); - } - }; - - // Compile the glob pattern to a (Regex) matcher - let matcher = pattern.compile_matcher(); - - modified_paths - .iter() - .filter(|p| matcher.is_match(p)) - .map(PathBuf::from) - .collect() -} - fn patch_adds(patch: &str, needle: &str) -> bool { patch .lines() @@ -296,257 +260,4 @@ mod tests { "; assert!(!patch_adds(patch, "missing")); } - - #[test] - fn entry_not_modified() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("library/Cargo.lock"), - Path::new("library/Cargo.toml") - ], - "compiler/rustc_span/", - ), - Vec::::new() - ); - } - - #[test] - fn entry_modified() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("Cargo.lock"), - Path::new("compiler/rustc_span/src/lib.rs"), - Path::new("compiler/rustc_span/src/symbol.rs"), - ], - "compiler/rustc_span/", - ), - vec![ - PathBuf::from("compiler/rustc_span/src/lib.rs"), - PathBuf::from("compiler/rustc_span/src/symbol.rs"), - ] - ); - } - - #[test] - fn entry_filename_modified() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("compiler/rustc_span/src/lib.rs"), - Path::new("compiler/rustc_span/src/symbol.rs"), - ], - "compiler/rustc_span/src/lib.rs", - ), - vec![PathBuf::from("compiler/rustc_span/src/lib.rs")] - ); - } - - #[test] - fn entry_top_level_filename_modified() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("Cargo.lock"), - Path::new("Cargo.toml"), - Path::new(".git/submodules"), - ], - "Cargo.lock", - ), - vec![PathBuf::from("Cargo.lock")] - ); - } - - #[test] - fn entry_modified_glob_either() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("Cargo.lock"), - Path::new("Cargo.toml"), - Path::new("library/dec2flt/lib.rs"), - Path::new("library/flt2dec/lib.rs"), - ], - "library/{dec2flt,flt2dec}", - ), - vec![ - PathBuf::from("library/dec2flt/lib.rs"), - PathBuf::from("library/flt2dec/lib.rs"), - ] - ); - } - - #[test] - fn entry_modified_glob_star() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("Cargo.lock"), - Path::new("Cargo.toml"), - Path::new("library/dec2flt/lib.rs"), - Path::new("library/flt2dec/lib.rs"), - ], - "library/dec2*", - ), - vec![PathBuf::from("library/dec2flt/lib.rs")] - ); - } - - #[test] - fn entry_modified_glob_star_middle() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("compiler/x86-64-none_eabi.rs"), - Path::new("compiler/armv7-none_eabi-something.rs"), - Path::new("compiler/armv7-none_eabi-something.txt"), - Path::new("compiler/none_eabi.rs"), - ], - "compiler/*none_eabi*.rs", - ), - vec![ - PathBuf::from("compiler/x86-64-none_eabi.rs"), - PathBuf::from("compiler/armv7-none_eabi-something.rs"), - PathBuf::from("compiler/none_eabi.rs"), - ] - ); - } - - #[test] - fn entry_modified_glob_double_star() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("Cargo.lock"), - Path::new("Cargo.toml"), - Path::new("library/.empty"), - Path::new("library/dec2flt/lib.rs"), - Path::new("library/flt2dec/lib.rs"), - ], - "library/**", - ), - vec![ - PathBuf::from("library/.empty"), - PathBuf::from("library/dec2flt/lib.rs"), - PathBuf::from("library/flt2dec/lib.rs"), - ] - ); - } - - #[test] - fn entry_modified_glob_empty_alternates() { - assert_eq!( - modified_paths_matches( - &[Path::new("result.rs"), Path::new("result.rs.stdout")], - "result.rs{,.stdout}", - ), - vec![ - PathBuf::from("result.rs"), - PathBuf::from("result.rs.stdout"), - ] - ); - } - - #[test] - fn entry_submodule_modified() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/tools/cargo" - ), - vec![PathBuf::from("src/tools/cargo")] - ); - } - - #[test] - fn entry_submodule_and_normal_dir_modified() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/tools/cargo{,test}" - ), - vec![ - PathBuf::from("src/tools/cargo"), - PathBuf::from("src/tools/cargotest") - ] - ); - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/tools/cargo*" - ), - vec![ - PathBuf::from("src/tools/cargo"), - PathBuf::from("src/tools/cargotest") - ] - ); - } - - #[test] - fn entry_submodule_modified_with_trailing_slash() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/tools/cargo/" - ), - vec![PathBuf::from("src/tools/cargo")] - ); - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/tools/cargo//" - ), - vec![PathBuf::from("src/tools/cargo")] - ); - } - - #[test] - fn entry_submodule_modified_glob() { - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/*/cargo" - ), - vec![PathBuf::from("src/tools/cargo")] - ); - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/*/cargo/" - ), - vec![PathBuf::from("src/tools/cargo")] - ); - assert_eq!( - modified_paths_matches( - &[ - Path::new("src/tools/cargo"), - Path::new("src/tools/cargotest") - ], - "src/*/cargo//" - ), - vec![PathBuf::from("src/tools/cargo")] - ); - } } diff --git a/src/handlers/rendered_link.rs b/src/handlers/rendered_link.rs index b1f375537..9a3541251 100644 --- a/src/handlers/rendered_link.rs +++ b/src/handlers/rendered_link.rs @@ -6,6 +6,7 @@ use crate::{ config::RenderedLinkConfig, github::{Event, IssuesAction, IssuesEvent, PullRequestFileStatus}, handlers::Context, + utils::ModifiedPathMatcher, }; pub(super) async fn handle( @@ -39,18 +40,13 @@ async fn add_rendered_link( || e.action == IssuesAction::Synchronize { let files = e.issue.files(&ctx.github).await?; + let trigger_matcher = ModifiedPathMatcher::new(&config.trigger_files); + let exclude_matcher = ModifiedPathMatcher::new(&config.exclude_files); let rendered_link = files .iter() .filter(|f| { - config - .trigger_files - .iter() - .any(|tf| f.filename.starts_with(tf)) - && !config - .exclude_files - .iter() - .any(|tf| f.filename.starts_with(tf)) + trigger_matcher.is_match(&f.filename) && !exclude_matcher.is_match(&f.filename) }) .filter(|f| match f.status { PullRequestFileStatus::Added diff --git a/src/utils.rs b/src/utils.rs index 0e8acaf2d..b7064fba7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,11 +2,12 @@ use crate::handlers::Context; use anyhow::Context as _; use axum::http::HeaderValue; +use globset::GlobSet; use hyper::{ HeaderMap, header::{CACHE_CONTROL, CONTENT_TYPE}, }; -use std::borrow::Cow; +use std::{borrow::Cow, path::Path}; /// Pluralize (add an 's' sufix) to `text` based on `count`. pub fn pluralize(text: &str, count: usize) -> Cow<'_, str> { @@ -81,3 +82,421 @@ pub(crate) fn immutable_headers(content_type: &'static str) -> HeaderMap { headers } + +#[derive(Debug)] +pub(crate) struct ModifiedPathMatcher { + prefixes: Vec, + globs: GlobSet, +} + +impl ModifiedPathMatcher { + /// Create a matcher against a set of prefixes (default) and globs (if the entry + /// contains wildcards). + pub fn new<'a, S>(entries: &[S]) -> Self + where + S: AsRef, + { + let mut prefixes = Vec::new(); + let mut globs = GlobSet::builder(); + + for entry in entries { + let entry = entry.as_ref(); + if globset::escape(entry) == entry { + prefixes.push(entry.to_owned()); + continue; + } + + // Prepare the glob pattern from the entry. + // + // We first trim any excess `/` at the end of the pattern and then add an alternate + // to match on the path as is or in any sub-directories. + let pattern = entry.trim_end_matches('/'); + let pattern = format!("{pattern}{{,/*}}"); + + // Create the glob pattern and log an error (should have already been reported to + // the user). + let glob = match globset::GlobBuilder::new(&pattern) + .empty_alternates(true) + .build() + { + Ok(pattern) => pattern, + Err(err) => { + tracing::error!("invalid glob pattern for \"{entry}\": {err}"); + continue; + } + }; + + globs.add(glob); + } + + let globs = match globs.build() { + Ok(globs) => globs, + Err(err) => { + // Shouldn't fail since the globs are already validated, but `globset` returns + // a `Result`. + tracing::error!("unable to build glob pattern: {err}"); + GlobSet::empty() + } + }; + + Self { prefixes, globs } + } + + /// Create a matcher against a single prefix or glob. + pub fn single(entry: &str) -> Self { + Self::new(&[entry]) + } + + pub fn is_match(&self, path: impl AsRef) -> bool { + let path = path.as_ref(); + + if self.globs.is_match(path) { + return true; + } + + for pfx in &self.prefixes { + if path.starts_with(pfx) { + return true; + } + } + + false + } + + /// Check for invalid globs or absolute paths. + pub fn validate_entry(entry: &str) -> Result<(), PathMatcherError> { + if let Err(e) = globset::GlobBuilder::new(entry) + .empty_alternates(true) + .build() + { + return Err(PathMatcherError::Glob(e)); + } + + if entry.starts_with('/') { + return Err(PathMatcherError::NonRelativePath); + } + + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub(crate) enum PathMatcherError { + Glob(globset::Error), + NonRelativePath, +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn modified_paths_matches(modified_paths: &[&Path], entry: &str) -> Vec { + modified_paths_matches_set(modified_paths, &[entry]) + } + + fn modified_paths_matches_set(modified_paths: &[&Path], entries: &[&str]) -> Vec { + let matcher = ModifiedPathMatcher::new(entries); + modified_paths + .iter() + .filter(|p| matcher.is_match(p)) + .map(PathBuf::from) + .collect() + } + + #[test] + fn entry_not_modified() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("library/Cargo.lock"), + Path::new("library/Cargo.toml") + ], + "compiler/rustc_span/", + ), + Vec::::new() + ); + } + + #[test] + fn entry_modified() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("Cargo.lock"), + Path::new("compiler/rustc_span/src/lib.rs"), + Path::new("compiler/rustc_span/src/symbol.rs"), + ], + "compiler/rustc_span/", + ), + vec![ + PathBuf::from("compiler/rustc_span/src/lib.rs"), + PathBuf::from("compiler/rustc_span/src/symbol.rs"), + ] + ); + } + + #[test] + fn entry_filename_modified() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("compiler/rustc_span/src/lib.rs"), + Path::new("compiler/rustc_span/src/symbol.rs"), + ], + "compiler/rustc_span/src/lib.rs", + ), + vec![PathBuf::from("compiler/rustc_span/src/lib.rs")] + ); + } + + #[test] + fn entry_top_level_filename_modified() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("Cargo.lock"), + Path::new("Cargo.toml"), + Path::new(".git/submodules"), + ], + "Cargo.lock", + ), + vec![PathBuf::from("Cargo.lock")] + ); + } + + #[test] + fn entry_modified_glob_either() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("Cargo.lock"), + Path::new("Cargo.toml"), + Path::new("library/dec2flt/lib.rs"), + Path::new("library/flt2dec/lib.rs"), + ], + "library/{dec2flt,flt2dec}", + ), + vec![ + PathBuf::from("library/dec2flt/lib.rs"), + PathBuf::from("library/flt2dec/lib.rs"), + ] + ); + } + + #[test] + fn entry_modified_glob_star() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("Cargo.lock"), + Path::new("Cargo.toml"), + Path::new("library/dec2flt/lib.rs"), + Path::new("library/flt2dec/lib.rs"), + ], + "library/dec2*", + ), + vec![PathBuf::from("library/dec2flt/lib.rs")] + ); + } + + #[test] + fn entry_modified_glob_star_middle() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("compiler/x86-64-none_eabi.rs"), + Path::new("compiler/armv7-none_eabi-something.rs"), + Path::new("compiler/armv7-none_eabi-something.txt"), + Path::new("compiler/none_eabi.rs"), + ], + "compiler/*none_eabi*.rs", + ), + vec![ + PathBuf::from("compiler/x86-64-none_eabi.rs"), + PathBuf::from("compiler/armv7-none_eabi-something.rs"), + PathBuf::from("compiler/none_eabi.rs"), + ] + ); + } + + #[test] + fn entry_modified_glob_double_star() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("Cargo.lock"), + Path::new("Cargo.toml"), + Path::new("library/.empty"), + Path::new("library/dec2flt/lib.rs"), + Path::new("library/flt2dec/lib.rs"), + ], + "library/**", + ), + vec![ + PathBuf::from("library/.empty"), + PathBuf::from("library/dec2flt/lib.rs"), + PathBuf::from("library/flt2dec/lib.rs"), + ] + ); + } + + #[test] + fn entry_modified_glob_set() { + assert_eq!( + modified_paths_matches_set( + &[ + Path::new("Cargo.lock"), + Path::new("Cargo.toml"), + Path::new("library/.empty"), + Path::new("library/dec2flt/lib.rs"), + Path::new("library/flt2dec/lib.rs"), + Path::new("a/foo/glob1.rs"), + Path::new("b/glob1.rs"), + Path::new("glob1.rs"), + Path::new("pfx1"), + Path::new("pfx1/foo.rs"), + Path::new("pfx2.rs"), + Path::new("pfx2/foo.rs"), + Path::new("nomatch/pfx1"), + Path::new("nomatch/pfx2") + ], + &[ + // Two globs, two prefixes + "library/**", + "*glob1*", + "pfx1", + "pfx2", + ] + ), + vec![ + PathBuf::from("library/.empty"), + PathBuf::from("library/dec2flt/lib.rs"), + PathBuf::from("library/flt2dec/lib.rs"), + PathBuf::from("a/foo/glob1.rs"), + PathBuf::from("b/glob1.rs"), + PathBuf::from("glob1.rs"), + PathBuf::from("pfx1"), + PathBuf::from("pfx1/foo.rs"), + PathBuf::from("pfx2/foo.rs"), + ] + ); + } + + #[test] + fn entry_modified_glob_empty_alternates() { + assert_eq!( + modified_paths_matches( + &[Path::new("result.rs"), Path::new("result.rs.stdout")], + "result.rs{,.stdout}", + ), + vec![ + PathBuf::from("result.rs"), + PathBuf::from("result.rs.stdout"), + ] + ); + } + + #[test] + fn entry_submodule_modified() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/tools/cargo" + ), + vec![PathBuf::from("src/tools/cargo")] + ); + } + + #[test] + fn entry_submodule_and_normal_dir_modified() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/tools/cargo{,test}" + ), + vec![ + PathBuf::from("src/tools/cargo"), + PathBuf::from("src/tools/cargotest") + ] + ); + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/tools/cargo*" + ), + vec![ + PathBuf::from("src/tools/cargo"), + PathBuf::from("src/tools/cargotest") + ] + ); + } + + #[test] + fn entry_submodule_modified_with_trailing_slash() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/tools/cargo/" + ), + vec![PathBuf::from("src/tools/cargo")] + ); + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/tools/cargo//" + ), + vec![PathBuf::from("src/tools/cargo")] + ); + } + + #[test] + fn entry_submodule_modified_glob() { + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/*/cargo" + ), + vec![PathBuf::from("src/tools/cargo")] + ); + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/*/cargo/" + ), + vec![PathBuf::from("src/tools/cargo")] + ); + assert_eq!( + modified_paths_matches( + &[ + Path::new("src/tools/cargo"), + Path::new("src/tools/cargotest") + ], + "src/*/cargo//" + ), + vec![PathBuf::from("src/tools/cargo")] + ); + } +}