Skip to main content

gobby_code/graph/code_graph/read/
relationships.rs

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(&current) 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}