Skip to main content

gobby_code/commands/graph/
reads.rs

1use std::collections::BTreeMap;
2
3use crate::config::Context;
4use crate::db;
5use crate::graph::code_graph;
6use crate::models::{GraphResult, PagedResponse};
7use crate::output::{self, Format};
8use crate::search::fts::{self, ResolvedGraphSymbol};
9use serde::Serialize;
10
11const GRAPH_BACKEND_HINT: &str =
12    "Graph commands require a configured FalkorDB graph backend and synced graph projection.";
13
14fn hint_for(ctx: &Context) -> Option<String> {
15    if ctx.falkordb.is_none() {
16        Some(GRAPH_BACKEND_HINT.to_string())
17    } else {
18        None
19    }
20}
21
22fn hint_for_error(ctx: &Context, error: &anyhow::Error) -> Option<String> {
23    match error.downcast_ref::<code_graph::GraphReadError>() {
24        Some(code_graph::GraphReadError::NotConfigured) => hint_for(ctx),
25        Some(code_graph::GraphReadError::Unreachable { message }) => Some(format!(
26            "FalkorDB is configured but unreachable; graph results are unavailable: {message}"
27        )),
28        _ => hint_for(ctx),
29    }
30}
31
32fn print_graph_hint_text(ctx: &Context, error: Option<&anyhow::Error>) {
33    let hint = error.and_then(|err| hint_for_error(ctx, err));
34    let hint = hint.or_else(|| hint_for(ctx));
35    if let Some(hint) = hint {
36        eprintln!("Hint: {hint}");
37    }
38}
39
40fn graph_read_unavailable(error: &anyhow::Error) -> bool {
41    matches!(
42        error.downcast_ref::<code_graph::GraphReadError>(),
43        Some(
44            code_graph::GraphReadError::NotConfigured
45                | code_graph::GraphReadError::Unreachable { .. }
46        )
47    )
48}
49
50fn empty_paged_response<T: Serialize>(
51    ctx: &Context,
52    offset: usize,
53    limit: usize,
54    format: Format,
55    error: Option<&anyhow::Error>,
56) -> anyhow::Result<()> {
57    match format {
58        Format::Json => output::print_json(&PagedResponse::<T> {
59            project_id: ctx.project_id.clone(),
60            total: 0,
61            offset,
62            limit,
63            results: vec![],
64            hint: error
65                .and_then(|err| hint_for_error(ctx, err))
66                .or_else(|| hint_for(ctx)),
67        }),
68        Format::Text => {
69            print_graph_hint_text(ctx, error);
70            Ok(())
71        }
72    }
73}
74
75fn graph_read_or_empty<T: Serialize>(
76    ctx: &Context,
77    offset: usize,
78    limit: usize,
79    format: Format,
80    read: impl FnOnce() -> anyhow::Result<T>,
81) -> anyhow::Result<Option<T>> {
82    match read() {
83        Ok(value) => Ok(Some(value)),
84        Err(err) if graph_read_unavailable(&err) => {
85            empty_paged_response::<T>(ctx, offset, limit, format, Some(&err))?;
86            Ok(None)
87        }
88        Err(err) => Err(err),
89    }
90}
91
92pub(super) fn format_grouped_graph_results<F>(results: &[GraphResult], format_line: F) -> String
93where
94    F: Fn(&GraphResult) -> String,
95{
96    let mut grouped: BTreeMap<&str, Vec<&GraphResult>> = BTreeMap::new();
97    for result in results {
98        grouped.entry(&result.file_path).or_default().push(result);
99    }
100
101    let mut lines = Vec::new();
102    for (file_path, mut entries) in grouped {
103        lines.push(if file_path.is_empty() {
104            "<unknown>".to_string()
105        } else {
106            file_path.to_string()
107        });
108        entries.sort_by(|a, b| {
109            a.line
110                .cmp(&b.line)
111                .then_with(|| a.name.cmp(&b.name))
112                .then_with(|| a.relation.cmp(&b.relation))
113                .then_with(|| a.distance.cmp(&b.distance))
114        });
115        lines.extend(entries.into_iter().map(&format_line));
116    }
117    lines.join("\n")
118}
119
120/// Resolve user input to a canonical symbol id, printing suggestions on ambiguity.
121/// Returns None and prints an error message if no match found.
122fn resolve_symbol(ctx: &Context, input: &str) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
123    let mut conn = match db::connect_readonly(&ctx.database_url) {
124        Ok(c) => c,
125        Err(e) => {
126            eprintln!("Failed to open index for graph resolution: {e}");
127            return Ok(None);
128        }
129    };
130    let (resolved, suggestions) = fts::resolve_graph_symbol(&mut conn, input, &ctx.project_id)?;
131    if resolved.is_none() {
132        if suggestions.is_empty() {
133            eprintln!("No symbol matching '{input}' found");
134        } else {
135            eprintln!(
136                "Ambiguous symbol '{input}'. Refine the query. Matches: {}",
137                suggestions.join(", ")
138            );
139        }
140    }
141    Ok(resolved)
142}
143
144fn resolve_symbol_or_empty_response(
145    ctx: &Context,
146    input: &str,
147    offset: usize,
148    limit: usize,
149    format: Format,
150) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
151    match resolve_symbol(ctx, input)? {
152        Some(symbol) => Ok(Some(symbol)),
153        None => {
154            empty_paged_response::<crate::models::GraphResult>(ctx, offset, limit, format, None)?;
155            Ok(None)
156        }
157    }
158}
159
160fn read_paged_symbol_graph_results(
161    ctx: &Context,
162    symbol_name: &str,
163    limit: usize,
164    offset: usize,
165    format: Format,
166    count: impl FnOnce(&Context, &str) -> anyhow::Result<usize>,
167    find: impl FnOnce(&Context, &str, usize, usize) -> anyhow::Result<Vec<GraphResult>>,
168) -> anyhow::Result<Option<(ResolvedGraphSymbol, usize, Vec<GraphResult>)>> {
169    let Some(()) = graph_read_or_empty::<()>(ctx, offset, limit, format, || {
170        code_graph::require_graph_reads(ctx)
171    })?
172    else {
173        return Ok(None);
174    };
175    let Some(symbol) = resolve_symbol_or_empty_response(ctx, symbol_name, offset, limit, format)?
176    else {
177        return Ok(None);
178    };
179    let Some(total) =
180        graph_read_or_empty::<usize>(ctx, offset, limit, format, || count(ctx, &symbol.id))?
181    else {
182        return Ok(None);
183    };
184    let Some(results) =
185        graph_read_or_empty::<Vec<GraphResult>>(ctx, offset, limit, format, || {
186            find(ctx, &symbol.id, offset, limit)
187        })?
188    else {
189        return Ok(None);
190    };
191
192    Ok(Some((symbol, total, results)))
193}
194
195pub fn callers(
196    ctx: &Context,
197    symbol_name: &str,
198    limit: usize,
199    offset: usize,
200    format: Format,
201) -> anyhow::Result<()> {
202    let Some((symbol, total, results)) = read_paged_symbol_graph_results(
203        ctx,
204        symbol_name,
205        limit,
206        offset,
207        format,
208        code_graph::count_callers,
209        code_graph::find_callers,
210    )?
211    else {
212        return Ok(());
213    };
214
215    match format {
216        Format::Json => output::print_json(&PagedResponse {
217            project_id: ctx.project_id.clone(),
218            total,
219            offset,
220            limit,
221            results,
222            hint: hint_for(ctx),
223        }),
224        Format::Text => {
225            if results.is_empty() && offset == 0 {
226                output::print_text(&format!("No callers found for '{}'", symbol.display_name))?;
227                print_graph_hint_text(ctx, None);
228            } else if results.is_empty() {
229                eprintln!("No callers at offset {offset} (total {total})");
230            } else {
231                output::print_text(&format_grouped_graph_results(&results, |r| {
232                    format!("{} {} -> {}", r.line, r.name, symbol.display_name)
233                }))?;
234                if total > offset + results.len() {
235                    eprintln!(
236                        "-- {} of {} results (use --offset {} for more)",
237                        results.len(),
238                        total,
239                        offset + results.len()
240                    );
241                }
242            }
243            Ok(())
244        }
245    }
246}
247
248pub fn usages(
249    ctx: &Context,
250    symbol_name: &str,
251    limit: usize,
252    offset: usize,
253    format: Format,
254) -> anyhow::Result<()> {
255    let Some((symbol, total, results)) = read_paged_symbol_graph_results(
256        ctx,
257        symbol_name,
258        limit,
259        offset,
260        format,
261        code_graph::count_usages,
262        code_graph::find_usages,
263    )?
264    else {
265        return Ok(());
266    };
267
268    match format {
269        Format::Json => output::print_json(&PagedResponse {
270            project_id: ctx.project_id.clone(),
271            total,
272            offset,
273            limit,
274            results,
275            hint: hint_for(ctx),
276        }),
277        Format::Text => {
278            if results.is_empty() && offset == 0 {
279                output::print_text(&format!("No usages found for '{}'", symbol.display_name))?;
280                print_graph_hint_text(ctx, None);
281            } else if results.is_empty() {
282                eprintln!("No usages at offset {offset} (total {total})");
283            } else {
284                output::print_text(&format_grouped_graph_results(&results, |r| {
285                    let rel = r.relation.as_deref().unwrap_or("unknown");
286                    format!("{} [{}] {} -> {}", r.line, rel, r.name, symbol.display_name)
287                }))?;
288                if total > offset + results.len() {
289                    eprintln!(
290                        "-- {} of {} results (use --offset {} for more)",
291                        results.len(),
292                        total,
293                        offset + results.len()
294                    );
295                }
296            }
297            Ok(())
298        }
299    }
300}
301
302pub fn imports(ctx: &Context, file: &str, format: Format) -> anyhow::Result<()> {
303    let Some(()) =
304        graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
305    else {
306        return Ok(());
307    };
308    let Some(results) =
309        graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
310            code_graph::get_imports(ctx, file)
311        })?
312    else {
313        return Ok(());
314    };
315    let total = results.len();
316    match format {
317        Format::Json => output::print_json(&PagedResponse {
318            project_id: ctx.project_id.clone(),
319            total,
320            offset: 0,
321            limit: total,
322            results,
323            hint: hint_for(ctx),
324        }),
325        Format::Text => {
326            if results.is_empty() {
327                output::print_text(&format!("No imports found for '{file}'"))?;
328                print_graph_hint_text(ctx, None);
329            } else {
330                for r in &results {
331                    output::print_text(&r.name)?;
332                }
333            }
334            Ok(())
335        }
336    }
337}
338
339pub fn blast_radius(
340    ctx: &Context,
341    target: &str,
342    depth: usize,
343    format: Format,
344) -> anyhow::Result<()> {
345    let Some(()) =
346        graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
347    else {
348        return Ok(());
349    };
350    let Some(symbol) = resolve_symbol_or_empty_response(ctx, target, 0, 0, format)? else {
351        return Ok(());
352    };
353    let Some(results) =
354        graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
355            code_graph::blast_radius(ctx, &symbol.id, depth)
356        })?
357    else {
358        return Ok(());
359    };
360    let total = results.len();
361    match format {
362        Format::Json => output::print_json(&PagedResponse {
363            project_id: ctx.project_id.clone(),
364            total,
365            offset: 0,
366            limit: total,
367            results,
368            hint: hint_for(ctx),
369        }),
370        Format::Text => {
371            if results.is_empty() {
372                output::print_text(&format!(
373                    "No blast radius found for '{}'",
374                    symbol.display_name
375                ))?;
376                print_graph_hint_text(ctx, None);
377            } else {
378                output::print_text(&format_grouped_graph_results(&results, |r| {
379                    let dist = r.distance.unwrap_or(0);
380                    format!("{} [distance={}] {}", r.line, dist, r.name)
381                }))?;
382            }
383            Ok(())
384        }
385    }
386}