use crate::db::GraphDb;
use crate::repograph::facts::{
commit_fact, evidence_line, evidence_lines, list_prop, neighbors, str_prop, CommitFact,
};
use crate::repograph::impact::MIN_SHARED_COMMITS;
use crate::repograph::owners::SHA_LEN;
use crate::repograph::path::{shortest_path, MAX_HOPS, PATH_EDGES};
use crate::repograph::render::{sanitize, ymd};
use crate::Direction;
use core_storage::fs::Fs;
use serde::Serialize;
use std::collections::BTreeSet;
const MAX_EVIDENCE: usize = 3;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct WhyReport {
pub a: String,
pub b: String,
pub links: Vec<WhyLink>,
pub path: Vec<(String, String)>,
pub shared: Option<SharedCommits>,
pub unknown: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SharedCommits {
pub count: usize,
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct WhyLink {
pub rule: String,
pub edge_type: String,
pub direction: String,
pub score: Option<f64>,
pub via: Option<String>,
pub evidence: Vec<String>,
}
#[must_use]
pub fn why<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> WhyReport {
let mut report = WhyReport {
a: sanitize(a),
b: sanitize(b),
links: Vec::new(),
path: Vec::new(),
shared: None,
unknown: Vec::new(),
};
for key in [a, b] {
if !db.has_node(key) {
report.unknown.push(sanitize(key));
}
}
report.unknown.sort();
report.unknown.dedup();
if !report.unknown.is_empty() {
return report;
}
for e in db.explain(a, b).unwrap_or_default() {
let forward = e.src_key == a;
report.links.push(WhyLink {
evidence: evidence(db, &e.edge_type, &e.src_key, &e.dst_key),
rule: sanitize(&e.rule),
edge_type: sanitize(&e.edge_type),
direction: if forward { "a→b" } else { "b→a" }.to_string(),
score: e.weight,
via: e.via_edge.as_deref().map(sanitize),
});
}
report.links.sort_by(|x, y| {
x.direction
.cmp(&y.direction)
.then(x.edge_type.cmp(&y.edge_type))
.then(x.rule.cmp(&y.rule))
.then(
y.score
.partial_cmp(&x.score)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(x.evidence.cmp(&y.evidence))
});
if !report.links.iter().any(|l| l.edge_type == "CO_CHANGED") {
report.shared = shared_commit_count(db, a, b);
}
if report.links.is_empty() && report.shared.is_none() {
report.path = shortest_path(db, a, b, &PATH_EDGES, MAX_HOPS);
}
report
}
fn shared_commit_count<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> Option<SharedCommits> {
let theirs: BTreeSet<String> = list_prop(db, b, "commits").into_iter().collect();
let count = list_prop(db, a, "commits")
.into_iter()
.collect::<BTreeSet<String>>()
.intersection(&theirs)
.count();
(count >= MIN_SHARED_COMMITS).then(|| SharedCommits {
count,
evidence: shared_commits(db, a, b),
})
}
fn evidence<F: Fs>(db: &GraphDb<F>, edge_type: &str, src: &str, dst: &str) -> Vec<String> {
match edge_type {
"CO_CHANGED" => shared_commits(db, src, dst),
"IMPORTS" => match evidence_line(&list_prop(db, src, "import_lines"), dst) {
Some(line) => vec![sanitize(&format!("{src} line {line}: import {dst}"))],
None => vec![sanitize(&format!("{src} imports {dst}"))],
},
"CALLS" => match evidence_lines(&list_prop(db, src, "call_lines"), dst) {
lines if lines.is_empty() => vec![sanitize(&format!("{src} calls {dst}"))],
lines => {
let shown: Vec<String> = lines.iter().map(u32::to_string).collect();
vec![sanitize(&format!(
"{src} calls {dst} at {} {}",
if lines.len() == 1 { "line" } else { "lines" },
shown.join(", ")
))]
}
},
"KNOWS" => via_files(db, src, dst),
"MENTIONS" => vec![mention(db, src, dst)],
_ => Vec::new(),
}
}
fn shared_commits<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> Vec<String> {
let theirs: BTreeSet<String> = list_prop(db, b, "commits").into_iter().collect();
let mut shared: Vec<CommitFact> = list_prop(db, a, "commits")
.into_iter()
.filter(|sha| theirs.contains(sha))
.filter_map(|sha| commit_fact(db, &sha))
.collect();
shared.sort_by(|x, y| y.ts.cmp(&x.ts).then(x.sha.cmp(&y.sha)));
shared.dedup_by(|x, y| x.sha == y.sha);
shared
.into_iter()
.take(MAX_EVIDENCE)
.map(|c| {
let short: String = c.sha.chars().take(SHA_LEN).collect();
sanitize(&format!("{short} {} {}", ymd(c.ts), c.subject))
})
.collect()
}
fn via_files<F: Fs>(db: &GraphDb<F>, author: &str, file: &str) -> Vec<String> {
let theirs: BTreeSet<String> = list_prop(db, file, "commits").into_iter().collect();
let mut scored: Vec<(usize, String)> = neighbors(db, author, "TOP_AUTHOR", Direction::In)
.into_iter()
.filter(|owned| owned != file)
.map(|owned| {
let shared = list_prop(db, &owned, "commits")
.into_iter()
.filter(|sha| theirs.contains(sha))
.count();
(shared, owned)
})
.filter(|(shared, _)| *shared > 0)
.collect();
scored.sort_by(|x, y| y.0.cmp(&x.0).then(x.1.cmp(&y.1)));
scored
.into_iter()
.take(MAX_EVIDENCE)
.map(|(shared, owned)| {
sanitize(&format!(
"via {owned} ({shared} shared commit{})",
if shared == 1 { "" } else { "s" }
))
})
.collect()
}
fn mention<F: Fs>(db: &GraphDb<F>, doc: &str, file: &str) -> String {
let headings = list_prop(db, doc, "headings");
let nearest = str_prop(db, doc, "body").and_then(|body| {
let lines: Vec<&str> = body.lines().collect();
let at = lines.iter().position(|l| l.contains(file))?;
lines[..=at]
.iter()
.rev()
.find_map(|l| heading_text(l).map(str::to_string))
});
match nearest.or_else(|| headings.first().cloned()) {
Some(heading) => sanitize(&format!("{doc} mentions {file} under \"{heading}\"")),
None => sanitize(&format!("{doc} mentions {file}")),
}
}
fn heading_text(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
if hashes == 0 || hashes > 6 {
return None;
}
let rest = trimmed.get(hashes..)?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
let title = rest.trim().trim_end_matches('#').trim();
(!title.is_empty()).then_some(title)
}
#[cfg(test)]
mod tests {
use super::heading_text;
#[test]
fn a_heading_is_hashes_a_space_and_a_title() {
assert_eq!(heading_text("## Rules"), Some("Rules"));
assert_eq!(heading_text(" # Top "), Some("Top"));
assert_eq!(heading_text("### Closed ###"), Some("Closed"));
assert_eq!(heading_text("#no-space"), None);
assert_eq!(heading_text("####### too deep"), None);
assert_eq!(heading_text("plain text"), None);
assert_eq!(heading_text("#"), None);
}
}