1use std::collections::{HashMap, HashSet};
2
3use crate::config::Context;
4
5use crate::graph::typed_query;
6use crate::models::{GraphPathStep, GraphResult};
7
8use super::super::connection::with_optional_core_graph;
9use super::super::payload::{row_string_owned, row_usize};
10use super::relationship_queries::{
11 blast_radius_query, count_callers_query, count_usages_query, find_callee_ids_batch_query,
12 find_callees_batch_query, find_caller_ids_batch_query, find_caller_ids_query,
13 find_callers_batch_query, find_callers_query, find_usage_ids_query, find_usages_query,
14 get_imports_query, resolve_external_call_target_query, symbol_callee_edges_query,
15 symbol_path_steps_query,
16};
17use super::support::{MAX_GRAPH_LIMIT, count_from_rows, row_to_graph_result};
18use gobby_core::falkor::GraphClient;
19
20pub const DEFAULT_SYMBOL_PATH_MAX_DEPTH: usize = 8;
21pub const MAX_SYMBOL_PATH_DEPTH: usize = 16;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ResolvedExternalCallTarget {
25 pub id: String,
26 pub display_name: String,
27}
28
29fn external_call_target_display_name(name: &str, module: &str) -> String {
30 if module.is_empty() {
31 name.to_string()
32 } else {
33 format!("{module}.{name}")
34 }
35}
36
37fn select_external_call_target(
38 candidates: Vec<ResolvedExternalCallTarget>,
39) -> (Option<ResolvedExternalCallTarget>, Vec<String>) {
40 if candidates.len() == 1 {
41 return (candidates.into_iter().next(), Vec::new());
42 }
43 let suggestions = candidates
44 .into_iter()
45 .map(|candidate| candidate.display_name)
46 .collect();
47 (None, suggestions)
48}
49
50pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
51 with_optional_core_graph(
52 ctx,
53 || 0,
54 |client| {
55 let (query, params) = count_callers_query(&ctx.project_id, symbol_id);
56 let rows = client.query(&query, Some(params))?;
57 Ok(count_from_rows(&rows))
58 },
59 )
60}
61
62pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
63 with_optional_core_graph(
64 ctx,
65 || 0,
66 |client| {
67 let (query, params) = count_usages_query(&ctx.project_id, symbol_id);
68 let rows = client.query(&query, Some(params))?;
69 Ok(count_from_rows(&rows))
70 },
71 )
72}
73
74pub fn find_callers(
75 ctx: &Context,
76 symbol_id: &str,
77 offset: usize,
78 limit: usize,
79) -> anyhow::Result<Vec<GraphResult>> {
80 with_optional_core_graph(ctx, Vec::new, |client| {
81 let (query, params) = find_callers_query(&ctx.project_id, symbol_id, offset, limit);
82 let rows = client.query(&query, Some(params))?;
83 Ok(rows.iter().map(row_to_graph_result).collect())
84 })
85}
86
87pub fn find_usages(
88 ctx: &Context,
89 symbol_id: &str,
90 offset: usize,
91 limit: usize,
92) -> anyhow::Result<Vec<GraphResult>> {
93 with_optional_core_graph(ctx, Vec::new, |client| {
94 let (query, params) = find_usages_query(&ctx.project_id, symbol_id, offset, limit);
95 let rows = client.query(&query, Some(params))?;
96 Ok(rows.iter().map(row_to_graph_result).collect())
97 })
98}
99
100pub fn find_caller_ids(
101 ctx: &Context,
102 symbol_id: &str,
103 limit: usize,
104) -> anyhow::Result<Vec<String>> {
105 with_optional_core_graph(ctx, Vec::new, |client| {
106 let (query, params) = find_caller_ids_query(&ctx.project_id, symbol_id, limit);
107 let rows = client.query(&query, Some(params))?;
108 Ok(rows
109 .iter()
110 .filter_map(|row| row_string_owned(row, &["id"]))
111 .collect())
112 })
113}
114
115pub fn find_usage_ids(ctx: &Context, symbol_id: &str, limit: usize) -> anyhow::Result<Vec<String>> {
116 with_optional_core_graph(ctx, Vec::new, |client| {
117 let (query, params) = find_usage_ids_query(&ctx.project_id, symbol_id, limit);
118 let rows = client.query(&query, Some(params))?;
119 Ok(rows
120 .iter()
121 .filter_map(|row| row_string_owned(row, &["id"]))
122 .collect())
123 })
124}
125
126pub fn find_callers_batch(
127 ctx: &Context,
128 symbol_ids: &[String],
129 limit: usize,
130) -> anyhow::Result<Vec<GraphResult>> {
131 if symbol_ids.is_empty() {
132 return Ok(vec![]);
133 }
134 with_optional_core_graph(ctx, Vec::new, |client| {
135 let (query, params) = find_callers_batch_query(&ctx.project_id, symbol_ids, limit);
136 let rows = client.query(&query, Some(params))?;
137 Ok(rows.iter().map(row_to_graph_result).collect())
138 })
139}
140
141pub fn find_caller_ids_batch(
142 ctx: &Context,
143 symbol_ids: &[String],
144 limit: usize,
145) -> anyhow::Result<Vec<String>> {
146 if symbol_ids.is_empty() {
147 return Ok(vec![]);
148 }
149 with_optional_core_graph(ctx, Vec::new, |client| {
150 let (query, params) = find_caller_ids_batch_query(&ctx.project_id, symbol_ids, limit);
151 let rows = client.query(&query, Some(params))?;
152 Ok(rows
153 .iter()
154 .filter_map(|row| row_string_owned(row, &["id"]))
155 .collect())
156 })
157}
158
159pub fn find_callees_batch(
160 ctx: &Context,
161 symbol_ids: &[String],
162 limit: usize,
163) -> anyhow::Result<Vec<GraphResult>> {
164 if symbol_ids.is_empty() {
165 return Ok(vec![]);
166 }
167 with_optional_core_graph(ctx, Vec::new, |client| {
168 let (query, params) = find_callees_batch_query(&ctx.project_id, symbol_ids, limit);
169 let rows = client.query(&query, Some(params))?;
170 Ok(rows.iter().map(row_to_graph_result).collect())
171 })
172}
173
174pub fn find_callee_ids_batch(
175 ctx: &Context,
176 symbol_ids: &[String],
177 limit: usize,
178) -> anyhow::Result<Vec<String>> {
179 if symbol_ids.is_empty() {
180 return Ok(vec![]);
181 }
182 with_optional_core_graph(ctx, Vec::new, |client| {
183 let (query, params) = find_callee_ids_batch_query(&ctx.project_id, symbol_ids, limit);
184 let rows = client.query(&query, Some(params))?;
185 Ok(rows
186 .iter()
187 .filter_map(|row| row_string_owned(row, &["id"]))
188 .collect())
189 })
190}
191
192pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
193 with_optional_core_graph(ctx, Vec::new, |client| {
194 let (query, params) = get_imports_query(&ctx.project_id, file_path);
195 let rows = client.query(&query, Some(params))?;
196 Ok(rows.iter().map(row_to_graph_result).collect())
197 })
198}
199
200pub fn resolve_external_call_target(
201 ctx: &Context,
202 input: &str,
203) -> anyhow::Result<(Option<ResolvedExternalCallTarget>, Vec<String>)> {
204 with_optional_core_graph(
205 ctx,
206 || (None, Vec::new()),
207 |client| {
208 let (query, params) = resolve_external_call_target_query(&ctx.project_id, input);
209 let rows = client.query(&query, Some(params))?;
210 let candidates = rows
211 .iter()
212 .filter_map(|row| {
213 let id = row_string_owned(row, &["id"])?;
214 let name = row_string_owned(row, &["name"]).unwrap_or_else(|| id.clone());
215 let module = row_string_owned(row, &["module"]).unwrap_or_default();
216 Some(ResolvedExternalCallTarget {
217 id,
218 display_name: external_call_target_display_name(&name, &module),
219 })
220 })
221 .collect();
222 Ok(select_external_call_target(candidates))
223 },
224 )
225}
226
227fn symbol_callee_edges(
228 client: &mut GraphClient,
229 project_id: &str,
230 symbol_ids: &[String],
231) -> anyhow::Result<Vec<(String, String)>> {
232 if symbol_ids.is_empty() {
233 return Ok(Vec::new());
234 }
235 let (query, params) = symbol_callee_edges_query(project_id, symbol_ids);
236 let rows = client.query(&query, Some(params))?;
237 Ok(rows
238 .iter()
239 .filter_map(|row| {
240 let source_id = row_string_owned(row, &["source_id"])?;
241 let target_id = row_string_owned(row, &["target_id"])?;
242 Some((source_id, target_id))
243 })
244 .collect())
245}
246
247fn reconstruct_symbol_path(
248 from_id: &str,
249 to_id: &str,
250 parents: &HashMap<String, String>,
251) -> Vec<String> {
252 let mut path = vec![to_id.to_string()];
253 let mut current = to_id.to_string();
254 while current != from_id {
255 let Some(parent) = parents.get(¤t) else {
256 return Vec::new();
257 };
258 path.push(parent.clone());
259 current = parent.clone();
260 }
261 path.reverse();
262 path
263}
264
265fn symbol_path_steps(
266 client: &mut GraphClient,
267 project_id: &str,
268 symbol_ids: &[String],
269) -> anyhow::Result<Vec<GraphPathStep>> {
270 if symbol_ids.is_empty() {
271 return Ok(Vec::new());
272 }
273 let (query, params) = symbol_path_steps_query(project_id, symbol_ids);
274 let rows = client.query(&query, Some(params))?;
275 let mut steps_by_id = HashMap::new();
276 for row in rows {
277 let Some(id) = row_string_owned(&row, &["symbol_id", "id"]) else {
278 continue;
279 };
280 steps_by_id.insert(
281 id.clone(),
282 GraphPathStep {
283 position: 0,
284 name: row_string_owned(&row, &["symbol_name", "name"])
285 .unwrap_or_else(|| id.clone()),
286 file_path: row_string_owned(&row, &["file_path", "file"]).unwrap_or_default(),
287 line: row_usize(&row, &["line"]).unwrap_or(0),
288 id,
289 },
290 );
291 }
292
293 let mut steps = Vec::with_capacity(symbol_ids.len());
294 for (position, symbol_id) in symbol_ids.iter().enumerate() {
295 let Some(mut step) = steps_by_id.remove(symbol_id) else {
296 return Ok(Vec::new());
297 };
298 step.position = position;
299 steps.push(step);
300 }
301 Ok(steps)
302}
303
304pub fn shortest_symbol_path(
305 ctx: &Context,
306 from_id: &str,
307 to_id: &str,
308 max_depth: usize,
309) -> anyhow::Result<Vec<GraphPathStep>> {
310 let max_depth = max_depth.clamp(1, MAX_SYMBOL_PATH_DEPTH);
311 with_optional_core_graph(ctx, Vec::new, |client| {
312 if from_id == to_id {
313 return symbol_path_steps(client, &ctx.project_id, &[from_id.to_string()]);
314 }
315
316 let mut visited = HashSet::from([from_id.to_string()]);
317 let mut parents = HashMap::new();
318 let mut frontier = vec![from_id.to_string()];
319
320 for _ in 0..max_depth {
321 let edges = symbol_callee_edges(client, &ctx.project_id, &frontier)?;
322 let mut next_frontier = Vec::new();
323 for (source_id, target_id) in edges {
324 if !visited.insert(target_id.clone()) {
325 continue;
326 }
327 parents.insert(target_id.clone(), source_id);
328 if target_id == to_id {
329 let symbol_ids = reconstruct_symbol_path(from_id, to_id, &parents);
330 return symbol_path_steps(client, &ctx.project_id, &symbol_ids);
331 }
332 next_frontier.push(target_id);
333 }
334 if next_frontier.is_empty() {
335 break;
336 }
337 frontier = next_frontier;
338 }
339
340 Ok(Vec::new())
341 })
342}
343
344pub fn blast_radius(
345 ctx: &Context,
346 symbol_id: &str,
347 depth: usize,
348) -> anyhow::Result<Vec<GraphResult>> {
349 with_optional_core_graph(ctx, Vec::new, |client| {
350 let query = blast_radius_query(depth, MAX_GRAPH_LIMIT);
351 let params = typed_query::string_params(&[("project", &ctx.project_id), ("id", symbol_id)]);
352 let rows = client.query(&query, Some(params))?;
353 Ok(rows.iter().map(row_to_graph_result).collect())
354 })
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn target(id: &str, display_name: &str) -> ResolvedExternalCallTarget {
362 ResolvedExternalCallTarget {
363 id: id.to_string(),
364 display_name: display_name.to_string(),
365 }
366 }
367
368 #[test]
369 fn external_call_target_display_uses_module_when_present() {
370 assert_eq!(
371 external_call_target_display_name("get", "requests"),
372 "requests.get"
373 );
374 assert_eq!(external_call_target_display_name("get", ""), "get");
375 }
376
377 #[test]
378 fn select_external_call_target_resolves_single_candidate() {
379 let (resolved, suggestions) =
380 select_external_call_target(vec![target("external-1", "requests.get")]);
381
382 assert!(suggestions.is_empty());
383 let resolved = resolved.expect("single external target resolves");
384 assert_eq!(resolved.id, "external-1");
385 assert_eq!(resolved.display_name, "requests.get");
386 }
387
388 #[test]
389 fn select_external_call_target_reports_ambiguous_candidates() {
390 let (resolved, suggestions) = select_external_call_target(vec![
391 target("external-1", "requests.get"),
392 target("external-2", "httpx.get"),
393 ]);
394
395 assert!(resolved.is_none());
396 assert_eq!(suggestions, ["requests.get", "httpx.get"]);
397 }
398}