1use std::collections::{HashMap, HashSet};
8
9use gitcortex_core::{
10 error::Result,
11 graph::Node,
12 schema::{EdgeConfidence, EdgeKind, NodeKind, Visibility},
13 store::GraphStore,
14};
15use serde::Serialize;
16
17use super::{
18 helpers::{confidence_rank, is_test_file, sig_line},
19 search::SearchHit,
20};
21
22const DEFAULT_LIMIT: usize = 25;
23const MAX_LIMIT: usize = 100;
24const DEFAULT_BUDGET_TOKENS: usize = 2_000;
25const MIN_BUDGET_TOKENS: usize = 400;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AgentStatus {
30 Ok,
31 Ambiguous,
32 NotFound,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct SymbolCandidate {
37 pub id: String,
38 pub name: String,
39 pub qualified_name: String,
40 pub kind: String,
41 pub file: String,
42 pub start_line: u32,
43 pub visibility: String,
44}
45
46#[derive(Debug, Clone, Default, Serialize)]
47pub struct ConfidenceMix {
48 pub extracted: usize,
49 pub resolved: usize,
50 pub inferred: usize,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct Coverage {
55 pub total: usize,
56 pub returned: usize,
57 pub truncated: bool,
58 pub confidence_mix: ConfidenceMix,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct CallerEvidence {
63 pub hop: u8,
64 pub symbol: String,
65 pub qualified_name: String,
66 pub kind: String,
67 pub file: String,
68 pub line: u32,
69 pub signature: String,
70 pub confidence: String,
71 pub is_test: bool,
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct AgentCallersResponse {
76 pub status: AgentStatus,
77 pub answer: String,
78 pub query: String,
79 pub branch: String,
80 pub depth: u8,
81 pub risk_level: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub symbol: Option<SymbolCandidate>,
84 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub candidates: Vec<SymbolCandidate>,
86 pub evidence: Vec<CallerEvidence>,
87 pub coverage: Coverage,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub next_action: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize)]
93pub struct RelationEvidence {
94 pub relation: String,
95 pub direction: String,
96 pub symbol: String,
97 pub qualified_name: String,
98 pub kind: String,
99 pub file: String,
100 pub line: u32,
101 pub confidence: String,
102 pub is_test: bool,
103}
104
105#[derive(Debug, Clone, Serialize)]
106pub struct NeighborhoodCoverage {
107 pub graph_nodes: usize,
108 pub graph_edges: usize,
109 pub direct_relations: usize,
110 pub returned: usize,
111 pub truncated: bool,
112}
113
114#[derive(Debug, Clone, Serialize)]
115pub struct AgentSubgraphResponse {
116 pub status: AgentStatus,
117 pub answer: String,
118 pub query: String,
119 pub branch: String,
120 pub depth: u8,
121 pub direction: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub symbol: Option<SymbolCandidate>,
124 #[serde(skip_serializing_if = "Vec::is_empty")]
125 pub candidates: Vec<SymbolCandidate>,
126 pub relation_counts: std::collections::BTreeMap<String, usize>,
127 pub evidence: Vec<RelationEvidence>,
128 pub coverage: NeighborhoodCoverage,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub next_action: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134pub struct SearchEvidence {
135 pub symbol: String,
136 pub qualified_name: String,
137 pub kind: String,
138 pub file: String,
139 pub line: u32,
140 pub signature: String,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub doc: Option<String>,
143 pub score: i32,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct SearchCoverage {
148 pub total: usize,
149 pub returned: usize,
150 pub truncated: bool,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct AgentSearchResponse {
155 pub status: AgentStatus,
156 pub answer: String,
157 pub query: String,
158 pub semantic_available: bool,
159 pub file_count: usize,
160 pub evidence: Vec<SearchEvidence>,
161 pub coverage: SearchCoverage,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub next_action: Option<String>,
164}
165
166#[derive(Debug, Clone, Copy)]
167pub struct AgentQueryOptions {
168 pub limit: usize,
169 pub budget_tokens: usize,
170}
171
172impl Default for AgentQueryOptions {
173 fn default() -> Self {
174 Self {
175 limit: DEFAULT_LIMIT,
176 budget_tokens: DEFAULT_BUDGET_TOKENS,
177 }
178 }
179}
180
181enum Resolution {
182 Exact(Box<Node>),
183 Ambiguous(Vec<Node>),
184 NotFound(Vec<Node>),
185}
186
187pub fn format_search<S: GraphStore + ?Sized>(
190 store: &S,
191 branch: &str,
192 query: &str,
193 hits: Vec<SearchHit>,
194 semantic_available: bool,
195 budget_tokens: usize,
196) -> Result<AgentSearchResponse> {
197 let total = hits.len();
198 let ids: Vec<String> = hits.iter().map(|hit| hit.id.clone()).collect();
199 let nodes = store.get_nodes_by_ids(branch, &ids)?;
200 let by_id: HashMap<String, Node> = nodes
201 .into_iter()
202 .map(|node| (node.id.as_str(), node))
203 .collect();
204 let mut files = HashSet::new();
205 let mut evidence = Vec::new();
206 for hit in hits {
207 files.insert(hit.file.clone());
208 let node = by_id.get(&hit.id);
209 let doc = node
210 .and_then(|node| node.metadata.definition.doc_comment.as_deref())
211 .and_then(|text| text.lines().find(|line| !line.trim().is_empty()))
212 .map(|line| line.trim().chars().take(180).collect());
213 evidence.push(SearchEvidence {
214 symbol: hit.name,
215 qualified_name: hit.qualified_name,
216 kind: hit.kind,
217 file: hit.file,
218 line: hit.start_line,
219 signature: node.map(sig_line).unwrap_or_default(),
220 doc,
221 score: hit.score,
222 });
223 }
224 let answer = if total == 0 {
225 format!("No code symbols matched '{query}'.")
226 } else {
227 let top_files = evidence
228 .iter()
229 .map(|item| item.file.as_str())
230 .take(3)
231 .collect::<Vec<_>>()
232 .join(", ");
233 format!(
234 "{total} ranked symbol match(es) across {} file(s). Top files: {top_files}.",
235 files.len()
236 )
237 };
238 let mut response = AgentSearchResponse {
239 status: if total == 0 {
240 AgentStatus::NotFound
241 } else {
242 AgentStatus::Ok
243 },
244 answer,
245 query: query.to_owned(),
246 semantic_available,
247 file_count: files.len(),
248 evidence,
249 coverage: SearchCoverage {
250 total,
251 returned: 0,
252 truncated: false,
253 },
254 next_action: if total == 0 {
255 Some("Try a concrete symbol fragment or alternate spelling.".to_owned())
256 } else {
257 None
258 },
259 };
260 apply_search_budget(&mut response, budget_tokens.max(MIN_BUDGET_TOKENS));
261 Ok(response)
262}
263
264pub fn find_callers<S: GraphStore + ?Sized>(
267 store: &S,
268 branch: &str,
269 query: &str,
270 depth: u8,
271 options: AgentQueryOptions,
272) -> Result<AgentCallersResponse> {
273 let depth = depth.clamp(1, 5);
274 let options = AgentQueryOptions {
275 limit: options.limit.clamp(1, MAX_LIMIT),
276 budget_tokens: options.budget_tokens.max(MIN_BUDGET_TOKENS),
277 };
278
279 let target = match resolve_symbol(store, branch, query)? {
280 Resolution::Exact(node) => *node,
281 Resolution::Ambiguous(nodes) => {
282 let total_candidates = nodes.len();
283 let candidates = candidate_head(nodes, 5);
284 return Ok(AgentCallersResponse {
285 status: AgentStatus::Ambiguous,
286 answer: format!(
287 "'{}' matches {total_candidates} code symbols; choose a qualified symbol before computing impact.",
288 query
289 ),
290 query: query.to_owned(),
291 branch: branch.to_owned(),
292 depth,
293 risk_level: "UNKNOWN".to_owned(),
294 symbol: None,
295 candidates,
296 evidence: Vec::new(),
297 coverage: Coverage {
298 total: 0,
299 returned: 0,
300 truncated: false,
301 confidence_mix: ConfidenceMix::default(),
302 },
303 next_action: Some(
304 "Repeat find_callers with one candidate's qualified_name.".to_owned(),
305 ),
306 });
307 }
308 Resolution::NotFound(nodes) => {
309 let candidates = candidate_head(nodes, 5);
310 return Ok(AgentCallersResponse {
311 status: AgentStatus::NotFound,
312 answer: format!("No exact code symbol matching '{query}' was found."),
313 query: query.to_owned(),
314 branch: branch.to_owned(),
315 depth,
316 risk_level: "UNKNOWN".to_owned(),
317 symbol: None,
318 candidates,
319 evidence: Vec::new(),
320 coverage: Coverage {
321 total: 0,
322 returned: 0,
323 truncated: false,
324 confidence_mix: ConfidenceMix::default(),
325 },
326 next_action: Some("Use search_code to find the exact qualified symbol.".to_owned()),
327 });
328 }
329 };
330
331 let target_summary = to_candidate(&target);
332 let mut seen: HashSet<String> = HashSet::new();
333 seen.insert(target.id.as_str());
334 let mut frontier = vec![target.id.as_str()];
335 let mut evidence = Vec::new();
336 let mut mix = ConfidenceMix::default();
337
338 for hop in 1..=depth {
339 let mut pairs = Vec::new();
340 for target_id in &frontier {
341 pairs.extend(store.find_callers_by_id_with_confidence(branch, target_id)?);
342 }
343 pairs.retain(|(node, _)| seen.insert(node.id.as_str()));
344 pairs.sort_by(rank_callers);
345
346 frontier = pairs.iter().map(|(node, _)| node.id.as_str()).collect();
347 for (node, confidence) in pairs {
348 match confidence {
349 EdgeConfidence::Extracted => mix.extracted += 1,
350 EdgeConfidence::Resolved => mix.resolved += 1,
351 EdgeConfidence::Inferred => mix.inferred += 1,
352 }
353 evidence.push(to_evidence(node, confidence, hop));
354 }
355 if frontier.is_empty() {
356 break;
357 }
358 }
359
360 let total = evidence.len();
361 let risk_level = match total {
362 0..=2 => "LOW",
363 3..=10 => "MEDIUM",
364 11..=30 => "HIGH",
365 _ => "CRITICAL",
366 };
367 evidence.truncate(options.limit);
368
369 let answer = if total == 0 {
370 format!(
371 "No callers found for '{}' ({}).",
372 target.name, target.qualified_name
373 )
374 } else {
375 format!(
376 "{total} caller(s) within {depth} hop(s) of '{}' — change risk {risk_level}.",
377 target.qualified_name
378 )
379 };
380
381 let mut response = AgentCallersResponse {
382 status: AgentStatus::Ok,
383 answer,
384 query: query.to_owned(),
385 branch: branch.to_owned(),
386 depth,
387 risk_level: risk_level.to_owned(),
388 symbol: Some(target_summary),
389 candidates: Vec::new(),
390 evidence,
391 coverage: Coverage {
392 total,
393 returned: 0,
394 truncated: false,
395 confidence_mix: mix,
396 },
397 next_action: None,
398 };
399 apply_budget(&mut response, options.budget_tokens);
400 Ok(response)
401}
402
403pub fn get_subgraph<S: GraphStore + ?Sized>(
406 store: &S,
407 branch: &str,
408 query: &str,
409 depth: u8,
410 direction: &str,
411 options: AgentQueryOptions,
412) -> Result<AgentSubgraphResponse> {
413 let depth = depth.clamp(1, 5);
414 let direction = match direction {
415 "in" | "out" | "both" => direction,
416 _ => "both",
417 };
418 let options = AgentQueryOptions {
419 limit: options.limit.clamp(1, MAX_LIMIT),
420 budget_tokens: options.budget_tokens.max(MIN_BUDGET_TOKENS),
421 };
422
423 let target = match resolve_symbol(store, branch, query)? {
424 Resolution::Exact(node) => *node,
425 Resolution::Ambiguous(nodes) => {
426 let total = nodes.len();
427 return Ok(AgentSubgraphResponse {
428 status: AgentStatus::Ambiguous,
429 answer: format!(
430 "'{query}' matches {total} code symbols; choose a qualified symbol before traversing its neighborhood."
431 ),
432 query: query.to_owned(),
433 branch: branch.to_owned(),
434 depth,
435 direction: direction.to_owned(),
436 symbol: None,
437 candidates: candidate_head(nodes, 5),
438 relation_counts: Default::default(),
439 evidence: Vec::new(),
440 coverage: NeighborhoodCoverage {
441 graph_nodes: 0,
442 graph_edges: 0,
443 direct_relations: 0,
444 returned: 0,
445 truncated: false,
446 },
447 next_action: Some(
448 "Repeat get_subgraph with one candidate's qualified_name.".to_owned(),
449 ),
450 });
451 }
452 Resolution::NotFound(nodes) => {
453 return Ok(AgentSubgraphResponse {
454 status: AgentStatus::NotFound,
455 answer: format!("No exact code symbol matching '{query}' was found."),
456 query: query.to_owned(),
457 branch: branch.to_owned(),
458 depth,
459 direction: direction.to_owned(),
460 symbol: None,
461 candidates: candidate_head(nodes, 5),
462 relation_counts: Default::default(),
463 evidence: Vec::new(),
464 coverage: NeighborhoodCoverage {
465 graph_nodes: 0,
466 graph_edges: 0,
467 direct_relations: 0,
468 returned: 0,
469 truncated: false,
470 },
471 next_action: Some("Use search_code to find the exact qualified symbol.".to_owned()),
472 });
473 }
474 };
475
476 let graph = store.get_subgraph_by_id(branch, &target.id.as_str(), depth, direction)?;
477 let by_id: HashMap<String, &Node> = graph
478 .nodes
479 .iter()
480 .filter(|node| is_code_node(node))
481 .map(|node| (node.id.as_str(), node))
482 .collect();
483 let target_id = target.id.as_str();
484 let mut evidence = Vec::new();
485 let mut seen = HashSet::new();
486 let mut counts = std::collections::BTreeMap::new();
487
488 for edge in &graph.edges {
489 let src = edge.src.as_str();
490 let dst = edge.dst.as_str();
491 let (other_id, edge_direction) = if src == target_id {
492 (dst, "out")
493 } else if dst == target_id {
494 (src, "in")
495 } else {
496 continue;
497 };
498 if direction != "both" && direction != edge_direction {
499 continue;
500 }
501 let Some(other) = by_id.get(&other_id) else {
502 continue;
503 };
504 let relation = relation_label(&edge.kind, edge_direction);
505 if !seen.insert((relation, other_id)) {
506 continue;
507 }
508 *counts.entry(relation.to_owned()).or_insert(0) += 1;
509 evidence.push(RelationEvidence {
510 relation: relation.to_owned(),
511 direction: edge_direction.to_owned(),
512 symbol: other.name.clone(),
513 qualified_name: other.qualified_name.clone(),
514 kind: other.kind.to_string(),
515 file: other.file.display().to_string(),
516 line: edge.line.unwrap_or(other.span.start_line),
517 confidence: edge.confidence.to_string(),
518 is_test: is_test_file(&other.file),
519 });
520 }
521 evidence.sort_by(|a, b| {
522 relation_rank(&a.relation)
523 .cmp(&relation_rank(&b.relation))
524 .then_with(|| {
525 confidence_label_rank(&a.confidence).cmp(&confidence_label_rank(&b.confidence))
526 })
527 .then_with(|| a.is_test.cmp(&b.is_test))
528 .then_with(|| a.file.cmp(&b.file))
529 .then_with(|| a.qualified_name.cmp(&b.qualified_name))
530 });
531 let direct_relations = evidence.len();
532 evidence.truncate(options.limit);
533 let count_summary = counts
534 .iter()
535 .map(|(relation, count)| format!("{relation}={count}"))
536 .collect::<Vec<_>>()
537 .join(", ");
538 let answer = if count_summary.is_empty() {
539 format!(
540 "'{}' has no direct relationships in the selected direction.",
541 target.qualified_name
542 )
543 } else {
544 format!(
545 "Direct relationships for '{}': {count_summary}.",
546 target.qualified_name
547 )
548 };
549 let mut response = AgentSubgraphResponse {
550 status: AgentStatus::Ok,
551 answer,
552 query: query.to_owned(),
553 branch: branch.to_owned(),
554 depth,
555 direction: direction.to_owned(),
556 symbol: Some(to_candidate(&target)),
557 candidates: Vec::new(),
558 relation_counts: counts,
559 evidence,
560 coverage: NeighborhoodCoverage {
561 graph_nodes: by_id.len(),
562 graph_edges: graph.edges.len(),
563 direct_relations,
564 returned: 0,
565 truncated: false,
566 },
567 next_action: None,
568 };
569 apply_subgraph_budget(&mut response, options.budget_tokens);
570 Ok(response)
571}
572
573#[derive(Debug, Clone, Serialize)]
574pub struct AgentSymbolContextResponse {
575 pub status: AgentStatus,
576 pub answer: String,
577 pub query: String,
578 pub branch: String,
579 #[serde(skip_serializing_if = "Option::is_none")]
580 pub symbol: Option<SymbolCandidate>,
581 #[serde(skip_serializing_if = "Vec::is_empty")]
582 pub candidates: Vec<SymbolCandidate>,
583 pub callers: Vec<RelationEvidence>,
584 pub callees: Vec<RelationEvidence>,
585 pub used_by: Vec<RelationEvidence>,
586 pub coverage: Coverage,
587 #[serde(skip_serializing_if = "Option::is_none")]
588 pub next_action: Option<String>,
589}
590
591pub fn symbol_context<S: GraphStore + ?Sized>(
598 store: &S,
599 branch: &str,
600 query: &str,
601 options: AgentQueryOptions,
602) -> Result<AgentSymbolContextResponse> {
603 let options = AgentQueryOptions {
604 limit: options.limit.clamp(1, MAX_LIMIT),
605 budget_tokens: options.budget_tokens.max(MIN_BUDGET_TOKENS),
606 };
607
608 let target = match resolve_symbol(store, branch, query)? {
609 Resolution::Exact(node) => *node,
610 Resolution::Ambiguous(nodes) => {
611 let total = nodes.len();
612 return Ok(AgentSymbolContextResponse {
613 status: AgentStatus::Ambiguous,
614 answer: format!(
615 "'{query}' matches {total} code symbols; choose a qualified symbol before requesting its context."
616 ),
617 query: query.to_owned(),
618 branch: branch.to_owned(),
619 symbol: None,
620 candidates: candidate_head(nodes, 5),
621 callers: Vec::new(),
622 callees: Vec::new(),
623 used_by: Vec::new(),
624 coverage: Coverage {
625 total: 0,
626 returned: 0,
627 truncated: false,
628 confidence_mix: ConfidenceMix::default(),
629 },
630 next_action: Some(
631 "Repeat symbol_context with one candidate's qualified_name.".to_owned(),
632 ),
633 });
634 }
635 Resolution::NotFound(nodes) => {
636 return Ok(AgentSymbolContextResponse {
637 status: AgentStatus::NotFound,
638 answer: format!("No exact code symbol matching '{query}' was found."),
639 query: query.to_owned(),
640 branch: branch.to_owned(),
641 symbol: None,
642 candidates: candidate_head(nodes, 5),
643 callers: Vec::new(),
644 callees: Vec::new(),
645 used_by: Vec::new(),
646 coverage: Coverage {
647 total: 0,
648 returned: 0,
649 truncated: false,
650 confidence_mix: ConfidenceMix::default(),
651 },
652 next_action: Some("Use search_code to find the exact qualified symbol.".to_owned()),
653 });
654 }
655 };
656
657 let graph = store.get_subgraph_by_id(branch, &target.id.as_str(), 1, "both")?;
658 let by_id: HashMap<String, &Node> = graph
659 .nodes
660 .iter()
661 .filter(|node| is_code_node(node))
662 .map(|node| (node.id.as_str(), node))
663 .collect();
664 let target_id = target.id.as_str();
665
666 let mut callers = Vec::new();
667 let mut callees = Vec::new();
668 let mut used_by = Vec::new();
669 let mut seen = HashSet::new();
670 let mut mix = ConfidenceMix::default();
671
672 for edge in &graph.edges {
673 let src = edge.src.as_str();
674 let dst = edge.dst.as_str();
675 let (other_id, edge_direction) = if src == target_id {
676 (dst, "out")
677 } else if dst == target_id {
678 (src, "in")
679 } else {
680 continue;
681 };
682 let Some(other) = by_id.get(&other_id) else {
683 continue;
684 };
685 let bucket = match (&edge.kind, edge_direction) {
686 (EdgeKind::Calls, "in") => &mut callers,
687 (EdgeKind::Calls, "out") => &mut callees,
688 (EdgeKind::Uses, "in") => &mut used_by,
689 _ => continue,
690 };
691 if !seen.insert((edge_direction, other_id)) {
692 continue;
693 }
694 match edge.confidence {
695 EdgeConfidence::Extracted => mix.extracted += 1,
696 EdgeConfidence::Resolved => mix.resolved += 1,
697 EdgeConfidence::Inferred => mix.inferred += 1,
698 }
699 bucket.push(RelationEvidence {
700 relation: relation_label(&edge.kind, edge_direction).to_owned(),
701 direction: edge_direction.to_owned(),
702 symbol: other.name.clone(),
703 qualified_name: other.qualified_name.clone(),
704 kind: other.kind.to_string(),
705 file: other.file.display().to_string(),
706 line: edge.line.unwrap_or(other.span.start_line),
707 confidence: edge.confidence.to_string(),
708 is_test: is_test_file(&other.file),
709 });
710 }
711 for bucket in [&mut callers, &mut callees, &mut used_by] {
712 bucket.sort_by(|a, b| {
713 a.file
714 .cmp(&b.file)
715 .then_with(|| a.qualified_name.cmp(&b.qualified_name))
716 });
717 }
718
719 let total = callers.len() + callees.len() + used_by.len();
720 for bucket in [&mut callers, &mut callees, &mut used_by] {
721 bucket.truncate(options.limit);
722 }
723 let answer = format!(
724 "'{}' has {} caller(s), {} callee(s), {} usage site(s).",
725 target.qualified_name,
726 callers.len(),
727 callees.len(),
728 used_by.len()
729 );
730 let mut response = AgentSymbolContextResponse {
731 status: AgentStatus::Ok,
732 answer,
733 query: query.to_owned(),
734 branch: branch.to_owned(),
735 symbol: Some(to_candidate(&target)),
736 candidates: Vec::new(),
737 callers,
738 callees,
739 used_by,
740 coverage: Coverage {
741 total,
742 returned: 0,
743 truncated: false,
744 confidence_mix: mix,
745 },
746 next_action: None,
747 };
748 apply_symbol_context_budget(&mut response, options.budget_tokens);
749 Ok(response)
750}
751
752fn apply_symbol_context_budget(response: &mut AgentSymbolContextResponse, budget_tokens: usize) {
753 let budget_bytes = budget_tokens * 4;
754 while (!response.used_by.is_empty()
755 || !response.callees.is_empty()
756 || !response.callers.is_empty())
757 && serde_json::to_vec(response)
758 .map(|bytes| bytes.len() > budget_bytes)
759 .unwrap_or(false)
760 {
761 if !response.used_by.is_empty() {
762 response.used_by.pop();
763 } else if !response.callees.is_empty() {
764 response.callees.pop();
765 } else {
766 response.callers.pop();
767 }
768 }
769 response.coverage.returned =
770 response.callers.len() + response.callees.len() + response.used_by.len();
771 response.coverage.truncated = response.coverage.returned < response.coverage.total;
772}
773
774fn relation_label(kind: &EdgeKind, direction: &str) -> &'static str {
775 match (kind, direction) {
776 (EdgeKind::Calls, "out") => "calls",
777 (EdgeKind::Calls, _) => "called_by",
778 (EdgeKind::Uses, "out") => "uses",
779 (EdgeKind::Uses, _) => "used_by",
780 (EdgeKind::Implements, "out") => "implements",
781 (EdgeKind::Implements, _) => "implemented_by",
782 (EdgeKind::Imports, "out") => "imports",
783 (EdgeKind::Imports, _) => "imported_by",
784 (EdgeKind::Contains, "out") => "contains",
785 (EdgeKind::Contains, _) => "contained_by",
786 (EdgeKind::Inherits, "out") => "inherits",
787 (EdgeKind::Inherits, _) => "inherited_by",
788 (EdgeKind::References, "out") => "references",
789 (EdgeKind::References, _) => "referenced_by",
790 _ => "related",
791 }
792}
793
794fn relation_rank(relation: &str) -> u8 {
795 match relation {
796 "called_by" | "calls" => 0,
797 "used_by" | "uses" => 1,
798 "implemented_by" | "implements" | "inherited_by" | "inherits" => 2,
799 "imported_by" | "imports" => 3,
800 "contained_by" | "contains" => 4,
801 _ => 5,
802 }
803}
804
805fn confidence_label_rank(confidence: &str) -> u8 {
806 match confidence {
807 "extracted" => 0,
808 "resolved" => 1,
809 _ => 2,
810 }
811}
812
813fn resolve_symbol<S: GraphStore + ?Sized>(
814 store: &S,
815 branch: &str,
816 query: &str,
817) -> Result<Resolution> {
818 let query = query.trim();
819 let mut exact = store.lookup_symbol(branch, query, false)?;
820 exact.retain(is_code_node);
821
822 let mut searched = store.search_nodes(branch, query, 50)?;
825 searched.retain(is_code_node);
826 if query.contains("::") || query.contains('.') {
827 let qualified: Vec<Node> = searched
828 .iter()
829 .filter(|node| node.qualified_name.eq_ignore_ascii_case(query))
830 .cloned()
831 .collect();
832 if qualified.len() == 1 {
833 return Ok(Resolution::Exact(Box::new(qualified[0].clone())));
834 }
835 if qualified.len() > 1 {
836 return Ok(Resolution::Ambiguous(qualified));
837 }
838 }
839
840 dedup_nodes(&mut exact);
841 match exact.len() {
842 1 => Ok(Resolution::Exact(Box::new(exact.remove(0)))),
843 n if n > 1 => Ok(Resolution::Ambiguous(exact)),
844 _ => {
845 searched.sort_by(rank_candidates);
846 dedup_nodes(&mut searched);
847 Ok(Resolution::NotFound(searched))
848 }
849 }
850}
851
852fn is_code_node(node: &Node) -> bool {
853 !matches!(
854 node.kind,
855 NodeKind::Section | NodeKind::File | NodeKind::Folder | NodeKind::Module
856 )
857}
858
859fn dedup_nodes(nodes: &mut Vec<Node>) {
860 let mut seen = HashSet::new();
861 nodes.retain(|node| seen.insert(node.id.as_str()));
862}
863
864fn candidate_head(mut nodes: Vec<Node>, limit: usize) -> Vec<SymbolCandidate> {
865 nodes.sort_by(rank_candidates);
866 nodes
867 .into_iter()
868 .take(limit)
869 .map(|n| to_candidate(&n))
870 .collect()
871}
872
873fn rank_candidates(a: &Node, b: &Node) -> std::cmp::Ordering {
874 candidate_rank(a)
875 .cmp(&candidate_rank(b))
876 .then_with(|| a.file.cmp(&b.file))
877 .then_with(|| a.qualified_name.cmp(&b.qualified_name))
878}
879
880fn candidate_rank(node: &Node) -> (u8, u8) {
881 let test = is_test_file(&node.file) as u8;
882 let visibility = match node.metadata.visibility {
883 Visibility::Pub => 0,
884 Visibility::PubCrate => 1,
885 Visibility::Private => 2,
886 };
887 (test, visibility)
888}
889
890fn rank_callers(
891 (a, ac): &(Node, EdgeConfidence),
892 (b, bc): &(Node, EdgeConfidence),
893) -> std::cmp::Ordering {
894 confidence_rank(ac)
895 .cmp(&confidence_rank(bc))
896 .then_with(|| candidate_rank(a).cmp(&candidate_rank(b)))
897 .then_with(|| a.file.cmp(&b.file))
898 .then_with(|| a.qualified_name.cmp(&b.qualified_name))
899}
900
901fn to_candidate(node: &Node) -> SymbolCandidate {
902 SymbolCandidate {
903 id: node.id.as_str(),
904 name: node.name.clone(),
905 qualified_name: node.qualified_name.clone(),
906 kind: node.kind.to_string(),
907 file: node.file.display().to_string(),
908 start_line: node.span.start_line,
909 visibility: node.metadata.visibility.to_string(),
910 }
911}
912
913fn to_evidence(node: Node, confidence: EdgeConfidence, hop: u8) -> CallerEvidence {
914 CallerEvidence {
915 hop,
916 symbol: node.name.clone(),
917 qualified_name: node.qualified_name.clone(),
918 kind: node.kind.to_string(),
919 file: node.file.display().to_string(),
920 line: node.span.start_line,
921 signature: sig_line(&node),
922 confidence: confidence.to_string(),
923 is_test: is_test_file(&node.file),
924 }
925}
926
927fn apply_search_budget(response: &mut AgentSearchResponse, budget_tokens: usize) {
928 let budget_bytes = budget_tokens * 4;
929 while !response.evidence.is_empty()
930 && serde_json::to_vec(response)
931 .map(|bytes| bytes.len() > budget_bytes)
932 .unwrap_or(false)
933 {
934 response.evidence.pop();
935 }
936 response.coverage.returned = response.evidence.len();
937 response.coverage.truncated = response.coverage.returned < response.coverage.total;
938}
939
940fn apply_subgraph_budget(response: &mut AgentSubgraphResponse, budget_tokens: usize) {
941 let budget_bytes = budget_tokens * 4;
942 while !response.evidence.is_empty()
943 && serde_json::to_vec(response)
944 .map(|bytes| bytes.len() > budget_bytes)
945 .unwrap_or(false)
946 {
947 response.evidence.pop();
948 }
949 response.coverage.returned = response.evidence.len();
950 response.coverage.truncated = response.coverage.returned < response.coverage.direct_relations;
951}
952
953fn apply_budget(response: &mut AgentCallersResponse, budget_tokens: usize) {
954 let budget_bytes = budget_tokens * 4;
955 while !response.evidence.is_empty()
956 && serde_json::to_vec(response)
957 .map(|bytes| bytes.len() > budget_bytes)
958 .unwrap_or(false)
959 {
960 response.evidence.pop();
961 }
962 response.coverage.returned = response.evidence.len();
963 response.coverage.truncated = response.coverage.returned < response.coverage.total;
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969
970 #[test]
971 fn test_file_detection_covers_supported_languages() {
972 for path in [
973 "tests/api.rs",
974 "src/api_test.go",
975 "src/api.test.ts",
976 "src/__tests__/api.tsx",
977 "src/ApiTest.java",
978 ] {
979 assert!(
980 is_test_file(std::path::Path::new(path)),
981 "expected test path: {path}"
982 );
983 }
984 assert!(!is_test_file(std::path::Path::new("src/api.rs")));
985 }
986
987 #[test]
988 fn confidence_order_is_strongest_first() {
989 assert!(
990 confidence_rank(&EdgeConfidence::Extracted)
991 < confidence_rank(&EdgeConfidence::Resolved)
992 );
993 assert!(
994 confidence_rank(&EdgeConfidence::Resolved) < confidence_rank(&EdgeConfidence::Inferred)
995 );
996 }
997}