use anyhow::{anyhow, Context, Result};
use super::helpers::*;
use crate::cli::OutputConfig;
use colored::Colorize;
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use crate::storage::sqlite::{MetadataStore, NewBugCausality, PriorTouch};
#[derive(Debug, Clone, PartialEq)]
pub(super) struct CausalityCandidate {
pub(super) file: String,
pub(super) culprit_sha: String,
pub(super) culprit_bead_id: String,
pub(super) confidence: f64,
}
#[derive(Serialize)]
struct CausalityOutput {
bug_id: String,
rows: usize,
}
pub(super) fn run_reconstruct_causality(
repo_root: &Path,
store: &MetadataStore,
bug: Option<&str>,
limit: usize,
output: &OutputConfig,
) -> Result<()> {
let git = crate::index::GitAnalyzer::new(repo_root).ok();
let bug_ids: Vec<String> = match bug {
Some(b) => vec![b.to_string()],
None => store
.distinct_lineage_bead_ids()?
.into_iter()
.filter(|(id, ty)| is_bug_bead(id, ty.as_deref()))
.map(|(id, _)| id)
.take(limit)
.collect(),
};
let mut results: Vec<CausalityOutput> = Vec::new();
for bug_id in &bug_ids {
let fix_rows = store.list_bead_lineage(Some(bug_id), None, 1000)?;
if fix_rows.is_empty() {
continue;
}
let mut fix_files: Vec<String> = Vec::new();
for r in &fix_rows {
for f in &r.touched_files {
if !fix_files.contains(f) {
fix_files.push(f.clone());
}
}
}
let before = fix_rows
.iter()
.map(|r| r.created_at.as_str())
.min()
.unwrap_or("")
.to_string();
if fix_files.is_empty() || before.is_empty() {
continue;
}
let prior: Vec<PriorTouch> = store
.prior_lineage_touching_files(&fix_files, &before)?
.into_iter()
.filter(|t| &t.bead_id != bug_id)
.collect();
let fallback = reconstruct_culprits(&fix_files, &prior);
let mut blame_map: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
if let Some(git) = git.as_ref() {
for row in &fix_rows {
let Some(sha) = row.commit_sha.as_deref().filter(|s| !s.is_empty()) else {
continue;
};
for file in &row.touched_files {
if let Ok(entries) = git.blame_fix_culprits(sha, file) {
if entries.is_empty() {
continue;
}
let shas = blame_map.entry(file.clone()).or_default();
for e in entries {
shas.push(e.commit_hash);
}
}
}
}
}
let candidates = merge_causality(fallback, &blame_map);
for c in &candidates {
store.record_bug_causality(&NewBugCausality {
bug_id: bug_id.clone(),
culprit_sha: Some(c.culprit_sha.clone()),
culprit_bead_id: Some(c.culprit_bead_id.clone()).filter(|b| !b.is_empty()),
file: Some(c.file.clone()),
confidence: Some(c.confidence),
})?;
}
if !candidates.is_empty() {
results.push(CausalityOutput {
bug_id: bug_id.clone(),
rows: candidates.len(),
});
}
}
if output.json {
println!("{}", serde_json::to_string_pretty(&results)?);
} else if !output.quiet {
let total: usize = results.iter().map(|r| r.rows).sum();
if results.is_empty() {
println!("{}", "No bug causality reconstructed.".dimmed());
} else {
for r in &results {
println!(
"{} {} {} culprit row{}",
"✓".green(),
r.bug_id.cyan(),
r.rows,
if r.rows == 1 { "" } else { "s" },
);
}
println!(
"{} {} bug(s), {} causality row(s) recorded",
"•".dimmed(),
results.len(),
total
);
}
}
Ok(())
}
fn is_bug_bead(bead_id: &str, lineage_type: Option<&str>) -> bool {
if let Some(t) = lineage_type {
if !t.is_empty() {
return t.eq_ignore_ascii_case("bug");
}
}
bead_json(bead_id)
.and_then(|v| {
v.get("issue_type")
.and_then(|t| t.as_str())
.map(|t| t.eq_ignore_ascii_case("bug"))
})
.unwrap_or(false)
}
pub(super) fn reconstruct_culprits(
fix_files: &[String],
prior: &[PriorTouch],
) -> Vec<CausalityCandidate> {
use std::collections::{HashMap, HashSet};
let fix_set: HashSet<&str> = fix_files.iter().map(|s| s.as_str()).collect();
let mut overlap: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut chosen: HashMap<&str, &PriorTouch> = HashMap::new();
for t in prior {
let sha = match t.commit_sha.as_deref() {
Some(s) if !s.is_empty() => s,
_ => continue,
};
if !fix_set.contains(t.file.as_str()) {
continue;
}
overlap.entry(sha).or_default().insert(t.file.as_str());
chosen.entry(t.file.as_str()).or_insert(t);
}
let n = fix_files.len().max(1) as f64;
let mut out: Vec<CausalityCandidate> = chosen
.into_iter()
.map(|(file, t)| {
let sha = t.commit_sha.clone().unwrap_or_default();
let breadth = overlap.get(sha.as_str()).map(|s| s.len()).unwrap_or(1) as f64;
CausalityCandidate {
file: file.to_string(),
culprit_sha: sha,
culprit_bead_id: t.bead_id.clone(),
confidence: (breadth / n).clamp(0.1, 0.95),
}
})
.collect();
out.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file.cmp(&b.file))
});
out
}
pub(super) fn dominant_culprit(shas: &[String]) -> Option<(String, usize, usize)> {
use std::collections::HashMap;
if shas.is_empty() {
return None;
}
let total = shas.len();
let mut counts: HashMap<&str, usize> = HashMap::new();
for s in shas {
*counts.entry(s.as_str()).or_default() += 1;
}
let mut pairs: Vec<(&str, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
let (sha, n) = pairs[0];
Some((sha.to_string(), n, total))
}
pub(super) fn blame_confidence(attributed: usize, total: usize) -> f64 {
let frac = attributed as f64 / (total.max(1) as f64);
(0.6 + 0.38 * frac).clamp(0.6, 0.98)
}
pub(super) fn merge_causality(
fallback: Vec<CausalityCandidate>,
blame_map: &std::collections::HashMap<String, Vec<String>>,
) -> Vec<CausalityCandidate> {
use std::collections::HashMap;
let mut by_file: HashMap<String, CausalityCandidate> =
fallback.into_iter().map(|c| (c.file.clone(), c)).collect();
for (file, shas) in blame_map {
if let Some((sha, attributed, total)) = dominant_culprit(shas) {
by_file.insert(
file.clone(),
CausalityCandidate {
file: file.clone(),
culprit_sha: sha,
culprit_bead_id: String::new(), confidence: blame_confidence(attributed, total),
},
);
}
}
let mut out: Vec<CausalityCandidate> = by_file.into_values().collect();
out.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file.cmp(&b.file))
});
out
}
pub(super) fn commit_numstat(repo_root: &Path, sha: &str) -> Result<(Vec<String>, i64, i64)> {
let out = Command::new("git")
.current_dir(repo_root)
.args(["show", "--numstat", "--pretty=format:", sha])
.output()
.context("Failed to run git")?;
if !out.status.success() {
return Err(anyhow!(
"git show failed for {}: {}",
sha,
String::from_utf8_lossy(&out.stderr)
));
}
Ok(parse_numstat(&String::from_utf8_lossy(&out.stdout)))
}
pub(super) fn parse_numstat(stdout: &str) -> (Vec<String>, i64, i64) {
let mut files = Vec::new();
let mut total_added = 0i64;
let mut total_deleted = 0i64;
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.splitn(3, '\t');
let added = parts.next().unwrap_or("-");
let deleted = parts.next().unwrap_or("-");
let path = match parts.next() {
Some(p) if !p.trim().is_empty() => p.trim().to_string(),
_ => continue,
};
total_added += added.parse::<i64>().unwrap_or(0);
total_deleted += deleted.parse::<i64>().unwrap_or(0);
files.push(path);
}
(files, total_added, total_deleted)
}