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
120fn 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}