use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::db::{Db, HitKind, HitReason, StoredFingerprintRow, TestId};
use crate::fingerprint::FingerprintComponent;
use crate::project::{git_added_files_since, LineRange, ShaRelation};
use crate::selection::{FileReasonCounts, Reachability, Selection};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Serialize)]
pub struct Report {
pub schema_version: u32,
pub cargo_affected_version: &'static str,
pub command: &'static str,
pub cache: CacheReport,
pub selection: SelectionReport,
}
#[derive(Debug, Serialize)]
pub struct CacheReport {
pub status: CacheStatus,
pub current_fingerprint: Option<String>,
pub current_components: Option<Vec<ComponentEntry>>,
pub stored_fingerprints: Vec<StoredFingerprintEntry>,
pub collect_shas: Vec<CollectShaEntry>,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CacheStatus {
HitExact,
HitWithDivergence,
MissFingerprint,
MissNoCoverage,
MissNoReachableSha,
ForcedAll,
}
impl CacheStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::HitExact => "hit-exact",
Self::HitWithDivergence => "hit-with-divergence",
Self::MissFingerprint => "miss-fingerprint",
Self::MissNoCoverage => "miss-no-coverage",
Self::MissNoReachableSha => "miss-no-reachable-sha",
Self::ForcedAll => "forced-all",
}
}
}
impl std::fmt::Display for CacheStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Serialize)]
pub struct ComponentEntry {
pub label: String,
pub hash: String,
}
#[derive(Debug, Serialize)]
pub struct StoredFingerprintEntry {
pub fingerprint: String,
pub last_seen: String,
pub diff_count: usize,
pub differing_labels: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct CollectShaEntry {
pub sha: String,
pub relation: ShaRelationKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub commits_ahead: Option<u32>,
pub row_count: usize,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ShaRelationKind {
Equal,
Reachable,
Missing,
}
#[derive(Debug, Serialize)]
pub struct SelectionReport {
pub summary: SelectionSummary,
pub changed_files: Option<Vec<ChangedFileEntry>>,
pub selected_tests: Option<Vec<SelectedTestEntry>>,
}
#[derive(Debug, Serialize)]
pub struct SelectionSummary {
pub selected: Option<usize>,
pub affected: Option<usize>,
pub config: Option<usize>,
pub new: Option<usize>,
pub stranded: Option<usize>,
pub skipped: Option<usize>,
pub total_reachable_known: Option<usize>,
pub mode: SelectionMode,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum SelectionMode {
Selection,
FullSuiteNoListing,
}
#[derive(Debug, Serialize)]
pub struct ChangedFileEntry {
pub path: String,
pub tracked_by_coverage: bool,
pub hunks_by_sha: Vec<HunksForSha>,
pub tests_pulled_total: usize,
pub tests_pulled_by_reason: ReasonCounts,
}
#[derive(Debug, Serialize)]
pub struct HunksForSha {
pub sha: String,
pub hunks: Vec<HunkEntry>,
}
#[derive(Debug, Serialize)]
pub struct HunkEntry {
pub start: i64,
pub end: i64,
}
#[derive(Debug, Serialize, Default)]
pub struct ReasonCounts {
pub line_overlap: usize,
pub structural_backstop: usize,
pub crate_root_sentinel: usize,
pub config_rule: usize,
}
#[derive(Debug, Serialize)]
pub struct SelectedTestEntry {
pub binary_id: String,
pub test_name: String,
pub kind: SelectedTestKind,
pub reasons: Vec<ReasonEntry>,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SelectedTestKind {
Affected,
ConfigRule,
New,
Stranded,
}
#[derive(Debug, Serialize)]
pub struct ReasonEntry {
pub collect_sha: String,
pub file: String,
pub kind: ReasonKind,
pub stored_range: Option<[i64; 2]>,
pub matched_hunk: [i64; 2],
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ReasonKind {
LineOverlap,
StructuralBackstop,
CrateRootSentinel,
ConfigRule,
}
impl From<HitKind> for ReasonKind {
fn from(kind: HitKind) -> Self {
match kind {
HitKind::LineOverlap => Self::LineOverlap,
HitKind::StructuralBackstop => Self::StructuralBackstop,
HitKind::CrateRootSentinel => Self::CrateRootSentinel,
HitKind::ConfigRule => Self::ConfigRule,
}
}
}
pub struct SelectionInputs<'a> {
pub command: &'static str,
pub current_fingerprint: String,
pub current_components: Vec<FingerprintComponent>,
pub stored_fingerprints: Vec<StoredFingerprintSnapshot>,
pub collect_shas: Vec<CollectShaSnapshot>,
pub status: CacheStatus,
pub selection: &'a Selection,
pub changed_files: Vec<ChangedFileInput>,
pub include_changed_files: bool,
}
pub struct FullSuiteInputs {
pub command: &'static str,
pub current_fingerprint: Option<String>,
pub current_components: Option<Vec<FingerprintComponent>>,
pub stored_fingerprints: Vec<StoredFingerprintSnapshot>,
pub collect_shas: Vec<CollectShaSnapshot>,
pub status: CacheStatus,
}
pub struct StoredFingerprintSnapshot {
pub fingerprint: String,
pub last_seen: String,
pub components: Vec<FingerprintComponent>,
}
impl From<StoredFingerprintRow> for StoredFingerprintSnapshot {
fn from(row: StoredFingerprintRow) -> Self {
Self {
fingerprint: row.fingerprint,
last_seen: row.last_seen,
components: row.components,
}
}
}
pub fn snapshots_from(rows: Vec<StoredFingerprintRow>) -> Vec<StoredFingerprintSnapshot> {
rows.into_iter().map(Into::into).collect()
}
pub struct CollectShaSnapshot {
pub sha: String,
pub relation: ShaRelation,
pub row_count: usize,
}
pub struct ChangedFileInput {
pub path: String,
pub tracked_by_coverage: bool,
pub hunks_by_sha: BTreeMap<String, Vec<(i64, i64)>>,
}
pub fn collect_sha_snapshots(
reach: &Reachability,
row_counts: &BTreeMap<String, usize>,
) -> Vec<CollectShaSnapshot> {
reach
.per_sha
.iter()
.map(|(sha, relation)| CollectShaSnapshot {
sha: sha.clone(),
relation: relation.clone(),
row_count: row_counts.get(sha).copied().unwrap_or(0),
})
.collect()
}
pub fn build_changed_file_inputs(
project_root: &std::path::Path,
db: &Db,
fingerprint: &str,
reach: &Reachability,
changed_ranges_by_sha: &BTreeMap<String, BTreeMap<String, Vec<LineRange>>>,
working_tree_files: &[String],
) -> Result<Vec<ChangedFileInput>> {
let mut all_files: BTreeSet<String> = working_tree_files.iter().cloned().collect();
for by_file in changed_ranges_by_sha.values() {
for path in by_file.keys() {
all_files.insert(path.clone());
}
}
for sha in &reach.reachable {
for added in git_added_files_since(project_root, sha)? {
all_files.insert(added);
}
}
let tracked = db.tracked_files_at_shas(fingerprint, &reach.reachable)?;
let mut out = Vec::new();
for path in all_files {
let mut hunks_by_sha: BTreeMap<String, Vec<(i64, i64)>> = BTreeMap::new();
for (sha, by_file) in changed_ranges_by_sha {
if let Some(hunks) = by_file.get(&path) {
hunks_by_sha.insert(
sha.clone(),
hunks.iter().map(|h| (h.start, h.end)).collect(),
);
}
}
out.push(ChangedFileInput {
tracked_by_coverage: tracked.contains(&path),
path,
hunks_by_sha,
});
}
Ok(out)
}
pub fn summary_line(
status: CacheStatus,
selection: Option<(usize, usize)>,
missing_shas: usize,
max_commits_ahead: u32,
) -> String {
match status {
CacheStatus::HitExact | CacheStatus::HitWithDivergence => {
let (selected, total) = selection.unwrap_or((0, 0));
let pct = (selected * 100).checked_div(total).unwrap_or(100);
let mut line = format!(
"cargo-affected: cache={status} selection={selected}/{total} ({pct}%)"
);
if matches!(status, CacheStatus::HitWithDivergence) {
if missing_shas > 0 {
line.push_str(&format!(" missing_shas={missing_shas}"));
}
if max_commits_ahead > 0 {
line.push_str(&format!(" max_commits_ahead={max_commits_ahead}"));
}
}
line
}
CacheStatus::MissNoReachableSha => {
format!(
"cargo-affected: cache={status} mode=full-suite missing_shas={missing_shas}"
)
}
_ => format!("cargo-affected: cache={status} mode=full-suite"),
}
}
impl Report {
pub fn build_selection(inputs: SelectionInputs<'_>) -> Self {
let selection = inputs.selection;
let summary = SelectionSummary {
selected: Some(selection.selected().len()),
affected: Some(selection.affected.len()),
config: Some(selection.config_tests.len()),
new: Some(selection.new_tests.len()),
stranded: Some(selection.stranded_tests.len()),
skipped: Some(selection.skipped()),
total_reachable_known: Some(selection.reachable_known_count),
mode: SelectionMode::Selection,
};
let changed_files = if inputs.include_changed_files {
Some(build_changed_files_entries(
&inputs.changed_files,
&selection.diagnostics.per_file,
))
} else {
None
};
let selected_tests = selection.diagnostics.per_test.as_ref().map(|per_test| {
build_selected_tests(
&selection.affected,
&selection.config_tests,
&selection.new_tests,
&selection.stranded_tests,
per_test,
)
});
Self {
schema_version: SCHEMA_VERSION,
cargo_affected_version: env!("CARGO_PKG_VERSION"),
command: inputs.command,
cache: build_cache(
inputs.status,
Some(inputs.current_fingerprint),
Some(inputs.current_components),
inputs.stored_fingerprints,
inputs.collect_shas,
),
selection: SelectionReport {
summary,
changed_files,
selected_tests,
},
}
}
pub fn build_full_suite(inputs: FullSuiteInputs) -> Self {
Self {
schema_version: SCHEMA_VERSION,
cargo_affected_version: env!("CARGO_PKG_VERSION"),
command: inputs.command,
cache: build_cache(
inputs.status,
inputs.current_fingerprint,
inputs.current_components,
inputs.stored_fingerprints,
inputs.collect_shas,
),
selection: SelectionReport {
summary: SelectionSummary {
selected: None,
affected: None,
config: None,
new: None,
stranded: None,
skipped: None,
total_reachable_known: None,
mode: SelectionMode::FullSuiteNoListing,
},
changed_files: None,
selected_tests: None,
},
}
}
pub fn write_json(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.context("failed to serialize report to JSON")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)
.with_context(|| format!("failed to write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| {
format!("failed to rename {} -> {}", tmp.display(), path.display())
})?;
Ok(())
}
}
fn build_cache(
status: CacheStatus,
current_fingerprint: Option<String>,
current_components: Option<Vec<FingerprintComponent>>,
stored_fingerprints: Vec<StoredFingerprintSnapshot>,
collect_shas: Vec<CollectShaSnapshot>,
) -> CacheReport {
let current_for_diff: &[FingerprintComponent] = current_components.as_deref().unwrap_or(&[]);
let stored = build_stored_fingerprints(stored_fingerprints, current_for_diff);
CacheReport {
status,
current_fingerprint,
current_components: current_components.map(|cs| {
cs.into_iter()
.map(|c| ComponentEntry {
label: c.label,
hash: c.hash,
})
.collect()
}),
stored_fingerprints: stored,
collect_shas: build_collect_shas(collect_shas),
}
}
fn build_stored_fingerprints(
stored: Vec<StoredFingerprintSnapshot>,
current: &[FingerprintComponent],
) -> Vec<StoredFingerprintEntry> {
let mut entries: Vec<StoredFingerprintEntry> = stored
.into_iter()
.map(|snap| {
let differing_labels = diff_labels(current, &snap.components);
StoredFingerprintEntry {
diff_count: differing_labels.len(),
differing_labels,
fingerprint: snap.fingerprint,
last_seen: snap.last_seen,
}
})
.collect();
entries.sort_by(|a, b| {
a.diff_count
.cmp(&b.diff_count)
.then(b.last_seen.cmp(&a.last_seen))
});
entries
}
fn diff_labels(
current: &[FingerprintComponent],
stored: &[FingerprintComponent],
) -> Vec<String> {
let current_by_label: BTreeMap<&str, &str> =
current.iter().map(|c| (c.label.as_str(), c.hash.as_str())).collect();
let stored_by_label: BTreeMap<&str, &str> =
stored.iter().map(|c| (c.label.as_str(), c.hash.as_str())).collect();
let mut differing: BTreeSet<String> = BTreeSet::new();
for (label, hash) in ¤t_by_label {
if stored_by_label.get(*label) != Some(hash) {
differing.insert((*label).to_string());
}
}
for label in stored_by_label.keys() {
if !current_by_label.contains_key(*label) {
differing.insert((*label).to_string());
}
}
differing.into_iter().collect()
}
pub fn closest_stored_diff_labels(
current: &[FingerprintComponent],
stored: &[StoredFingerprintSnapshot],
) -> Vec<String> {
stored
.iter()
.map(|snap| diff_labels(current, &snap.components))
.min_by_key(Vec::len)
.unwrap_or_default()
}
pub fn fingerprint_miss_clause(labels: &[String]) -> String {
if labels.is_empty() {
String::new()
} else {
format!(
" (differs from closest stored fingerprint in: {})",
labels.join(", ")
)
}
}
fn build_collect_shas(shas: Vec<CollectShaSnapshot>) -> Vec<CollectShaEntry> {
let mut entries: Vec<CollectShaEntry> = shas
.into_iter()
.map(|s| {
let (relation, commits_ahead) = match s.relation {
ShaRelation::Equal => (ShaRelationKind::Equal, None),
ShaRelation::Reachable { commits_ahead } => {
(ShaRelationKind::Reachable, Some(commits_ahead))
}
ShaRelation::Missing => (ShaRelationKind::Missing, None),
};
CollectShaEntry {
sha: s.sha,
relation,
commits_ahead,
row_count: s.row_count,
}
})
.collect();
entries.sort_by(|a, b| a.sha.cmp(&b.sha));
entries
}
fn build_changed_files_entries(
inputs: &[ChangedFileInput],
per_file_counts: &BTreeMap<String, FileReasonCounts>,
) -> Vec<ChangedFileEntry> {
let mut entries: Vec<ChangedFileEntry> = inputs
.iter()
.map(|f| {
let counts = per_file_counts.get(&f.path).cloned().unwrap_or_default();
let hunks_by_sha = f
.hunks_by_sha
.iter()
.map(|(sha, hunks)| HunksForSha {
sha: sha.clone(),
hunks: hunks
.iter()
.map(|(s, e)| HunkEntry { start: *s, end: *e })
.collect(),
})
.collect();
ChangedFileEntry {
path: f.path.clone(),
tracked_by_coverage: f.tracked_by_coverage,
hunks_by_sha,
tests_pulled_total: counts.total_unique_tests,
tests_pulled_by_reason: ReasonCounts {
line_overlap: counts.line_overlap,
structural_backstop: counts.structural_backstop,
crate_root_sentinel: counts.crate_root_sentinel,
config_rule: counts.config_rule,
},
}
})
.collect();
entries.sort_by(|a, b| {
b.tests_pulled_total
.cmp(&a.tests_pulled_total)
.then(a.path.cmp(&b.path))
});
entries
}
fn build_selected_tests(
affected: &BTreeSet<TestId>,
config_tests: &BTreeSet<TestId>,
new_tests: &BTreeSet<TestId>,
stranded: &BTreeSet<TestId>,
per_test: &BTreeMap<TestId, Vec<HitReason>>,
) -> Vec<SelectedTestEntry> {
let mut out: Vec<SelectedTestEntry> = Vec::new();
let union: BTreeSet<&TestId> = affected
.iter()
.chain(config_tests.iter())
.chain(new_tests.iter())
.chain(stranded.iter())
.collect();
for test in union {
let kind = if new_tests.contains(test) {
SelectedTestKind::New
} else if stranded.contains(test) {
SelectedTestKind::Stranded
} else if config_tests.contains(test) {
SelectedTestKind::ConfigRule
} else {
SelectedTestKind::Affected
};
let mut reasons: Vec<ReasonEntry> = per_test
.get(test)
.map(|rs| rs.iter().map(reason_entry).collect())
.unwrap_or_default();
reasons.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then(a.kind.cmp(&b.kind))
.then(a.collect_sha.cmp(&b.collect_sha))
});
out.push(SelectedTestEntry {
binary_id: test.binary_id.clone(),
test_name: test.test_name.clone(),
kind,
reasons,
});
}
out
}
fn reason_entry(r: &HitReason) -> ReasonEntry {
ReasonEntry {
collect_sha: r.collect_sha.clone(),
file: r.file.clone(),
kind: r.kind.into(),
stored_range: r.stored_range.map(|(s, e)| [s, e]),
matched_hunk: [r.matched_hunk.0, r.matched_hunk.1],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::ShaRelation;
fn fp_component(label: &str, hash: &str) -> FingerprintComponent {
FingerprintComponent {
label: label.to_string(),
hash: hash.to_string(),
}
}
#[test]
fn cache_status_serializes_kebab_case() {
let json = serde_json::to_string(&CacheStatus::HitWithDivergence).unwrap();
assert_eq!(json, "\"hit-with-divergence\"");
}
#[test]
fn stored_fingerprint_diff_is_symmetric() {
let current = vec![
fp_component("cargo_lock", "h-current-lock"),
fp_component("rustc", "h-rustc"),
];
let stored = vec![StoredFingerprintSnapshot {
fingerprint: "old".to_string(),
last_seen: "2026-01-01T00:00:00Z".to_string(),
components: vec![
fp_component("cargo_lock", "h-old-lock"),
fp_component("rustc", "h-rustc"),
fp_component("manifest:Cargo.toml", "h-old-manifest"),
],
}];
let entries = build_stored_fingerprints(stored, ¤t);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].diff_count, 2);
assert_eq!(
entries[0].differing_labels,
vec!["cargo_lock", "manifest:Cargo.toml"],
);
}
#[test]
fn closest_stored_diff_labels_picks_min_diff() {
let current = vec![
fp_component("rustc", "host-A"),
fp_component("CARGO_BUILD_TARGET", "tgt-A"),
];
let stored = vec![
StoredFingerprintSnapshot {
fingerprint: "far".to_string(),
last_seen: "2026-01-01T00:00:00Z".to_string(),
components: vec![
fp_component("rustc", "host-X"),
fp_component("CARGO_BUILD_TARGET", "tgt-X"),
],
},
StoredFingerprintSnapshot {
fingerprint: "close".to_string(),
last_seen: "2026-04-01T00:00:00Z".to_string(),
components: vec![
fp_component("rustc", "host-X"),
fp_component("CARGO_BUILD_TARGET", "tgt-A"),
],
},
];
let labels = closest_stored_diff_labels(¤t, &stored);
assert_eq!(labels, vec!["rustc"]);
assert!(closest_stored_diff_labels(¤t, &[]).is_empty());
}
#[test]
fn stored_fingerprints_sorted_by_diff_then_recency() {
let current = vec![fp_component("rustc", "h")];
let stored = vec![
StoredFingerprintSnapshot {
fingerprint: "older-far".to_string(),
last_seen: "2026-01-01T00:00:00Z".to_string(),
components: vec![fp_component("rustc", "DIFF")],
},
StoredFingerprintSnapshot {
fingerprint: "newer-close".to_string(),
last_seen: "2026-05-01T00:00:00Z".to_string(),
components: vec![fp_component("rustc", "h")],
},
StoredFingerprintSnapshot {
fingerprint: "older-close".to_string(),
last_seen: "2026-02-01T00:00:00Z".to_string(),
components: vec![fp_component("rustc", "h")],
},
];
let entries = build_stored_fingerprints(stored, ¤t);
let order: Vec<&str> = entries.iter().map(|e| e.fingerprint.as_str()).collect();
assert_eq!(order, vec!["newer-close", "older-close", "older-far"]);
}
#[test]
fn collect_shas_render_relation_and_commits_ahead() {
let entries = build_collect_shas(vec![
CollectShaSnapshot {
sha: "aaa".to_string(),
relation: ShaRelation::Equal,
row_count: 10,
},
CollectShaSnapshot {
sha: "bbb".to_string(),
relation: ShaRelation::Reachable { commits_ahead: 6 },
row_count: 100,
},
CollectShaSnapshot {
sha: "ccc".to_string(),
relation: ShaRelation::Missing,
row_count: 5,
},
]);
assert_eq!(entries[0].relation, ShaRelationKind::Equal);
assert_eq!(entries[0].commits_ahead, None);
assert_eq!(entries[1].relation, ShaRelationKind::Reachable);
assert_eq!(entries[1].commits_ahead, Some(6));
assert_eq!(entries[2].relation, ShaRelationKind::Missing);
assert_eq!(entries[2].commits_ahead, None);
}
#[test]
fn full_suite_report_has_null_counts_and_omitted_arrays() {
let report = Report::build_full_suite(FullSuiteInputs {
command: "run",
current_fingerprint: Some("abc".to_string()),
current_components: Some(vec![fp_component("cargo_lock", "h")]),
stored_fingerprints: vec![],
collect_shas: vec![],
status: CacheStatus::ForcedAll,
});
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
assert_eq!(json["selection"]["summary"]["mode"], "full-suite-no-listing");
assert!(json["selection"]["summary"]["selected"].is_null());
assert!(json["selection"]["changed_files"].is_null());
assert!(json["selection"]["selected_tests"].is_null());
assert_eq!(json["cache"]["status"], "forced-all");
}
}