devscout_rs/query/coverage.rs
1use std::collections::HashMap;
2
3use crate::graph;
4
5use super::bus::{self, BusDirection, BusHopRow};
6use super::index::{def_files, symbol_refs, GraphIndex};
7use super::member::{self, MemberCandidate, MemberSeedResolution};
8use super::refs_tables::{cap_rows, edge_loc, row_tier, Table, DEFAULT_CAP};
9use super::symbol::{resolve_symbol, Resolution};
10
11// ============================================================================
12// build_tests_model -- test coverage.
13// ============================================================================
14
15/// Which vouch earned a row in a tests model: an attribute-carrying def
16/// declared in the file (`Attribute`), or, absent that, the project model
17/// placing the file's unit inside a project marked `test` (`Project`).
18///
19/// The two are checked in that order -- a file's own attributed def is always
20/// the more specific vouch, so `Attribute` wins whenever both would apply.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum TestVia {
23 /// A def in the file carries a test-runner attribute.
24 Attribute,
25 /// No such def; the file's unit in the project model is a test project.
26 Project,
27}
28
29/// One row of a tests model: a test `file`, the test-carrying `test_defs` in
30/// it (possibly empty for a `Project`-vouched row -- a harness file in a test
31/// project need not declare any attributed method itself), the `lines` at
32/// which it references the symbol, the `ref_count`, whether the reference was
33/// guessed (`heuristic`), and which vouch (`via`) put the file in this model
34/// at all.
35#[derive(Debug, Clone, PartialEq)]
36pub struct TestRow {
37 /// The file value.
38 pub file: String,
39 /// The test defs value.
40 pub test_defs: Vec<String>,
41 /// The lines value.
42 pub lines: Vec<usize>,
43 /// The ref count value.
44 pub ref_count: usize,
45 /// The heuristic value.
46 pub heuristic: bool,
47 /// Which heuristic tier stands behind this FILE's references. A row folds
48 /// every reference the file makes, so one extension edge among them names
49 /// the whole row -- same rule as [`InboundRow::tier`], applied to a set.
50 pub tier: Option<graph::HeuristicTier>,
51 /// Which vouch (attribute or project) put this file in the model.
52 pub via: TestVia,
53}
54
55/// The resolved `tests` result for one symbol.
56#[derive(Debug, Clone, PartialEq)]
57pub struct TestsModel {
58 /// The query value.
59 pub query: String,
60 /// The symbol value.
61 pub symbol: String,
62 /// The def files value.
63 pub def_files: Vec<String>,
64 /// Precise rows first, heuristic rows after -- the same discipline every
65 /// other consumer keeps, so a guess never sits inside the list of facts.
66 pub rows: Vec<TestRow>,
67 /// The test file count value.
68 pub test_file_count: usize,
69 /// The ref count value.
70 pub ref_count: usize,
71 /// The heuristic file count value.
72 pub heuristic_file_count: usize,
73 /// The heuristic ref count value.
74 pub heuristic_ref_count: usize,
75 /// Test files that PUBLISH a message this symbol (a handler) receives,
76 /// over a `bus-hop` -- a possible route, runtime routing unverified,
77 /// never counted in `test_file_count`/`ref_count` or their heuristic
78 /// twins: a possible route must never inflate precise coverage.
79 /// Structurally separate from `rows` by construction -- a different
80 /// field entirely, not a flag on an existing row -- so nothing has to
81 /// remember to exclude it. `--json` omits the key entirely when empty.
82 pub bus: Table<BusHopRow>,
83}
84
85/// The outcome of a `tests` query: resolved, ambiguous, or not found.
86#[derive(Debug, Clone, PartialEq)]
87pub enum TestsResult {
88 /// Represents `Resolved`.
89 Resolved(TestsModel),
90 /// Represents `Ambiguous`.
91 Ambiguous(Vec<String>),
92 /// The seed named a member declared by more than one type -- `tests`
93 /// answers as the member's unique declaring type, so this can only ever
94 /// arise from a member seed.
95 MemberAmbiguous(Vec<MemberCandidate>),
96 /// Represents `NotFound`.
97 NotFound,
98}
99
100// The inbound kinds of one adjacency, walked in `REF_KINDS` order (plus
101// `implements`/`overrides`, which never carry a heuristic tier).
102fn collect_test_rows(index: &GraphIndex, kinds: [&[usize]; 5], heuristic: bool) -> Vec<TestRow> {
103 let mut rows: Vec<TestRow> = Vec::new();
104 let mut by_file: HashMap<String, usize> = HashMap::new();
105 for idxs in kinds {
106 for &i in idxs {
107 let (from_file, from_line) = edge_loc(&index.graph.edges[i]);
108 let test_defs = index.test_defs_by_file.get(from_file);
109 // The attribute vouch wins when it is there. Absent it, the file
110 // still earns a row -- with an empty `test_defs` -- when
111 // `is_test_file` vouches for it through the project model alone;
112 // a file neither vouch reaches is not a test file and is skipped,
113 // same as before this vouch existed.
114 let via = if test_defs.is_some() {
115 TestVia::Attribute
116 } else if index.is_test_file(from_file) {
117 TestVia::Project
118 } else {
119 continue;
120 };
121 let slot = match by_file.get(from_file) {
122 Some(&slot) => slot,
123 None => {
124 let slot = rows.len();
125 rows.push(TestRow {
126 file: from_file.to_string(),
127 test_defs: test_defs
128 .map(|defs| {
129 defs.iter()
130 .map(|&d| index.graph.defs[d].id.clone())
131 .collect()
132 })
133 .unwrap_or_default(),
134 lines: Vec::new(),
135 ref_count: 0,
136 heuristic,
137 tier: row_tier(heuristic, false),
138 via,
139 });
140 by_file.insert(from_file.to_string(), slot);
141 slot
142 }
143 };
144 // A row folds every reference ONE file makes, so its tier can only
145 // ever be raised as the rest of that file's edges arrive: the first
146 // extension edge names the row, and no later name guess takes that
147 // back (see `row_tier`).
148 if index.graph.edges[i].tier() == Some(graph::HeuristicTier::Ext) {
149 rows[slot].tier = row_tier(heuristic, true);
150 }
151 rows[slot].lines.push(from_line);
152 rows[slot].ref_count += 1;
153 }
154 }
155 for row in &mut rows {
156 row.lines.sort_unstable();
157 }
158 rows
159}
160
161/// Which TEST files reference this symbol, at which lines, via which
162/// test-carrying defs. Nothing here is a new kind of edge: it is the same
163/// inbound set `refs` reports, filtered to the files `test_defs_by_file`
164/// vouches for, which is why a symbol nothing tests answers "none" rather than
165/// falling back to a name convention.
166///
167/// Row order is FIRST-SEEN edge order, kind by kind (not sorted), so the file a
168/// consumer reads first is the one the graph reached first; the edge array
169/// walked is itself built in a pinned order.
170pub fn build_tests_model(index: &GraphIndex, query: &str) -> TestsResult {
171 let id = match resolve_symbol(index, query) {
172 Resolution::Resolved(id) => id,
173 Resolution::Ambiguous(ids) => return TestsResult::Ambiguous(ids),
174 // The member path is a FALLBACK, reached only when nothing in the
175 // graph declares `query` as a type -- same order `refs`/`impact`
176 // apply. `tests` has no member-shaped answer of its own: a member
177 // seed answers as its unique declaring type, exactly like `impact`.
178 Resolution::NotFound => match member::resolve_member_seed(index, query) {
179 MemberSeedResolution::Resolved(id) => id,
180 MemberSeedResolution::Ambiguous(candidates) => {
181 return TestsResult::MemberAmbiguous(candidates)
182 }
183 MemberSeedResolution::NotFound => return TestsResult::NotFound,
184 },
185 };
186 let refs = symbol_refs(index, &id);
187
188 let precise = collect_test_rows(
189 index,
190 [
191 &refs.inbound_inherits,
192 &refs.inbound_uses_type,
193 &refs.inbound_uses_member,
194 &refs.inbound_implements,
195 &refs.inbound_overrides,
196 ],
197 false,
198 );
199 const EMPTY: &[usize] = &[];
200 let heuristic = collect_test_rows(
201 index,
202 [
203 &refs.heuristic_inbound_inherits,
204 &refs.heuristic_inbound_uses_type,
205 &refs.heuristic_inbound_uses_member,
206 EMPTY,
207 EMPTY,
208 ],
209 true,
210 );
211
212 let test_file_count = precise.len();
213 let ref_count: usize = precise.iter().map(|r| r.ref_count).sum();
214 let heuristic_file_count = heuristic.len();
215 let heuristic_ref_count: usize = heuristic.iter().map(|r| r.ref_count).sum();
216
217 let mut rows = precise;
218 rows.extend(heuristic);
219
220 TestsResult::Resolved(TestsModel {
221 query: query.to_string(),
222 symbol: id.clone(),
223 def_files: def_files(index, &id),
224 rows,
225 test_file_count,
226 ref_count,
227 heuristic_file_count,
228 heuristic_ref_count,
229 bus: test_bus_rows(index, &id),
230 })
231}
232
233// Every `bus-hop` row reaching this symbol (a handler) FROM a publish site a
234// test file vouches for -- the same `In`-direction half `refs`' own `bus`
235// table shows, filtered to the publish site's own file being a test file by
236// the SAME rule `collect_test_rows` applies to an ordinary reference
237// (`test_defs_by_file`, falling back to `is_test_file`). Reuses
238// `query::bus::symbol_bus_rows` rather than a second bus-hop lookup, so a
239// route the reverse walk already resolved is never re-derived here; the
240// filter runs on the UNCAPPED set (`usize::MAX`) so a large `Out`-direction
241// or non-test share of this symbol's own bus rows can never push a
242// legitimate test-vouched row out before the filter ever sees it, and the
243// cap this table itself reports is applied only after filtering.
244fn test_bus_rows(index: &GraphIndex, id: &str) -> Table<BusHopRow> {
245 let all = bus::symbol_bus_rows(index, id, usize::MAX);
246 let filtered: Vec<BusHopRow> = all
247 .rows
248 .into_iter()
249 .filter(|r| {
250 r.direction == BusDirection::In
251 && (index.test_defs_by_file.contains_key(&r.file) || index.is_test_file(&r.file))
252 })
253 .collect();
254 let total = filtered.len();
255 let (rows, dropped) = cap_rows(filtered, DEFAULT_CAP);
256 Table {
257 total,
258 dropped,
259 rows,
260 }
261}