1use crate::db::GraphDb;
16use crate::repograph::facts::{
17 commit_fact, evidence_line, list_prop, neighbors, str_prop, CommitFact,
18};
19use crate::repograph::owners::SHA_LEN;
20use crate::repograph::path::{shortest_path, MAX_HOPS, PATH_EDGES};
21use crate::repograph::render::{sanitize, ymd};
22use crate::Direction;
23use core_storage::fs::Fs;
24use serde::Serialize;
25use std::collections::BTreeSet;
26
27const MAX_EVIDENCE: usize = 3;
30
31#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct WhyReport {
34 pub a: String,
35 pub b: String,
36 pub links: Vec<WhyLink>,
38 pub path: Vec<(String, String)>,
41 pub unknown: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize)]
47pub struct WhyLink {
48 pub rule: String,
50 pub edge_type: String,
51 pub direction: String,
53 pub score: Option<f64>,
55 pub via: Option<String>,
58 pub evidence: Vec<String>,
60}
61
62#[must_use]
68pub fn why<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> WhyReport {
69 let mut report = WhyReport {
70 a: sanitize(a),
71 b: sanitize(b),
72 links: Vec::new(),
73 path: Vec::new(),
74 unknown: Vec::new(),
75 };
76 for key in [a, b] {
77 if !db.has_node(key) {
78 report.unknown.push(sanitize(key));
79 }
80 }
81 report.unknown.sort();
82 report.unknown.dedup();
83 if !report.unknown.is_empty() {
84 return report;
85 }
86
87 for e in db.explain(a, b).unwrap_or_default() {
88 let forward = e.src_key == a;
89 report.links.push(WhyLink {
90 evidence: evidence(db, &e.edge_type, &e.src_key, &e.dst_key),
91 rule: sanitize(&e.rule),
92 edge_type: sanitize(&e.edge_type),
93 direction: if forward { "a→b" } else { "b→a" }.to_string(),
94 score: e.weight,
95 via: e.via_edge.as_deref().map(sanitize),
96 });
97 }
98 report.links.sort_by(|x, y| {
102 x.direction
103 .cmp(&y.direction)
104 .then(x.edge_type.cmp(&y.edge_type))
105 .then(x.rule.cmp(&y.rule))
106 .then(
107 y.score
108 .partial_cmp(&x.score)
109 .unwrap_or(std::cmp::Ordering::Equal),
110 )
111 .then(x.evidence.cmp(&y.evidence))
112 });
113 if report.links.is_empty() {
114 report.path = shortest_path(db, a, b, &PATH_EDGES, MAX_HOPS);
115 }
116 report
117}
118
119fn evidence<F: Fs>(db: &GraphDb<F>, edge_type: &str, src: &str, dst: &str) -> Vec<String> {
125 match edge_type {
126 "CO_CHANGED" => shared_commits(db, src, dst),
127 "IMPORTS" => match evidence_line(&list_prop(db, src, "import_lines"), dst) {
128 Some(line) => vec![sanitize(&format!("{src} line {line}: import {dst}"))],
129 None => vec![sanitize(&format!("{src} imports {dst}"))],
130 },
131 "CALLS" => match evidence_line(&list_prop(db, src, "call_lines"), dst) {
132 Some(line) => vec![sanitize(&format!("{src} line {line}: call {dst}"))],
133 None => vec![sanitize(&format!("{src} calls {dst}"))],
134 },
135 "KNOWS" => via_files(db, src, dst),
136 "MENTIONS" => vec![mention(db, src, dst)],
137 _ => Vec::new(),
138 }
139}
140
141fn shared_commits<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> Vec<String> {
143 let theirs: BTreeSet<String> = list_prop(db, b, "commits").into_iter().collect();
144 let mut shared: Vec<CommitFact> = list_prop(db, a, "commits")
145 .into_iter()
146 .filter(|sha| theirs.contains(sha))
147 .filter_map(|sha| commit_fact(db, &sha))
148 .collect();
149 shared.sort_by(|x, y| y.ts.cmp(&x.ts).then(x.sha.cmp(&y.sha)));
150 shared.dedup_by(|x, y| x.sha == y.sha);
151 shared
152 .into_iter()
153 .take(MAX_EVIDENCE)
154 .map(|c| {
155 let short: String = c.sha.chars().take(SHA_LEN).collect();
156 sanitize(&format!("{short} {} {}", ymd(c.ts), c.subject))
157 })
158 .collect()
159}
160
161fn via_files<F: Fs>(db: &GraphDb<F>, author: &str, file: &str) -> Vec<String> {
164 let theirs: BTreeSet<String> = list_prop(db, file, "commits").into_iter().collect();
165 let mut scored: Vec<(usize, String)> = neighbors(db, author, "TOP_AUTHOR", Direction::In)
166 .into_iter()
167 .filter(|owned| owned != file)
168 .map(|owned| {
169 let shared = list_prop(db, &owned, "commits")
170 .into_iter()
171 .filter(|sha| theirs.contains(sha))
172 .count();
173 (shared, owned)
174 })
175 .filter(|(shared, _)| *shared > 0)
176 .collect();
177 scored.sort_by(|x, y| y.0.cmp(&x.0).then(x.1.cmp(&y.1)));
180 scored
181 .into_iter()
182 .take(MAX_EVIDENCE)
183 .map(|(shared, owned)| {
184 sanitize(&format!(
185 "via {owned} ({shared} shared commit{})",
186 if shared == 1 { "" } else { "s" }
187 ))
188 })
189 .collect()
190}
191
192fn mention<F: Fs>(db: &GraphDb<F>, doc: &str, file: &str) -> String {
199 let headings = list_prop(db, doc, "headings");
200 let nearest = str_prop(db, doc, "body").and_then(|body| {
201 let lines: Vec<&str> = body.lines().collect();
202 let at = lines.iter().position(|l| l.contains(file))?;
203 lines[..=at]
204 .iter()
205 .rev()
206 .find_map(|l| heading_text(l).map(str::to_string))
207 });
208 match nearest.or_else(|| headings.first().cloned()) {
209 Some(heading) => sanitize(&format!("{doc} mentions {file} under \"{heading}\"")),
210 None => sanitize(&format!("{doc} mentions {file}")),
211 }
212}
213
214fn heading_text(line: &str) -> Option<&str> {
216 let trimmed = line.trim_start();
217 let hashes = trimmed.chars().take_while(|c| *c == '#').count();
218 if hashes == 0 || hashes > 6 {
219 return None;
220 }
221 let rest = trimmed.get(hashes..)?;
222 if !rest.starts_with(char::is_whitespace) {
223 return None;
224 }
225 let title = rest.trim().trim_end_matches('#').trim();
226 (!title.is_empty()).then_some(title)
227}
228
229#[cfg(test)]
230mod tests {
231 use super::heading_text;
232
233 #[test]
234 fn a_heading_is_hashes_a_space_and_a_title() {
235 assert_eq!(heading_text("## Rules"), Some("Rules"));
236 assert_eq!(heading_text(" # Top "), Some("Top"));
237 assert_eq!(heading_text("### Closed ###"), Some("Closed"));
238 assert_eq!(heading_text("#no-space"), None);
239 assert_eq!(heading_text("####### too deep"), None);
240 assert_eq!(heading_text("plain text"), None);
241 assert_eq!(heading_text("#"), None);
242 }
243}