use std::collections::{BTreeMap, BTreeSet};
use glob::Pattern;
use crate::globmatch::glob_matches;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Status {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Verb {
Added,
Modified,
Removed,
}
impl Verb {
pub(crate) fn marker(self) -> char {
match self {
Verb::Added => 'A',
Verb::Modified => 'M',
Verb::Removed => 'D',
}
}
}
pub(crate) fn net(events: &[Status]) -> Verb {
match events.last() {
Some(Status::Deleted) => Verb::Removed,
_ if events.contains(&Status::Added) => Verb::Added,
_ => Verb::Modified,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Conformant {
pub(crate) path: String,
pub(crate) matched_selector: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Undeclared {
pub(crate) path: String,
pub(crate) verb: Verb,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Conformance {
pub(crate) conformant: Vec<Conformant>,
pub(crate) undeclared: Vec<Undeclared>,
pub(crate) undelivered: Vec<String>,
}
pub(crate) fn compute(selectors: &[String], actual: &BTreeMap<String, Vec<Status>>) -> Conformance {
let compiled = compile(selectors);
let mut out = Conformance::default();
let mut matched: BTreeSet<&str> = BTreeSet::new();
for (path, events) in actual {
match matched_selector(&compiled, path) {
Some(sel) => {
matched.insert(sel);
out.conformant.push(Conformant {
path: path.clone(),
matched_selector: sel.to_string(),
});
}
None => out.undeclared.push(Undeclared {
path: path.clone(),
verb: net(events),
}),
}
}
out.undelivered = compiled
.iter()
.filter(|(sel, _)| !matched.contains(sel.as_str()))
.map(|(sel, _)| sel.clone())
.collect();
out
}
pub(crate) fn undeclared_paths(selectors: &[String], paths: &[&str]) -> Vec<String> {
let compiled = compile(selectors);
paths
.iter()
.filter(|path| matched_selector(&compiled, path).is_none())
.map(|path| (*path).to_string())
.collect()
}
fn compile(selectors: &[String]) -> Vec<(String, Option<Pattern>)> {
selectors
.iter()
.map(|s| (s.clone(), Pattern::new(s).ok()))
.collect()
}
fn matched_selector<'a>(compiled: &'a [(String, Option<Pattern>)], path: &str) -> Option<&'a str> {
compiled
.iter()
.find(|(_, pat)| pat.as_ref().is_some_and(|p| glob_matches(p, path)))
.map(|(sel, _)| sel.as_str())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SelectorScope {
ReadFence,
WillTouch,
}
const BROAD_SHARE_NUM: usize = 1;
const BROAD_SHARE_DEN: usize = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SelectorFinding {
Uncompilable { selector: String, error: String },
Unmatched { selector: String },
Redundant {
selector: String,
subsumed_by: String,
},
Broad {
selector: String,
matched: usize,
universe: usize,
},
}
fn matches_in(sel: &str, universe: &BTreeSet<String>) -> Option<BTreeSet<String>> {
let pat = Pattern::new(sel).ok()?;
Some(
universe
.iter()
.filter(|p| glob_matches(&pat, p))
.cloned()
.collect(),
)
}
pub(crate) fn diagnose_selector(
sel: &str,
scope: SelectorScope,
universe: &BTreeSet<String>,
others: &[&str],
) -> Vec<SelectorFinding> {
let pat = match Pattern::new(sel) {
Ok(p) => p,
Err(e) => {
return vec![SelectorFinding::Uncompilable {
selector: sel.to_string(),
error: e.to_string(),
}];
}
};
let matched: BTreeSet<String> = universe
.iter()
.filter(|p| glob_matches(&pat, p))
.cloned()
.collect();
if matched.is_empty() {
return vec![SelectorFinding::Unmatched {
selector: sel.to_string(),
}];
}
let mut findings = Vec::new();
if let Some(peer) = others
.iter()
.filter(|o| **o != sel)
.filter_map(|o| matches_in(o, universe).map(|m| (*o, m)))
.find(|(_, m)| m.len() > matched.len() && matched.is_subset(m))
.map(|(o, _)| o)
{
findings.push(SelectorFinding::Redundant {
selector: sel.to_string(),
subsumed_by: peer.to_string(),
});
}
if scope == SelectorScope::WillTouch
&& !universe.is_empty()
&& matched.len() * BROAD_SHARE_DEN > universe.len() * BROAD_SHARE_NUM
{
findings.push(SelectorFinding::Broad {
selector: sel.to_string(),
matched: matched.len(),
universe: universe.len(),
});
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(s: &[Status]) -> Vec<Status> {
s.to_vec()
}
fn universe(paths: &[&str]) -> BTreeSet<String> {
paths.iter().map(|p| (*p).to_string()).collect()
}
#[test]
fn diagnose_uncompilable_glob_is_flagged_alone() {
let u = universe(&["src/a.rs"]);
let f = diagnose_selector("src/[", SelectorScope::WillTouch, &u, &[]);
assert!(
matches!(f.as_slice(), [SelectorFinding::Uncompilable { selector, .. }] if selector == "src/["),
"a broken glob is Uncompilable and nothing else: {f:?}"
);
}
#[test]
fn diagnose_unmatched_selector_is_flagged() {
let u = universe(&["src/a.rs", "src/b.rs"]);
let f = diagnose_selector("docs/*.md", SelectorScope::WillTouch, &u, &[]);
assert_eq!(
f,
vec![SelectorFinding::Unmatched {
selector: "docs/*.md".to_string(),
}]
);
}
#[test]
fn diagnose_redundant_when_subsumed_by_a_broader_peer() {
let u = universe(&["src/a.rs", "src/b.rs", "src/sub/c.rs"]);
let f = diagnose_selector(
"src/a.rs",
SelectorScope::WillTouch,
&u,
&["src/**", "src/a.rs"],
);
assert!(
f.contains(&SelectorFinding::Redundant {
selector: "src/a.rs".to_string(),
subsumed_by: "src/**".to_string(),
}),
"a selector whose matches are a subset of another is redundant: {f:?}"
);
}
#[test]
fn diagnose_broad_design_target_matching_most_of_the_universe() {
let u = universe(&["src/a.rs", "src/b.rs", "docs/x.md", "docs/y.md"]);
let f = diagnose_selector("**", SelectorScope::WillTouch, &u, &[]);
assert!(
f.iter().any(|x| matches!(
x,
SelectorFinding::Broad {
matched: 4,
universe: 4,
..
}
)),
"a design-target matching the whole universe is broad: {f:?}"
);
}
#[test]
fn diagnose_broad_suppressed_for_scope_relevant_read_fence() {
let u = universe(&["src/a.rs", "src/b.rs", "docs/x.md", "docs/y.md"]);
let f = diagnose_selector("**", SelectorScope::ReadFence, &u, &[]);
assert!(
!f.iter().any(|x| matches!(x, SelectorFinding::Broad { .. })),
"broad is suppressed for a scope-relevant read fence: {f:?}"
);
}
#[test]
fn net_added_then_modified_is_added() {
assert_eq!(net(&ev(&[Status::Added, Status::Modified])), Verb::Added);
}
#[test]
fn net_added_then_deleted_is_removed() {
assert_eq!(net(&ev(&[Status::Added, Status::Deleted])), Verb::Removed);
}
#[test]
fn net_modified_then_modified_is_modified() {
assert_eq!(
net(&ev(&[Status::Modified, Status::Modified])),
Verb::Modified
);
}
#[test]
fn net_modified_deleted_added_is_added() {
assert_eq!(
net(&ev(&[Status::Modified, Status::Deleted, Status::Added])),
Verb::Added
);
}
fn actual(pairs: &[(&str, &[Status])]) -> BTreeMap<String, Vec<Status>> {
pairs
.iter()
.map(|(p, s)| ((*p).to_string(), s.to_vec()))
.collect()
}
#[test]
fn conformant_paths_carry_the_matched_selector() {
let sel = vec!["src/*.rs".to_string()];
let a = actual(&[("src/state.rs", &[Status::Modified])]);
let c = compute(&sel, &a);
assert_eq!(
c.conformant,
vec![Conformant {
path: "src/state.rs".to_string(),
matched_selector: "src/*.rs".to_string(),
}]
);
assert!(c.undeclared.is_empty());
assert!(c.undelivered.is_empty());
}
#[test]
fn undeclared_path_carries_its_net_verb() {
let sel = vec!["src/state.rs".to_string()];
let a = actual(&[("docs/readme.md", &[Status::Added, Status::Deleted])]);
let c = compute(&sel, &a);
assert!(c.conformant.is_empty());
assert_eq!(
c.undeclared,
vec![Undeclared {
path: "docs/readme.md".to_string(),
verb: Verb::Removed,
}]
);
assert_eq!(c.undelivered, vec!["src/state.rs".to_string()]);
}
#[test]
fn literal_selector_matches_exact_path_only() {
let sel = vec!["src/state.rs".to_string()];
let a = actual(&[
("src/state.rs", &[Status::Modified]),
("src/state_helper.rs", &[Status::Modified]),
]);
let c = compute(&sel, &a);
assert_eq!(c.conformant.len(), 1);
assert_eq!(c.conformant[0].path, "src/state.rs");
assert_eq!(c.undeclared.len(), 1);
assert_eq!(c.undeclared[0].path, "src/state_helper.rs");
}
#[test]
fn glob_selector_absorbs_multiple_paths_each_reporting_the_selector() {
let sel = vec!["src/**".to_string()];
let a = actual(&[
("src/a.rs", &[Status::Added]),
("src/sub/b.rs", &[Status::Modified]),
]);
let c = compute(&sel, &a);
assert_eq!(c.conformant.len(), 2);
assert!(c.conformant.iter().all(|x| x.matched_selector == "src/**"));
assert!(c.undeclared.is_empty());
assert!(c.undelivered.is_empty());
}
#[test]
fn first_matching_selector_wins_and_others_stay_undelivered() {
let sel = vec!["src/**".to_string(), "src/state.rs".to_string()];
let a = actual(&[("src/state.rs", &[Status::Modified])]);
let c = compute(&sel, &a);
assert_eq!(c.conformant[0].matched_selector, "src/**");
assert_eq!(c.undelivered, vec!["src/state.rs".to_string()]);
}
#[test]
fn undeclared_paths_returns_the_unmatched_subset_in_input_order() {
let sel = vec!["src/**".to_string()];
let paths = ["docs/b.md", "src/a.rs", "docs/a.md"];
assert_eq!(
undeclared_paths(&sel, &paths),
vec!["docs/b.md".to_string(), "docs/a.md".to_string()]
);
}
#[test]
fn undeclared_paths_empty_selectors_leaves_every_path_undeclared() {
let sel: Vec<String> = Vec::new();
let paths = ["src/a.rs", "docs/b.md"];
assert_eq!(
undeclared_paths(&sel, &paths),
vec!["src/a.rs".to_string(), "docs/b.md".to_string()]
);
}
#[test]
fn undeclared_paths_glob_selector_absorbs_its_matches() {
let sel = vec!["src/**".to_string()];
let paths = ["src/a.rs", "src/sub/b.rs"];
assert!(undeclared_paths(&sel, &paths).is_empty());
}
#[test]
fn undeclared_paths_literal_selector_matches_its_exact_path_only() {
let sel = vec!["src/state.rs".to_string()];
let paths = ["src/state.rs", "src/state_helper.rs"];
assert_eq!(
undeclared_paths(&sel, &paths),
vec!["src/state_helper.rs".to_string()]
);
}
}