devscout_rs/query/imported.rs
1// Imported-edge reach for `impact`: given the native walk's already-computed
2// result and the validated artifact `import-edges` wrote, names every file
3// reached only through a foreign edge. This is a lookup over `ImpactModel`,
4// not a widening of `impact_walk` itself -- an imported edge reaches a file
5// and stops there, the same rule a heuristic edge already follows, so it
6// never feeds `add_adj` and never enters the next frontier. Kept out of
7// `impact.rs`, which has no room left under its own size ceiling.
8
9use std::collections::{HashMap, HashSet};
10
11use crate::graph;
12
13use super::impact::ImpactModel;
14use super::why::Why;
15
16/// One row of imported-edge reach: a file this graph never resolved an edge
17/// to, named only because a foreign, producer-asserted record points at (or
18/// out of) a file the native walk already reached.
19#[derive(Debug, Clone, PartialEq)]
20pub struct ImportedRow {
21 /// The foreign file this row names.
22 pub file: String,
23 /// The foreign repo id this file belongs to.
24 pub repo: String,
25 /// One more than the local file's own hop -- the same "one hop of
26 /// distance" `impact`'s native hops already mean.
27 pub hop: u32,
28 /// The producer's own kind for the record that reached this row
29 /// (`calls`, `publishes`, `consumes`, or `enqueues`).
30 pub imported_kind: String,
31 /// Always [`Why::ImportedEdge`]; carried on the row so every row this
32 /// module builds already carries the field its consumers read.
33 pub why: Why,
34}
35
36/// The imported half of an `impact` answer: capped and counted apart from
37/// the native rows, so a large import can never evict one of them.
38#[derive(Debug, Clone, PartialEq, Default)]
39pub struct ImportedSection {
40 /// The rows actually shown, ranked hop then repo then file.
41 pub rows: Vec<ImportedRow>,
42 /// How many more distinct foreign files the import named beyond `cap`.
43 pub dropped: usize,
44 /// Every distinct foreign file the import named, capped or not.
45 pub affected: usize,
46}
47
48// Every file the native walk already reached, keyed to its own hop -- a seed
49// file counts as hop 0, so a foreign edge landing on it reports at hop 1, the
50// same "one more hop out" rule a file the walk reached at hop N gives a
51// foreign edge landing there (hop N + 1).
52fn local_hops(model: &ImpactModel) -> HashMap<&str, u32> {
53 let mut hops = HashMap::new();
54 for f in &model.seed_files {
55 hops.entry(f.as_str()).or_insert(0);
56 }
57 for r in &model.rows {
58 hops.entry(r.file.as_str()).or_insert(r.hop);
59 }
60 hops
61}
62
63fn is_mapped(end: &graph::EdgeEnd, mapped_repo: &str) -> bool {
64 end.repo.as_deref() == Some(mapped_repo)
65}
66
67// Direct reach: a record whose `to` end resolves into the mapped repo at a
68// file the walk already reached makes the record's own foreign `from` file a
69// row -- the producer already joined the two sites, so no further widening
70// through it is needed or done.
71fn direct_rows(
72 edges: &graph::ImportedEdges,
73 hops: &HashMap<&str, u32>,
74 seen: &mut HashSet<(String, String)>,
75 out: &mut Vec<ImportedRow>,
76) {
77 for rec in &edges.edges {
78 if is_mapped(&rec.from, &edges.mapped_repo) || !is_mapped(&rec.to, &edges.mapped_repo) {
79 continue;
80 }
81 let Some(to_file) = rec.to.file.as_deref() else {
82 continue;
83 };
84 let Some(&hop) = hops.get(to_file) else {
85 continue;
86 };
87 let (Some(from_repo), Some(from_file)) =
88 (rec.from.repo.as_deref(), rec.from.file.as_deref())
89 else {
90 continue;
91 };
92 if seen.insert((from_repo.to_string(), from_file.to_string())) {
93 out.push(ImportedRow {
94 file: from_file.to_string(),
95 repo: from_repo.to_string(),
96 hop: hop + 1,
97 imported_kind: rec.kind.clone(),
98 why: Why::ImportedEdge,
99 });
100 }
101 }
102}
103
104// Composed reach: a `publishes` record out of the mapped repo, at a file the
105// walk already reached, joined through its message `key` to a `consumes`/
106// `enqueues` record whose `to` end is foreign, makes that foreign consumer a
107// row. Costs one hop, not two -- the message node is not a file -- and never
108// reports the arriving `publishes` record's own site as its own consumer.
109fn composed_rows(
110 edges: &graph::ImportedEdges,
111 hops: &HashMap<&str, u32>,
112 seen: &mut HashSet<(String, String)>,
113 out: &mut Vec<ImportedRow>,
114) {
115 for pub_rec in &edges.edges {
116 if pub_rec.kind != "publishes" || !is_mapped(&pub_rec.from, &edges.mapped_repo) {
117 continue;
118 }
119 let Some(pub_file) = pub_rec.from.file.as_deref() else {
120 continue;
121 };
122 let Some(&hop) = hops.get(pub_file) else {
123 continue;
124 };
125 let Some(msg_key) = pub_rec.key.as_deref() else {
126 continue;
127 };
128 for con_rec in &edges.edges {
129 if con_rec.kind != "consumes" && con_rec.kind != "enqueues" {
130 continue;
131 }
132 if con_rec.key.as_deref() != Some(msg_key) {
133 continue;
134 }
135 if is_mapped(&con_rec.to, &edges.mapped_repo) {
136 continue;
137 }
138 if con_rec.to.file.as_deref() == Some(pub_file) && con_rec.to.line == pub_rec.from.line
139 {
140 continue;
141 }
142 let (Some(con_repo), Some(con_file)) =
143 (con_rec.to.repo.as_deref(), con_rec.to.file.as_deref())
144 else {
145 continue;
146 };
147 if seen.insert((con_repo.to_string(), con_file.to_string())) {
148 out.push(ImportedRow {
149 file: con_file.to_string(),
150 repo: con_repo.to_string(),
151 hop: hop + 1,
152 imported_kind: con_rec.kind.clone(),
153 why: Why::ImportedEdge,
154 });
155 }
156 }
157 }
158}
159
160/// Builds the imported section of an `impact` answer.
161///
162/// Reads the native model's already-finished result and the validated
163/// artifact. `cap` bounds `rows` the same way
164/// [`super::refs_tables::cap_rows`] bounds the native ones, but
165/// independently -- this count never competes with the native cap.
166pub fn build_imported_section(
167 model: &ImpactModel,
168 edges: &graph::ImportedEdges,
169 cap: usize,
170) -> ImportedSection {
171 let hops = local_hops(model);
172 let mut seen: HashSet<(String, String)> = HashSet::new();
173 let mut rows = Vec::new();
174 direct_rows(edges, &hops, &mut seen, &mut rows);
175 composed_rows(edges, &hops, &mut seen, &mut rows);
176
177 rows.sort_by(|a, b| {
178 a.hop
179 .cmp(&b.hop)
180 .then_with(|| a.repo.cmp(&b.repo))
181 .then_with(|| a.file.cmp(&b.file))
182 });
183
184 let affected = rows.len();
185 let dropped = affected.saturating_sub(cap);
186 rows.truncate(cap);
187 ImportedSection {
188 rows,
189 dropped,
190 affected,
191 }
192}