use std::borrow::Cow;
use globset::{Glob, GlobSetBuilder};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlobErrorPolicy {
OverRecall,
Drop,
}
impl GlobErrorPolicy {
#[inline]
const fn verdict(self) -> bool {
match self {
Self::OverRecall => true,
Self::Drop => false,
}
}
}
enum BuiltPatterns {
Universal,
Set(globset::GlobSet),
Unusable,
}
fn build_globset(patterns_json: Option<&str>) -> BuiltPatterns {
let Some(raw) = patterns_json.map(str::trim).filter(|s| !s.is_empty()) else {
return BuiltPatterns::Universal;
};
let patterns: Vec<String> = match serde_json::from_str(raw) {
Ok(v) => v,
Err(_) => return BuiltPatterns::Unusable,
};
if patterns.is_empty() {
return BuiltPatterns::Universal;
}
let mut builder = GlobSetBuilder::new();
let mut added = false;
for pattern in &patterns {
if let Ok(glob) = Glob::new(pattern.trim()) {
builder.add(glob);
added = true;
}
}
if !added {
return BuiltPatterns::Unusable;
}
match builder.build() {
Ok(set) => BuiltPatterns::Set(set),
Err(_) => BuiltPatterns::Unusable,
}
}
fn normalise_path(path: &str) -> Cow<'_, str> {
let trimmed = path.trim_start_matches('/');
if trimmed.contains('\\') {
Cow::Owned(trimmed.replace('\\', "/"))
} else {
Cow::Borrowed(trimmed)
}
}
pub fn glob_match(patterns_json: Option<&str>, path: &str, on_error: GlobErrorPolicy) -> bool {
match build_globset(patterns_json) {
BuiltPatterns::Universal => true,
BuiltPatterns::Unusable => on_error.verdict(),
BuiltPatterns::Set(set) => {
let path = normalise_path(path);
set.is_match(path.as_ref())
}
}
}
pub fn glob_match_changeset(
patterns_json: Option<&str>,
paths: &[String],
on_error: GlobErrorPolicy,
) -> bool {
match build_globset(patterns_json) {
BuiltPatterns::Universal => true,
BuiltPatterns::Unusable => on_error.verdict(),
BuiltPatterns::Set(set) => paths.iter().any(|path| {
let path = normalise_path(path);
set.is_match(path.as_ref())
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_or_empty_is_universal_under_either_policy() {
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
assert!(glob_match(None, "src/lib.rs", policy));
assert!(glob_match(Some(""), "src/lib.rs", policy));
assert!(glob_match(Some(" "), "src/lib.rs", policy));
assert!(glob_match(Some("[]"), "src/lib.rs", policy));
}
}
#[test]
fn normalise_path_borrows_when_no_rewrite_is_needed() {
assert!(matches!(normalise_path("src/lib.rs"), Cow::Borrowed(_)));
assert_eq!(normalise_path("/src/lib.rs").as_ref(), "src/lib.rs");
assert!(matches!(
normalise_path("src\\lib.rs"),
Cow::Owned(ref path) if path == "src/lib.rs"
));
}
#[test]
fn glob_match_basic_and_path_normalisation() {
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
assert!(glob_match(
Some(r#"["**/*.rs"]"#),
"tokio/src/io/uring.rs",
policy
));
assert!(!glob_match(
Some(r#"["**/*.rs"]"#),
".github/workflows/ci.yml",
policy
));
assert!(glob_match(
Some(r#"["tokio/src/io/**"]"#),
"tokio/src/io/uring.rs",
policy
));
assert!(!glob_match(
Some(r#"["tokio/src/io/**"]"#),
"tokio/src/runtime/mod.rs",
policy
));
assert!(glob_match(
Some(r#"["tokio/src/io/**"]"#),
"tokio\\src\\io\\uring.rs",
policy
));
assert!(glob_match(
Some(r#"["tokio/src/io/**"]"#),
"/tokio/src/io/uring.rs",
policy
));
}
}
const SCHEMA_MIGRATION_GLOBS: &str = r#"["db/schema/**", "migrations/**/*.sql"]"#;
fn changeset(paths: &[&str]) -> Vec<String> {
paths.iter().map(|p| (*p).to_owned()).collect()
}
#[test]
fn changeset_both_sides_of_coupled_change_hit() {
let diff = changeset(&["db/schema/users.sql", "migrations/0042/add_email.sql"]);
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
assert!(glob_match_changeset(
Some(SCHEMA_MIGRATION_GLOBS),
&diff,
policy
));
}
}
#[test]
fn changeset_single_side_hit_recalls_rule() {
let diff = changeset(&["src/api/handler.ts", "db/schema/users.sql"]);
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
assert!(glob_match_changeset(
Some(SCHEMA_MIGRATION_GLOBS),
&diff,
policy
));
}
let migration_only = changeset(&["migrations/0042/add_email.sql"]);
assert!(glob_match_changeset(
Some(SCHEMA_MIGRATION_GLOBS),
&migration_only,
GlobErrorPolicy::OverRecall
));
}
#[test]
fn changeset_no_path_in_scope_is_no_match() {
let diff = changeset(&["src/api/handler.ts", "README.md"]);
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
assert!(!glob_match_changeset(
Some(SCHEMA_MIGRATION_GLOBS),
&diff,
policy
));
}
}
#[test]
fn changeset_empty_or_absent_patterns_is_universal() {
let diff = changeset(&["src/lib.rs"]);
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
assert!(glob_match_changeset(None, &diff, policy));
assert!(glob_match_changeset(Some(""), &diff, policy));
assert!(glob_match_changeset(Some("[]"), &diff, policy));
assert!(glob_match_changeset(Some("[]"), &[], policy));
}
}
#[test]
fn changeset_empty_paths_never_proves_a_scoped_rule() {
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
assert!(!glob_match_changeset(
Some(SCHEMA_MIGRATION_GLOBS),
&[],
policy
));
}
}
#[test]
fn changeset_malformed_blob_follows_policy() {
let diff = changeset(&["db/schema/users.sql"]);
for blob in ["not-json", "{}"] {
assert!(glob_match_changeset(
Some(blob),
&diff,
GlobErrorPolicy::OverRecall
));
assert!(!glob_match_changeset(
Some(blob),
&diff,
GlobErrorPolicy::Drop
));
}
}
#[test]
fn changeset_normalises_windows_and_leading_slash_paths() {
let diff = changeset(&["db\\schema\\users.sql"]);
assert!(glob_match_changeset(
Some(SCHEMA_MIGRATION_GLOBS),
&diff,
GlobErrorPolicy::Drop
));
let rooted = changeset(&["/migrations/0001/init.sql"]);
assert!(glob_match_changeset(
Some(SCHEMA_MIGRATION_GLOBS),
&rooted,
GlobErrorPolicy::Drop
));
}
#[test]
fn changeset_agrees_with_single_path_matcher_per_path() {
let paths = changeset(&[
"src/api/handler.ts",
"db/schema/users.sql",
".github/workflows/ci.yml",
]);
for policy in [GlobErrorPolicy::OverRecall, GlobErrorPolicy::Drop] {
let per_path_any = paths
.iter()
.any(|p| glob_match(Some(SCHEMA_MIGRATION_GLOBS), p, policy));
assert_eq!(
glob_match_changeset(Some(SCHEMA_MIGRATION_GLOBS), &paths, policy),
per_path_any,
);
}
}
#[test]
fn malformed_blob_follows_policy() {
assert!(glob_match(
Some("not-json"),
"any/path.rs",
GlobErrorPolicy::OverRecall
));
assert!(!glob_match(
Some("not-json"),
"any/path.rs",
GlobErrorPolicy::Drop
));
assert!(glob_match(
Some("{}"),
"any/path.rs",
GlobErrorPolicy::OverRecall
));
assert!(!glob_match(
Some("{}"),
"any/path.rs",
GlobErrorPolicy::Drop
));
}
}