1use std::collections::{HashMap, VecDeque};
9
10use crate::graph_index::GraphIndex;
11use crate::retrieval::{Confidence, Hit};
12use crate::schema::{EdgeBasis, Graph, Kind, Node, relation};
13
14fn last_segment(id: &str) -> &str {
15 id.rsplit("::").next().unwrap_or(id)
16}
17
18fn hit_exact(id: &str, score: i64, why: String) -> Hit {
19 Hit {
20 id: id.to_string(),
21 score,
22 lexical_score: score,
23 confidence: Confidence::Exact,
24 why: vec![why],
25 relation_matches: Vec::new(),
26 anchor: 1.0,
27 relation_anchor: false,
28 }
29}
30
31fn is_function_indexed(index: &GraphIndex<'_>, id: &str) -> bool {
32 index.is_kind(id, Kind::Function)
33}
34
35fn symbol_rank(node: &Node) -> (u8, u8, &str) {
36 let test_penalty = u8::from(
37 node.id.contains("::tests::")
38 || node
39 .source_files
40 .iter()
41 .any(|path| path.contains("/tests/") || path.contains("\\tests\\")),
42 );
43 let kind_rank = match node.kind {
44 Kind::Type => 0,
45 Kind::Trait => 1,
46 Kind::Function => 2,
47 Kind::Module => 3,
48 Kind::Skill => 4,
49 Kind::Agent => 5,
50 Kind::Doc => 6,
51 Kind::Section => 7,
52 Kind::Unknown => 8,
53 };
54 (test_penalty, kind_rank, node.id.as_str())
55}
56
57fn best_symbol_candidate<'a>(nodes: impl Iterator<Item = &'a Node>) -> Option<String> {
58 let mut candidates = nodes.collect::<Vec<_>>();
59 candidates.sort_by_key(|node| symbol_rank(node));
60 candidates.first().map(|node| node.id.clone())
61}
62
63pub fn resolve_symbol(graph: &Graph, needle: &str) -> Option<String> {
65 let n = needle.trim().trim_matches('`').to_lowercase();
66 if n.is_empty() {
67 return None;
68 }
69 if let Some(node) = graph.nodes.iter().find(|x| x.id.to_lowercase() == n) {
71 return Some(node.id.clone());
72 }
73 let seg = graph
75 .nodes
76 .iter()
77 .filter(|x| x.id.to_lowercase().rsplit("::").next() == Some(n.as_str()))
78 .collect::<Vec<_>>();
79 if !seg.is_empty() {
80 return best_symbol_candidate(seg.into_iter());
81 }
82 let needle_seg = format!("::{n}");
84 best_symbol_candidate(
85 graph
86 .nodes
87 .iter()
88 .filter(|x| x.id.to_lowercase().contains(&needle_seg) || x.title.to_lowercase() == n),
89 )
90}
91
92pub fn callers(graph: &Graph, node_id: &str) -> Vec<Hit> {
96 let index = GraphIndex::build(graph);
97 let is_trait = index.is_kind(node_id, Kind::Trait);
98 let mut froms: Vec<String> = index
99 .incoming(node_id)
100 .filter(|e| e.basis == EdgeBasis::Resolved)
105 .filter(|e| {
106 e.relation == relation::REFERENCES || (is_trait && e.relation == relation::IMPLEMENTS)
107 })
108 .map(|e| e.from.clone())
109 .collect();
110 froms.sort_unstable();
111 froms.dedup();
112
113 let fn_callers: Vec<&str> = froms
114 .iter()
115 .filter(|id| is_function_indexed(&index, id))
116 .map(std::string::String::as_str)
117 .collect();
118 let kept: Vec<&str> = froms
119 .iter()
120 .filter(|id| {
121 if is_function_indexed(&index, id) {
122 return true;
123 }
124 !fn_callers
126 .iter()
127 .any(|f| index.has_outgoing(id, relation::CONTAINS, f))
128 })
129 .map(std::string::String::as_str)
130 .collect();
131
132 let label = last_segment(node_id).to_string();
133 let mut hits: Vec<Hit> = kept
134 .iter()
135 .map(|id| hit_exact(id, 100, format!("calls/uses {label} (references edge)")))
136 .collect();
137 hits.sort_by(|a, b| {
138 let af = is_function_indexed(&index, &a.id);
139 let bf = is_function_indexed(&index, &b.id);
140 bf.cmp(&af).then_with(|| a.id.cmp(&b.id))
141 });
142 hits
143}
144
145pub fn impact(graph: &Graph, node_id: &str, depth: usize) -> Vec<Hit> {
149 let index = GraphIndex::build(graph);
150 let depth = depth.clamp(1, 8);
151 let mut dist: HashMap<String, usize> = HashMap::new();
152 dist.insert(node_id.to_string(), 0);
153 let mut q: VecDeque<(String, usize)> = VecDeque::new();
154 q.push_back((node_id.to_string(), 0));
155 for e in index.outgoing(node_id) {
156 if e.relation == relation::CONTAINS && !dist.contains_key(&e.to) {
157 dist.insert(e.to.clone(), 1);
158 q.push_back((e.to.clone(), 1));
159 }
160 }
161 while let Some((cur, d)) = q.pop_front() {
162 if d >= depth {
163 continue;
164 }
165 for e in index.incoming(&cur) {
166 if e.basis == EdgeBasis::Resolved
169 && (e.relation == relation::REFERENCES || e.relation == relation::IMPLEMENTS)
170 {
171 let nd = d + 1;
172 if dist.get(&e.from).is_none_or(|&old| nd < old) {
173 dist.insert(e.from.clone(), nd);
174 q.push_back((e.from.clone(), nd));
175 }
176 }
177 }
178 }
179 let label = last_segment(node_id).to_string();
180 let mut items: Vec<(String, usize)> =
181 dist.into_iter().filter(|(id, _)| id != node_id).collect();
182 items.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
183 items
184 .iter()
185 .map(|(id, d)| {
186 hit_exact(
187 id,
188 (100 - *d as i64).max(1),
189 format!("depends on {label} ({d} hop(s) via references/implements)"),
190 )
191 })
192 .collect()
193}
194
195pub fn kind_intent(query: &str) -> Option<Kind> {
199 for word in query.to_lowercase().split(|c: char| !c.is_alphanumeric()) {
200 let k = match word {
201 "struct" | "enum" | "class" | "type" | "record" | "dataclass" | "datatype"
202 | "object" => Kind::Type,
203 "trait" | "interface" | "protocol" => Kind::Trait,
204 "function" | "func" | "fn" | "method" | "def" | "procedure" | "routine" | "handler"
205 | "endpoint" | "route" | "hook" | "component" | "callback" => Kind::Function,
206 "module" | "package" | "namespace" => Kind::Module,
207 _ => continue,
208 };
209 return Some(k);
210 }
211 None
212}
213
214pub fn rerank_by_kind(graph: &Graph, mut hits: Vec<Hit>, target: Kind) -> Vec<Hit> {
217 let matches = |id: &str| graph.nodes.iter().any(|n| n.id == id && n.kind == target);
218 hits.sort_by_key(|h| !matches(&h.id));
219 hits
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum RelMode {
224 Callers,
225 Impact,
226}
227
228pub fn detect_relational_intent(query: &str) -> Option<(RelMode, String)> {
231 let q = query.to_lowercase();
232 const IMPACT: &[&str] = &[
233 "what depends on",
234 "depends on",
235 "what breaks if",
236 "what would break",
237 "impact of changing",
238 "dependents of",
239 ];
240 const CALLERS: &[&str] = &[
241 "what calls",
242 "who calls",
243 "callers of",
244 "what references",
245 "what uses",
246 ];
247 let (mode, pat) = if let Some(p) = IMPACT.iter().find(|p| q.contains(**p)) {
248 (RelMode::Impact, *p)
249 } else if let Some(p) = CALLERS.iter().find(|p| q.contains(**p)) {
250 (RelMode::Callers, *p)
251 } else {
252 return None;
253 };
254 let after = q.split(pat).nth(1).unwrap_or("").to_string();
255 let subject = extract_symbol(&after).or_else(|| extract_symbol(&q));
256 subject.map(|s| (mode, s))
257}
258
259fn extract_symbol(text: &str) -> Option<String> {
260 const STOP: &[&str] = &[
261 "the", "a", "an", "if", "i", "to", "my", "our", "this", "that", "change", "changing",
262 "trait", "enum", "struct", "class", "type", "function", "method", "fn", "module", "it",
263 "would", "break", "of", "on", "in", "is", "are",
264 ];
265 let toks: Vec<&str> = text
266 .split(|c: char| !(c.is_alphanumeric() || c == '_'))
267 .filter(|t| {
268 if t.is_empty() {
269 return false;
270 }
271 let tl = t.to_lowercase();
272 !STOP.contains(&tl.as_str())
273 })
274 .collect();
275 toks.last().map(std::string::ToString::to_string)
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::schema::{Edge, Node};
282
283 fn node(id: &str, kind: Kind) -> Node {
284 Node {
285 id: id.into(),
286 kind,
287 subkind: None,
288 title: last_segment(id).into(),
289 summary: String::new(),
290 aliases: vec![],
291 tags: vec![],
292 query_examples: vec![],
293 source_files: vec![],
294 span: None,
295 partition: None,
296 }
297 }
298 fn edge(from: &str, to: &str, rel: &str) -> Edge {
299 Edge {
302 from: from.into(),
303 to: to.into(),
304 relation: rel.into(),
305 evidence: String::new(),
306 basis: EdgeBasis::Resolved,
307 ..Default::default()
308 }
309 }
310 fn lexical_edge(from: &str, to: &str, rel: &str) -> Edge {
311 Edge {
312 basis: EdgeBasis::Lexical,
313 ..edge(from, to, rel)
314 }
315 }
316 fn fixture() -> Graph {
317 Graph {
318 nodes: vec![
319 node("fn.a::auth::verify_password", Kind::Function),
320 node("fn.a::routes::login", Kind::Function),
321 node("fn.a::service::TaskService::authenticate", Kind::Function),
322 node("type.a::service::TaskService", Kind::Type),
323 node("type.c::scheduler::Scheduler", Kind::Type),
324 node("type.c::scheduler::Task", Kind::Type),
325 node("fn.c::scheduler::Task::id", Kind::Function),
326 node("fn.c::scheduler::tests::Task", Kind::Function),
327 ],
328 edges: vec![
329 edge(
330 "fn.a::routes::login",
331 "fn.a::auth::verify_password",
332 relation::REFERENCES,
333 ),
334 edge(
335 "fn.a::service::TaskService::authenticate",
336 "fn.a::auth::verify_password",
337 relation::REFERENCES,
338 ),
339 edge(
341 "type.a::service::TaskService",
342 "fn.a::auth::verify_password",
343 relation::REFERENCES,
344 ),
345 edge(
346 "type.a::service::TaskService",
347 "fn.a::service::TaskService::authenticate",
348 relation::CONTAINS,
349 ),
350 edge(
351 "type.c::scheduler::Scheduler",
352 "type.c::scheduler::Task",
353 relation::REFERENCES,
354 ),
355 edge(
356 "type.c::scheduler::Task",
357 "fn.c::scheduler::Task::id",
358 relation::CONTAINS,
359 ),
360 ],
361 ..Default::default()
362 }
363 }
364
365 #[test]
366 fn callers_returns_function_callers_not_the_symbol_or_rolled_up_type() {
367 let g = fixture();
368 let ids: Vec<String> = callers(&g, "fn.a::auth::verify_password")
369 .into_iter()
370 .map(|h| h.id)
371 .collect();
372 assert!(ids.contains(&"fn.a::routes::login".to_string()));
373 assert!(ids.contains(&"fn.a::service::TaskService::authenticate".to_string()));
374 assert!(!ids.contains(&"fn.a::auth::verify_password".to_string()));
375 assert!(!ids.contains(&"type.a::service::TaskService".to_string()));
377 }
378
379 #[test]
380 fn impact_and_callers_ignore_lexical_edges() {
381 let mut g = fixture();
385 g.nodes
386 .push(node("fn.z::other::coincidental", Kind::Function));
387 g.edges.push(lexical_edge(
388 "fn.z::other::coincidental",
389 "fn.a::auth::verify_password",
390 relation::REFERENCES,
391 ));
392
393 let callers: Vec<String> = callers(&g, "fn.a::auth::verify_password")
394 .into_iter()
395 .map(|h| h.id)
396 .collect();
397 assert!(
398 !callers.contains(&"fn.z::other::coincidental".to_string()),
399 "a Lexical reference must not appear as a caller: {callers:?}"
400 );
401 assert!(callers.contains(&"fn.a::routes::login".to_string()));
403
404 let impacted: Vec<String> = impact(&g, "fn.a::auth::verify_password", 8)
405 .into_iter()
406 .map(|h| h.id)
407 .collect();
408 assert!(
409 !impacted.contains(&"fn.z::other::coincidental".to_string()),
410 "a Lexical reference must not appear as a dependent: {impacted:?}"
411 );
412 }
413
414 #[test]
415 fn impact_includes_dependents_and_contained_children_not_subject() {
416 let g = fixture();
417 let ids: Vec<String> = impact(&g, "type.c::scheduler::Task", 8)
418 .into_iter()
419 .map(|h| h.id)
420 .collect();
421 assert!(ids.contains(&"type.c::scheduler::Scheduler".to_string()));
422 assert!(ids.contains(&"fn.c::scheduler::Task::id".to_string()));
423 assert!(!ids.contains(&"type.c::scheduler::Task".to_string()));
424 }
425
426 #[test]
427 fn resolve_symbol_finds_by_last_segment() {
428 let g = fixture();
429 assert_eq!(
430 resolve_symbol(&g, "verify_password").as_deref(),
431 Some("fn.a::auth::verify_password")
432 );
433 assert_eq!(
434 resolve_symbol(&g, "Task").as_deref(),
435 Some("type.c::scheduler::Task")
436 );
437 assert_eq!(
438 resolve_symbol(&g, "scheduler::Task").as_deref(),
439 Some("type.c::scheduler::Task")
440 );
441 assert_eq!(resolve_symbol(&g, "nonexistent_xyz"), None);
442 }
443
444 #[test]
445 fn intent_detection() {
446 assert_eq!(
447 detect_relational_intent("what calls verify_password"),
448 Some((RelMode::Callers, "verify_password".to_string()))
449 );
450 assert_eq!(
451 detect_relational_intent("what would break if I change the Task enum"),
452 Some((RelMode::Impact, "task".to_string()))
453 );
454 assert_eq!(
455 detect_relational_intent("who calls scheduler tick"),
456 Some((RelMode::Callers, "tick".to_string()))
457 );
458 assert_eq!(detect_relational_intent("the worker run loop"), None);
459 }
460
461 #[test]
462 fn kind_intent_maps_generic_vocabulary() {
463 assert_eq!(
464 kind_intent("the struct that owns the queue"),
465 Some(Kind::Type)
466 );
467 assert_eq!(
468 kind_intent("the trait that picks a worker"),
469 Some(Kind::Trait)
470 );
471 assert_eq!(kind_intent("the react hook for auth"), Some(Kind::Function));
472 assert_eq!(kind_intent("password hashing"), None);
473 }
474
475 #[test]
476 fn rerank_promotes_target_kind_preserving_order() {
477 let g = fixture();
478 let hits = vec![
479 hit_exact("fn.a::service::TaskService::authenticate", 90, "x".into()),
480 hit_exact("type.a::service::TaskService", 80, "x".into()),
481 ];
482 let r = rerank_by_kind(&g, hits, Kind::Type);
483 assert_eq!(r[0].id, "type.a::service::TaskService");
484 }
485}