use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::Path;
use anyhow::Result;
use crate::collect::Listing;
use crate::db::{Db, HitKind, HitReason, TestId};
use crate::project::{
git_added_files_since, git_changed_line_ranges, relation_to_head, LineRange, ShaRelation,
};
pub(crate) struct Selection {
pub(crate) affected: BTreeSet<TestId>,
pub(crate) new_tests: BTreeSet<TestId>,
pub(crate) stranded_tests: BTreeSet<TestId>,
pub(crate) config_tests: BTreeSet<TestId>,
pub(crate) reachable_known_count: usize,
pub(crate) listed: BTreeSet<TestId>,
pub(crate) diagnostics: SelectionDiagnostics,
}
impl Selection {
pub(crate) fn selected(&self) -> BTreeSet<TestId> {
let mut out = self.affected.clone();
out.extend(self.new_tests.iter().cloned());
out.extend(self.stranded_tests.iter().cloned());
out.extend(self.config_tests.iter().cloned());
out
}
pub(crate) fn skipped(&self) -> usize {
self.reachable_known_count
.saturating_sub(self.affected.len() + self.config_tests.len())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum DiagnosticDetail {
Summary,
Full,
}
pub(crate) struct SelectionDiagnostics {
pub(crate) per_file: BTreeMap<String, FileReasonCounts>,
pub(crate) per_test: Option<BTreeMap<TestId, Vec<HitReason>>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct FileReasonCounts {
pub(crate) line_overlap: usize,
pub(crate) structural_backstop: usize,
pub(crate) crate_root_sentinel: usize,
pub(crate) config_rule: usize,
pub(crate) total_unique_tests: usize,
}
fn strongest(a: HitKind, b: HitKind) -> HitKind {
fn rank(k: HitKind) -> u8 {
match k {
HitKind::LineOverlap => 3,
HitKind::StructuralBackstop => 2,
HitKind::ConfigRule => 1,
HitKind::CrateRootSentinel => 0,
}
}
if rank(a) >= rank(b) {
a
} else {
b
}
}
pub(crate) type ChangedRangesBySha = BTreeMap<String, BTreeMap<String, Vec<LineRange>>>;
pub(crate) struct Reachability {
pub(crate) per_sha: BTreeMap<String, ShaRelation>,
pub(crate) reachable: BTreeSet<String>,
pub(crate) missing: BTreeSet<String>,
pub(crate) max_commits_ahead: u32,
}
pub(crate) fn missing_shas_notice(missing: &BTreeSet<String>, verb_phrase: &str) -> String {
let plural = if missing.len() == 1 { "" } else { "s" };
let list = missing.iter().cloned().collect::<Vec<_>>().join(", ");
format!(
"note: {} collect_sha{plural} not in the repo ({list}) — \
tests anchored only there {verb_phrase}; \
run `cargo affected clean` to clear stale rows",
missing.len(),
)
}
pub(crate) fn check_shas_reachable(
project_root: &Path,
shas: &BTreeSet<String>,
) -> Result<Reachability> {
let mut per_sha = BTreeMap::new();
let mut reachable = BTreeSet::new();
let mut missing = BTreeSet::new();
let mut max_commits_ahead = 0u32;
for sha in shas {
let relation = relation_to_head(project_root, sha)?;
match &relation {
ShaRelation::Equal => {
reachable.insert(sha.clone());
}
ShaRelation::Reachable { commits_ahead } => {
reachable.insert(sha.clone());
max_commits_ahead = max_commits_ahead.max(*commits_ahead);
}
ShaRelation::Missing => {
missing.insert(sha.clone());
}
}
per_sha.insert(sha.clone(), relation);
}
Ok(Reachability {
per_sha,
reachable,
missing,
max_commits_ahead,
})
}
pub(crate) fn changed_ranges_per_sha(
project_root: &Path,
shas: &BTreeSet<String>,
) -> Result<ChangedRangesBySha> {
let mut out = BTreeMap::new();
for sha in shas {
let ranges = git_changed_line_ranges(project_root, sha)?;
out.insert(sha.clone(), ranges);
}
Ok(out)
}
pub(crate) fn select_with_reach(
project_root: &Path,
db: &Db,
fingerprint: &str,
listing: &Listing,
reach: &Reachability,
detail: DiagnosticDetail,
) -> Result<Selection> {
let changed_ranges_by_sha = changed_ranges_per_sha(project_root, &reach.reachable)?;
compute(
db,
fingerprint,
&reach.reachable,
&changed_ranges_by_sha,
listing,
&BTreeMap::new(),
detail,
)
}
pub(crate) fn select_with_precomputed_ranges(
db: &Db,
fingerprint: &str,
listing: &Listing,
reach: &Reachability,
changed_ranges_by_sha: &ChangedRangesBySha,
config_hits: &BTreeMap<String, BTreeSet<TestId>>,
detail: DiagnosticDetail,
) -> Result<Selection> {
compute(
db,
fingerprint,
&reach.reachable,
changed_ranges_by_sha,
listing,
config_hits,
detail,
)
}
pub(crate) fn changed_paths_since(
project_root: &Path,
reach: &Reachability,
changed_ranges_by_sha: &ChangedRangesBySha,
working_tree_files: &[String],
) -> Result<BTreeSet<String>> {
let mut paths: BTreeSet<String> = working_tree_files.iter().cloned().collect();
for by_file in changed_ranges_by_sha.values() {
paths.extend(by_file.keys().cloned());
}
for sha in &reach.reachable {
paths.extend(git_added_files_since(project_root, sha)?);
}
Ok(paths)
}
pub(crate) fn compute(
db: &Db,
env_fingerprint: &str,
reachable_shas: &BTreeSet<String>,
changed_ranges_by_sha: &ChangedRangesBySha,
listing: &Listing,
config_hits: &BTreeMap<String, BTreeSet<TestId>>,
detail: DiagnosticDetail,
) -> Result<Selection> {
db.touch(env_fingerprint)?;
let listed: BTreeSet<TestId> = listing.tests.iter().cloned().collect();
let reachable_known = db.all_tests_at_shas(env_fingerprint, reachable_shas)?;
let reachable_known_count = reachable_known.len();
let all_db_tests = db.all_tests_for_fingerprint(env_fingerprint)?;
let mut new_tests = BTreeSet::new();
let mut stranded_tests = BTreeSet::new();
for t in &listed {
if listing.ignored.contains(t) {
continue;
}
if reachable_known.contains(t) {
continue;
}
if all_db_tests.contains(t) {
stranded_tests.insert(t.clone());
} else {
new_tests.insert(t.clone());
}
}
let mut affected = BTreeSet::new();
let mut strongest_per_file_test: BTreeMap<String, BTreeMap<TestId, HitKind>> =
BTreeMap::new();
let mut per_test_reasons: BTreeMap<TestId, Vec<HitReason>> = BTreeMap::new();
let retain_per_test = matches!(detail, DiagnosticDetail::Full);
for (collect_sha, ranges_by_file) in changed_ranges_by_sha {
for (file, hunks) in ranges_by_file {
if hunks.is_empty() {
continue;
}
let hits =
db.tests_covering_ranges(env_fingerprint, collect_sha, file, hunks)?;
for hit in hits {
if listing.ignored.contains(&hit.test_id) {
continue;
}
affected.insert(hit.test_id.clone());
let kind = hit.reason.kind;
let entry = strongest_per_file_test
.entry(hit.reason.file.clone())
.or_default()
.entry(hit.test_id.clone());
entry
.and_modify(|existing| *existing = strongest(*existing, kind))
.or_insert(kind);
if retain_per_test {
per_test_reasons
.entry(hit.test_id)
.or_default()
.push(hit.reason);
}
}
}
}
let mut config_tests = BTreeSet::new();
for (path, tests) in config_hits {
for test in tests {
if listing.ignored.contains(test)
|| affected.contains(test)
|| !reachable_known.contains(test)
{
continue;
}
config_tests.insert(test.clone());
strongest_per_file_test
.entry(path.clone())
.or_default()
.entry(test.clone())
.and_modify(|k| *k = strongest(*k, HitKind::ConfigRule))
.or_insert(HitKind::ConfigRule);
if retain_per_test {
per_test_reasons.entry(test.clone()).or_default().push(HitReason {
collect_sha: String::new(),
file: path.clone(),
kind: HitKind::ConfigRule,
matched_hunk: (0, 0),
stored_range: None,
});
}
}
}
let per_file = aggregate_per_file_counts(&strongest_per_file_test);
let diagnostics = SelectionDiagnostics {
per_file,
per_test: retain_per_test.then_some(per_test_reasons),
};
Ok(Selection {
affected,
new_tests,
stranded_tests,
config_tests,
reachable_known_count,
listed,
diagnostics,
})
}
fn aggregate_per_file_counts(
strongest_per_file_test: &BTreeMap<String, BTreeMap<TestId, HitKind>>,
) -> BTreeMap<String, FileReasonCounts> {
let mut out: BTreeMap<String, FileReasonCounts> = BTreeMap::new();
for (file, by_test) in strongest_per_file_test {
let mut counts = FileReasonCounts::default();
for kind in by_test.values() {
match kind {
HitKind::LineOverlap => counts.line_overlap += 1,
HitKind::StructuralBackstop => counts.structural_backstop += 1,
HitKind::CrateRootSentinel => counts.crate_root_sentinel += 1,
HitKind::ConfigRule => counts.config_rule += 1,
}
counts.total_unique_tests += 1;
}
out.insert(file.clone(), counts);
}
out
}
pub(crate) fn format_summary(sel: &Selection, verb: &str, verbose: bool) -> String {
let selected = sel.selected();
let mut out = format!(
"{} tests {verb} ({} affected + {} config + {} new + {} stranded, \
{} skipped of {} reachable-known)",
selected.len(),
sel.affected.len(),
sel.config_tests.len(),
sel.new_tests.len(),
sel.stranded_tests.len(),
sel.skipped(),
sel.reachable_known_count,
);
if verbose {
out.push(':');
for t in &selected {
let tag = if sel.new_tests.contains(t) {
" (new)"
} else if sel.stranded_tests.contains(t) {
" (stranded)"
} else if sel.config_tests.contains(t) {
" (config)"
} else {
""
};
let _ = write!(out, "\n {}::{}{tag}", t.binary_id, t.test_name);
}
} else {
out.push_str(" — pass -v to list");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tid(binary_id: &str, test_name: &str) -> TestId {
TestId::new(binary_id, test_name)
}
fn selection_with(
affected: &[TestId],
new_tests: &[TestId],
stranded_tests: &[TestId],
config_tests: &[TestId],
reachable_known_count: usize,
) -> Selection {
let listed: BTreeSet<TestId> = affected
.iter()
.cloned()
.chain(new_tests.iter().cloned())
.chain(stranded_tests.iter().cloned())
.chain(config_tests.iter().cloned())
.collect();
Selection {
affected: affected.iter().cloned().collect(),
new_tests: new_tests.iter().cloned().collect(),
stranded_tests: stranded_tests.iter().cloned().collect(),
config_tests: config_tests.iter().cloned().collect(),
reachable_known_count,
listed,
diagnostics: SelectionDiagnostics {
per_file: BTreeMap::new(),
per_test: None,
},
}
}
#[test]
fn summary_compact_form() {
let sel = selection_with(
&[tid("crate_a", "test_a"), tid("crate_a", "test_b")],
&[tid("crate_a", "test_c")],
&[],
&[],
5,
);
let out = format_summary(&sel, "to run", false);
assert_eq!(
out,
"3 tests to run (2 affected + 0 config + 1 new + 0 stranded, \
3 skipped of 5 reachable-known) — pass -v to list"
);
}
#[test]
fn summary_verbose_tags_categories() {
let sel = selection_with(
&[tid("crate_a", "test_a")],
&[tid("crate_a", "test_b")],
&[tid("crate_a", "test_c")],
&[tid("crate_a", "test_d")],
5,
);
let out = format_summary(&sel, "would run", true);
assert_eq!(
out,
"4 tests would run (1 affected + 1 config + 1 new + 1 stranded, \
3 skipped of 5 reachable-known):\n \
crate_a::test_a\n \
crate_a::test_b (new)\n \
crate_a::test_c (stranded)\n \
crate_a::test_d (config)"
);
}
#[test]
fn skipped_subtracts_affected_and_config() {
let sel = selection_with(
&[tid("crate_a", "a"), tid("crate_a", "b")],
&[],
&[],
&[tid("crate_a", "c")],
3,
);
assert_eq!(sel.skipped(), 0);
}
#[test]
fn strongest_reason_orders_line_then_backstop_then_sentinel() {
for (a, b, expected) in [
(HitKind::LineOverlap, HitKind::CrateRootSentinel, HitKind::LineOverlap),
(HitKind::CrateRootSentinel, HitKind::LineOverlap, HitKind::LineOverlap),
(HitKind::StructuralBackstop, HitKind::CrateRootSentinel, HitKind::StructuralBackstop),
(HitKind::CrateRootSentinel, HitKind::StructuralBackstop, HitKind::StructuralBackstop),
(HitKind::LineOverlap, HitKind::StructuralBackstop, HitKind::LineOverlap),
(HitKind::StructuralBackstop, HitKind::LineOverlap, HitKind::LineOverlap),
] {
assert_eq!(strongest(a, b), expected, "{a:?} vs {b:?}");
}
}
#[test]
fn aggregate_dedupes_test_per_file_by_strongest_reason() {
let test_a = tid("crate_a", "a");
let mut strongest: BTreeMap<String, BTreeMap<TestId, HitKind>> = BTreeMap::new();
let by_test = strongest.entry("src/lib.rs".to_string()).or_default();
by_test.insert(test_a.clone(), HitKind::CrateRootSentinel);
let entry = by_test.entry(test_a.clone());
entry
.and_modify(|k| *k = super::strongest(*k, HitKind::LineOverlap))
.or_insert(HitKind::LineOverlap);
let counts = aggregate_per_file_counts(&strongest);
let lib = counts.get("src/lib.rs").expect("src/lib.rs should appear");
assert_eq!(lib.line_overlap, 1);
assert_eq!(lib.structural_backstop, 0);
assert_eq!(lib.crate_root_sentinel, 0);
assert_eq!(lib.total_unique_tests, 1);
}
}