Skip to main content

core_api/repograph/
why.rs

1//! `why` — what links two things, with the evidence.
2//!
3//! A derived edge is a claim, and a claim an assistant is going to act on
4//! should come with what it was derived from. So every link this reports
5//! carries the rule that wrote it, the score it was written with, and the lines
6//! of the repository that make it true: the commits two files share, the line
7//! an import sits on, the line a call is made from, the file an author knows
8//! both through, the heading a document mentions something under.
9//!
10//! When no rule links the two at all the answer is the shortest walk between
11//! them — see [`shortest_path`](crate::repograph::shortest_path) — and when
12//! there is not even one of those, `why` says so rather than implying a
13//! connection nobody can name.
14
15use 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
27/// Lines of evidence kept per link. Three commits say "these two move
28/// together"; thirty say it no better and crowd out the next link.
29const MAX_EVIDENCE: usize = 3;
30
31/// How `a` and `b` are linked, if they are.
32#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct WhyReport {
34    pub a: String,
35    pub b: String,
36    /// Every rule-written edge between them, in either direction.
37    pub links: Vec<WhyLink>,
38    /// `(edge type, node reached)` hops, filled only when no rule links them
39    /// directly and a walk of at most [`MAX_HOPS`] edges connects them.
40    pub path: Vec<(String, String)>,
41    /// Whichever of `a` and `b` the store has never heard of.
42    pub unknown: Vec<String>,
43}
44
45/// One rule-written edge, and what makes it true.
46#[derive(Debug, Clone, PartialEq, Serialize)]
47pub struct WhyLink {
48    /// The rule that wrote the edge.
49    pub rule: String,
50    pub edge_type: String,
51    /// `a→b` or `b→a`, in terms of the keys the caller asked about.
52    pub direction: String,
53    /// The score the rule matched at. `None` for a rule that records none.
54    pub score: Option<f64>,
55    /// For a via-hop rule, the edge type the rule hopped over to find its
56    /// candidates. The evidence names the node it hopped through.
57    pub via: Option<String>,
58    /// The repository facts behind the edge, strongest or newest first.
59    pub evidence: Vec<String>,
60}
61
62/// Why `a` and `b` are linked.
63///
64/// Deterministic: links come back in the engine's `(rule, edge type)` order,
65/// evidence is sorted before it is cut, and the path — when there is one — is
66/// the same shortest walk every time.
67#[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    // The engine returns provenance in its own stable order; sorting on the
99    // fields themselves means the answer depends on what the links *are*
100    // rather than on the order two stores happened to intern their ids in.
101    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
119/// The repository facts behind one edge, in the form its kind is read in.
120///
121/// An edge type nothing is recorded for — an auto-FK edge such as `DEFINES`,
122/// whose evidence is the prop it was derived from and is already in the key —
123/// gets none, and the digest prints the link alone.
124fn 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
141/// The commits both files were touched by, newest first.
142fn 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
161/// The files an author knows `file` through: the ones they own that share
162/// commits with it, which is what the `knows` rule hopped over to find them.
163fn 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    // Most shared commits first, ties on the key: the same order the digest
178    // would rank any other association in.
179    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
192/// Where in a document a file is mentioned: the heading it sits under.
193///
194/// The mention itself carries no line, so the line is found in the document's
195/// stored `body` — the first one naming the file — and the heading is the
196/// nearest one above it. A document whose body was not stored still has its
197/// headings, and the first of those is what it is about.
198fn 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
214/// The text of a Markdown ATX heading, if the line is one.
215fn 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}