1use crate::db::GraphDb;
19use crate::repograph::facts::{
20 commit_fact, evidence_line, evidence_lines, list_prop, neighbors, str_prop, CommitFact,
21};
22use crate::repograph::impact::MIN_SHARED_COMMITS;
23use crate::repograph::owners::SHA_LEN;
24use crate::repograph::path::{shortest_path, MAX_HOPS, PATH_EDGES};
25use crate::repograph::render::{sanitize, ymd};
26use crate::Direction;
27use core_storage::fs::Fs;
28use serde::Serialize;
29use std::collections::BTreeSet;
30
31const MAX_EVIDENCE: usize = 3;
34
35#[derive(Debug, Clone, PartialEq, Serialize)]
37pub struct WhyReport {
38 pub a: String,
39 pub b: String,
40 pub links: Vec<WhyLink>,
42 pub path: Vec<(String, String)>,
46 pub shared: Option<SharedCommits>,
55 pub unknown: Vec<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct SharedCommits {
62 pub count: usize,
64 pub evidence: Vec<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize)]
70pub struct WhyLink {
71 pub rule: String,
73 pub edge_type: String,
74 pub direction: String,
76 pub score: Option<f64>,
78 pub via: Option<String>,
81 pub evidence: Vec<String>,
83}
84
85#[must_use]
91pub fn why<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> WhyReport {
92 let mut report = WhyReport {
93 a: sanitize(a),
94 b: sanitize(b),
95 links: Vec::new(),
96 path: Vec::new(),
97 shared: None,
98 unknown: Vec::new(),
99 };
100 for key in [a, b] {
101 if !db.has_node(key) {
102 report.unknown.push(sanitize(key));
103 }
104 }
105 report.unknown.sort();
106 report.unknown.dedup();
107 if !report.unknown.is_empty() {
108 return report;
109 }
110
111 for e in db.explain(a, b).unwrap_or_default() {
112 let forward = e.src_key == a;
113 report.links.push(WhyLink {
114 evidence: evidence(db, &e.edge_type, &e.src_key, &e.dst_key),
115 rule: sanitize(&e.rule),
116 edge_type: sanitize(&e.edge_type),
117 direction: if forward { "a→b" } else { "b→a" }.to_string(),
118 score: e.weight,
119 via: e.via_edge.as_deref().map(sanitize),
120 });
121 }
122 report.links.sort_by(|x, y| {
126 x.direction
127 .cmp(&y.direction)
128 .then(x.edge_type.cmp(&y.edge_type))
129 .then(x.rule.cmp(&y.rule))
130 .then(
131 y.score
132 .partial_cmp(&x.score)
133 .unwrap_or(std::cmp::Ordering::Equal),
134 )
135 .then(x.evidence.cmp(&y.evidence))
136 });
137 if !report.links.iter().any(|l| l.edge_type == "CO_CHANGED") {
144 report.shared = shared_commit_count(db, a, b);
145 }
146 if report.links.is_empty() && report.shared.is_none() {
147 report.path = shortest_path(db, a, b, &PATH_EDGES, MAX_HOPS);
148 }
149 report
150}
151
152fn shared_commit_count<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> Option<SharedCommits> {
157 let theirs: BTreeSet<String> = list_prop(db, b, "commits").into_iter().collect();
158 let count = list_prop(db, a, "commits")
159 .into_iter()
160 .collect::<BTreeSet<String>>()
161 .intersection(&theirs)
162 .count();
163 (count >= MIN_SHARED_COMMITS).then(|| SharedCommits {
164 count,
165 evidence: shared_commits(db, a, b),
166 })
167}
168
169fn evidence<F: Fs>(db: &GraphDb<F>, edge_type: &str, src: &str, dst: &str) -> Vec<String> {
175 match edge_type {
176 "CO_CHANGED" => shared_commits(db, src, dst),
177 "IMPORTS" => match evidence_line(&list_prop(db, src, "import_lines"), dst) {
178 Some(line) => vec![sanitize(&format!("{src} line {line}: import {dst}"))],
179 None => vec![sanitize(&format!("{src} imports {dst}"))],
180 },
181 "CALLS" => match evidence_lines(&list_prop(db, src, "call_lines"), dst) {
184 lines if lines.is_empty() => vec![sanitize(&format!("{src} calls {dst}"))],
185 lines => {
186 let shown: Vec<String> = lines.iter().map(u32::to_string).collect();
187 vec![sanitize(&format!(
188 "{src} calls {dst} at {} {}",
189 if lines.len() == 1 { "line" } else { "lines" },
190 shown.join(", ")
191 ))]
192 }
193 },
194 "KNOWS" => via_files(db, src, dst),
195 "MENTIONS" => vec![mention(db, src, dst)],
196 _ => Vec::new(),
197 }
198}
199
200fn shared_commits<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> Vec<String> {
202 let theirs: BTreeSet<String> = list_prop(db, b, "commits").into_iter().collect();
203 let mut shared: Vec<CommitFact> = list_prop(db, a, "commits")
204 .into_iter()
205 .filter(|sha| theirs.contains(sha))
206 .filter_map(|sha| commit_fact(db, &sha))
207 .collect();
208 shared.sort_by(|x, y| y.ts.cmp(&x.ts).then(x.sha.cmp(&y.sha)));
209 shared.dedup_by(|x, y| x.sha == y.sha);
210 shared
211 .into_iter()
212 .take(MAX_EVIDENCE)
213 .map(|c| {
214 let short: String = c.sha.chars().take(SHA_LEN).collect();
215 sanitize(&format!("{short} {} {}", ymd(c.ts), c.subject))
216 })
217 .collect()
218}
219
220fn via_files<F: Fs>(db: &GraphDb<F>, author: &str, file: &str) -> Vec<String> {
223 let theirs: BTreeSet<String> = list_prop(db, file, "commits").into_iter().collect();
224 let mut scored: Vec<(usize, String)> = neighbors(db, author, "TOP_AUTHOR", Direction::In)
225 .into_iter()
226 .filter(|owned| owned != file)
227 .map(|owned| {
228 let shared = list_prop(db, &owned, "commits")
229 .into_iter()
230 .filter(|sha| theirs.contains(sha))
231 .count();
232 (shared, owned)
233 })
234 .filter(|(shared, _)| *shared > 0)
235 .collect();
236 scored.sort_by(|x, y| y.0.cmp(&x.0).then(x.1.cmp(&y.1)));
239 scored
240 .into_iter()
241 .take(MAX_EVIDENCE)
242 .map(|(shared, owned)| {
243 sanitize(&format!(
244 "via {owned} ({shared} shared commit{})",
245 if shared == 1 { "" } else { "s" }
246 ))
247 })
248 .collect()
249}
250
251fn mention<F: Fs>(db: &GraphDb<F>, doc: &str, file: &str) -> String {
258 let headings = list_prop(db, doc, "headings");
259 let nearest = str_prop(db, doc, "body").and_then(|body| {
260 let lines: Vec<&str> = body.lines().collect();
261 let at = lines.iter().position(|l| l.contains(file))?;
262 lines[..=at]
263 .iter()
264 .rev()
265 .find_map(|l| heading_text(l).map(str::to_string))
266 });
267 match nearest.or_else(|| headings.first().cloned()) {
268 Some(heading) => sanitize(&format!("{doc} mentions {file} under \"{heading}\"")),
269 None => sanitize(&format!("{doc} mentions {file}")),
270 }
271}
272
273fn heading_text(line: &str) -> Option<&str> {
275 let trimmed = line.trim_start();
276 let hashes = trimmed.chars().take_while(|c| *c == '#').count();
277 if hashes == 0 || hashes > 6 {
278 return None;
279 }
280 let rest = trimmed.get(hashes..)?;
281 if !rest.starts_with(char::is_whitespace) {
282 return None;
283 }
284 let title = rest.trim().trim_end_matches('#').trim();
285 (!title.is_empty()).then_some(title)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::heading_text;
291
292 #[test]
293 fn a_heading_is_hashes_a_space_and_a_title() {
294 assert_eq!(heading_text("## Rules"), Some("Rules"));
295 assert_eq!(heading_text(" # Top "), Some("Top"));
296 assert_eq!(heading_text("### Closed ###"), Some("Closed"));
297 assert_eq!(heading_text("#no-space"), None);
298 assert_eq!(heading_text("####### too deep"), None);
299 assert_eq!(heading_text("plain text"), None);
300 assert_eq!(heading_text("#"), None);
301 }
302}