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_with_connection(
121 conn: &mut postgres::Client,
122 project_id: &str,
123 input: &str,
124) -> anyhow::Result<(Option<ResolvedGraphSymbol>, Vec<String>)> {
125 if let Ok(symbol_id) = uuid::Uuid::parse_str(input) {
126 return Ok((
127 fts::resolve_graph_symbol_by_id(conn, &symbol_id.to_string(), project_id)?,
128 Vec::new(),
129 ));
130 }
131
132 fts::resolve_graph_symbol(conn, input, project_id)
133}
134
135fn resolve_symbol(ctx: &Context, input: &str) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
138 let mut conn = match db::connect_readonly(&ctx.database_url) {
139 Ok(c) => c,
140 Err(e) => {
141 eprintln!("Failed to open index for graph resolution: {e}");
142 return Ok(None);
143 }
144 };
145 let (resolved, suggestions) =
146 resolve_symbol_with_connection(&mut conn, &ctx.project_id, input)?;
147 if resolved.is_none() {
148 if suggestions.is_empty() {
149 eprintln!("No symbol matching '{input}' found");
150 } else {
151 eprintln!(
152 "Ambiguous symbol '{input}'. Refine the query. Matches: {}",
153 suggestions.join(", ")
154 );
155 }
156 }
157 Ok(resolved)
158}
159
160fn resolve_symbol_or_empty_response(
161 ctx: &Context,
162 input: &str,
163 offset: usize,
164 limit: usize,
165 format: Format,
166) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
167 match resolve_symbol(ctx, input)? {
168 Some(symbol) => Ok(Some(symbol)),
169 None => {
170 empty_paged_response::<crate::models::GraphResult>(ctx, offset, limit, format, None)?;
171 Ok(None)
172 }
173 }
174}
175
176fn read_paged_symbol_graph_results(
177 ctx: &Context,
178 symbol_name: &str,
179 limit: usize,
180 offset: usize,
181 format: Format,
182 count: impl FnOnce(&Context, &str) -> anyhow::Result<usize>,
183 find: impl FnOnce(&Context, &str, usize, usize) -> anyhow::Result<Vec<GraphResult>>,
184) -> anyhow::Result<Option<(ResolvedGraphSymbol, usize, Vec<GraphResult>)>> {
185 let Some(()) = graph_read_or_empty::<()>(ctx, offset, limit, format, || {
186 code_graph::require_graph_reads(ctx)
187 })?
188 else {
189 return Ok(None);
190 };
191 let Some(symbol) = resolve_symbol_or_empty_response(ctx, symbol_name, offset, limit, format)?
192 else {
193 return Ok(None);
194 };
195 let Some(total) =
196 graph_read_or_empty::<usize>(ctx, offset, limit, format, || count(ctx, &symbol.id))?
197 else {
198 return Ok(None);
199 };
200 let Some(results) =
201 graph_read_or_empty::<Vec<GraphResult>>(ctx, offset, limit, format, || {
202 find(ctx, &symbol.id, offset, limit)
203 })?
204 else {
205 return Ok(None);
206 };
207
208 Ok(Some((symbol, total, results)))
209}
210
211pub fn callers(
212 ctx: &Context,
213 symbol_name: &str,
214 limit: usize,
215 offset: usize,
216 format: Format,
217) -> anyhow::Result<()> {
218 let Some((symbol, total, results)) = read_paged_symbol_graph_results(
219 ctx,
220 symbol_name,
221 limit,
222 offset,
223 format,
224 code_graph::count_callers,
225 code_graph::find_callers,
226 )?
227 else {
228 return Ok(());
229 };
230
231 match format {
232 Format::Json => output::print_json(&PagedResponse {
233 project_id: ctx.project_id.clone(),
234 total,
235 offset,
236 limit,
237 results,
238 hint: hint_for(ctx),
239 }),
240 Format::Text => {
241 if results.is_empty() && offset == 0 {
242 output::print_text(&format!("No callers found for '{}'", symbol.display_name))?;
243 print_graph_hint_text(ctx, None);
244 } else if results.is_empty() {
245 eprintln!("No callers at offset {offset} (total {total})");
246 } else {
247 output::print_text(&format_grouped_graph_results(&results, |r| {
248 format!("{} {} -> {}", r.line, r.name, symbol.display_name)
249 }))?;
250 if total > offset + results.len() {
251 eprintln!(
252 "-- {} of {} results (use --offset {} for more)",
253 results.len(),
254 total,
255 offset + results.len()
256 );
257 }
258 }
259 Ok(())
260 }
261 }
262}
263
264pub fn usages(
265 ctx: &Context,
266 symbol_name: &str,
267 limit: usize,
268 offset: usize,
269 format: Format,
270) -> anyhow::Result<()> {
271 let Some((symbol, total, results)) = read_paged_symbol_graph_results(
272 ctx,
273 symbol_name,
274 limit,
275 offset,
276 format,
277 code_graph::count_usages,
278 code_graph::find_usages,
279 )?
280 else {
281 return Ok(());
282 };
283
284 match format {
285 Format::Json => output::print_json(&PagedResponse {
286 project_id: ctx.project_id.clone(),
287 total,
288 offset,
289 limit,
290 results,
291 hint: hint_for(ctx),
292 }),
293 Format::Text => {
294 if results.is_empty() && offset == 0 {
295 output::print_text(&format!("No usages found for '{}'", symbol.display_name))?;
296 print_graph_hint_text(ctx, None);
297 } else if results.is_empty() {
298 eprintln!("No usages at offset {offset} (total {total})");
299 } else {
300 output::print_text(&format_grouped_graph_results(&results, |r| {
301 let rel = r.relation.as_deref().unwrap_or("unknown");
302 format!("{} [{}] {} -> {}", r.line, rel, r.name, symbol.display_name)
303 }))?;
304 if total > offset + results.len() {
305 eprintln!(
306 "-- {} of {} results (use --offset {} for more)",
307 results.len(),
308 total,
309 offset + results.len()
310 );
311 }
312 }
313 Ok(())
314 }
315 }
316}
317
318pub fn imports(ctx: &Context, file: &str, format: Format) -> anyhow::Result<()> {
319 let Some(()) =
320 graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
321 else {
322 return Ok(());
323 };
324 let Some(results) =
325 graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
326 code_graph::get_imports(ctx, file)
327 })?
328 else {
329 return Ok(());
330 };
331 let total = results.len();
332 match format {
333 Format::Json => output::print_json(&PagedResponse {
334 project_id: ctx.project_id.clone(),
335 total,
336 offset: 0,
337 limit: total,
338 results,
339 hint: hint_for(ctx),
340 }),
341 Format::Text => {
342 if results.is_empty() {
343 output::print_text(&format!("No imports found for '{file}'"))?;
344 print_graph_hint_text(ctx, None);
345 } else {
346 for r in &results {
347 output::print_text(&r.name)?;
348 }
349 }
350 Ok(())
351 }
352 }
353}
354
355pub fn blast_radius(
356 ctx: &Context,
357 target: &str,
358 depth: usize,
359 format: Format,
360) -> anyhow::Result<()> {
361 let Some(()) =
362 graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
363 else {
364 return Ok(());
365 };
366 let Some(symbol) = resolve_symbol_or_empty_response(ctx, target, 0, 0, format)? else {
367 return Ok(());
368 };
369 let Some(results) =
370 graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
371 code_graph::blast_radius(ctx, &symbol.id, depth)
372 })?
373 else {
374 return Ok(());
375 };
376 let total = results.len();
377 match format {
378 Format::Json => output::print_json(&PagedResponse {
379 project_id: ctx.project_id.clone(),
380 total,
381 offset: 0,
382 limit: total,
383 results,
384 hint: hint_for(ctx),
385 }),
386 Format::Text => {
387 if results.is_empty() {
388 output::print_text(&format!(
389 "No blast radius found for '{}'",
390 symbol.display_name
391 ))?;
392 print_graph_hint_text(ctx, None);
393 } else {
394 output::print_text(&format_grouped_graph_results(&results, |r| {
395 let dist = r.distance.unwrap_or(0);
396 format!("{} [distance={}] {}", r.line, dist, r.name)
397 }))?;
398 }
399 Ok(())
400 }
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use postgres::Client;
408 use postgres::types::ToSql;
409 use std::time::{SystemTime, UNIX_EPOCH};
410
411 const GRAPH_RESOLUTION_CHILD_TABLES: &[&str] = &[
412 "code_calls",
413 "code_imports",
414 "code_symbols",
415 "code_content_chunks",
416 "code_indexed_files",
417 ];
418
419 fn graph_resolution_database_url() -> Option<String> {
420 std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL").ok()
421 }
422
423 fn connect_graph_resolution_test_db() -> Option<Client> {
424 let database_url = graph_resolution_database_url()?;
425 match gobby_core::postgres::connect_readwrite(&database_url) {
426 Ok(mut conn) => {
427 if let Err(err) = crate::schema::validate_runtime_schema(&mut conn) {
428 eprintln!(
429 "skipping graph resolution test: PostgreSQL schema is invalid: {err}"
430 );
431 return None;
432 }
433 Some(conn)
434 }
435 Err(err) => {
436 eprintln!("skipping graph resolution test: failed to connect PostgreSQL: {err}");
437 None
438 }
439 }
440 }
441
442 fn unique_uuid(label: &str) -> String {
443 let nanos = SystemTime::now()
444 .duration_since(UNIX_EPOCH)
445 .expect("system clock before unix epoch")
446 .as_nanos();
447 let key = format!("graph-resolution-test:{label}:{nanos}");
448 uuid::Uuid::new_v5(&crate::models::CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
449 }
450
451 struct GraphResolutionProjectCleanup {
452 database_url: String,
453 project_id: String,
454 }
455
456 impl GraphResolutionProjectCleanup {
457 fn new(project_id: &str) -> Self {
458 Self {
459 database_url: graph_resolution_database_url()
460 .expect("graph resolution database URL"),
461 project_id: project_id.to_string(),
462 }
463 }
464 }
465
466 impl Drop for GraphResolutionProjectCleanup {
467 fn drop(&mut self) {
468 match gobby_core::postgres::connect_readwrite(&self.database_url) {
469 Ok(mut conn) => {
470 if let Err(error) =
471 try_cleanup_graph_resolution_project(&mut conn, &self.project_id)
472 {
473 eprintln!("graph resolution cleanup failed: {error}");
474 }
475 }
476 Err(error) => eprintln!("graph resolution cleanup connect failed: {error}"),
477 }
478 }
479 }
480
481 fn cleanup_graph_resolution_project(conn: &mut Client, project_id: &str) {
482 try_cleanup_graph_resolution_project(conn, project_id)
483 .expect("cleanup graph resolution project");
484 }
485
486 fn try_cleanup_graph_resolution_project(
487 conn: &mut Client,
488 project_id: &str,
489 ) -> Result<(), postgres::Error> {
490 let mut tx = conn.transaction()?;
491 for table in GRAPH_RESOLUTION_CHILD_TABLES {
492 let sql = format!("DELETE FROM {table} WHERE project_id = $1");
493 tx.execute(&sql, &[&project_id])?;
494 }
495 tx.execute(
496 "DELETE FROM code_indexed_projects WHERE id = $1",
497 &[&project_id],
498 )?;
499 tx.commit()
500 }
501
502 fn insert_project(conn: &mut Client, project_id: &str) {
503 let root_path = format!("/tmp/gcode-graph-resolution-{project_id}");
504 conn.execute(
505 "INSERT INTO code_indexed_projects
506 (id, root_path, total_files, total_symbols, last_indexed_at, index_duration_ms)
507 VALUES ($1, $2, 0, 0, NOW(), 0)",
508 &[&project_id, &root_path],
509 )
510 .expect("insert graph resolution project");
511 }
512
513 fn insert_file(conn: &mut Client, project_id: &str, file_path: &str, symbol_count: i32) {
514 let id = format!("{project_id}:{file_path}");
515 let params: &[&(dyn ToSql + Sync)] = &[&id, &project_id, &file_path, &symbol_count];
516 conn.execute(
517 "INSERT INTO code_indexed_files
518 (id, project_id, file_path, language, content_hash, symbol_count, byte_size,
519 graph_synced, vectors_synced, graph_sync_attempted_at, indexed_at)
520 VALUES ($1, $2, $3, 'rust', 'hash', $4, 1, false, false, NULL, NOW())",
521 params,
522 )
523 .expect("insert graph resolution file");
524 }
525
526 fn insert_symbol(
527 conn: &mut Client,
528 project_id: &str,
529 file_path: &str,
530 id: &str,
531 name: &str,
532 line_start: i32,
533 ) {
534 let params: &[&(dyn ToSql + Sync)] = &[&id, &project_id, &file_path, &name, &line_start];
535 conn.execute(
536 "INSERT INTO code_symbols
537 (id, project_id, file_path, name, qualified_name, kind, language, byte_start,
538 byte_end, line_start, line_end, signature, docstring, parent_symbol_id,
539 content_hash, summary, created_at, updated_at)
540 VALUES ($1, $2, $3, $4, $4, 'function', 'rust', 0, 1, $5, $5, $4, NULL, NULL,
541 'hash', NULL, NOW(), NOW())",
542 params,
543 )
544 .expect("insert graph resolution symbol");
545 }
546
547 mod serial_db {
548 use super::*;
549
550 #[test]
551 #[serial_test::serial(serial_db)]
552 fn uuid_input_resolves_exact_symbol_for_active_project() {
553 let Some(mut conn) = connect_graph_resolution_test_db() else {
554 return;
555 };
556 let project_id = unique_uuid("project");
557 cleanup_graph_resolution_project(&mut conn, &project_id);
558 let _cleanup = GraphResolutionProjectCleanup::new(&project_id);
559 insert_project(&mut conn, &project_id);
560 insert_file(&mut conn, &project_id, "src/target.rs", 1);
561
562 let symbol_id = unique_uuid("target-symbol");
563 insert_symbol(
564 &mut conn,
565 &project_id,
566 "src/target.rs",
567 &symbol_id,
568 "target_symbol",
569 7,
570 );
571
572 let (resolved, suggestions) =
573 resolve_symbol_with_connection(&mut conn, &project_id, &symbol_id)
574 .expect("resolve graph symbol by uuid");
575
576 assert!(suggestions.is_empty());
577 let resolved = resolved.expect("symbol should resolve");
578 assert_eq!(resolved.id, symbol_id);
579 assert_eq!(resolved.display_name, "target_symbol");
580 }
581
582 #[test]
583 #[serial_test::serial(serial_db)]
584 fn unknown_uuid_input_does_not_fall_back_to_name_resolution() {
585 let Some(mut conn) = connect_graph_resolution_test_db() else {
586 return;
587 };
588 let project_id = unique_uuid("project");
589 cleanup_graph_resolution_project(&mut conn, &project_id);
590 let _cleanup = GraphResolutionProjectCleanup::new(&project_id);
591 insert_project(&mut conn, &project_id);
592 insert_file(&mut conn, &project_id, "src/name.rs", 1);
593
594 let uuid_shaped_name = unique_uuid("uuid-shaped-name");
595 insert_symbol(
596 &mut conn,
597 &project_id,
598 "src/name.rs",
599 &unique_uuid("different-symbol-id"),
600 &uuid_shaped_name,
601 3,
602 );
603
604 let (resolved, suggestions) =
605 resolve_symbol_with_connection(&mut conn, &project_id, &uuid_shaped_name)
606 .expect("resolve unknown uuid");
607
608 assert!(resolved.is_none());
609 assert!(suggestions.is_empty());
610 }
611
612 #[test]
613 #[serial_test::serial(serial_db)]
614 fn ambiguous_name_behavior_remains_unchanged() {
615 let Some(mut conn) = connect_graph_resolution_test_db() else {
616 return;
617 };
618 let project_id = unique_uuid("project");
619 cleanup_graph_resolution_project(&mut conn, &project_id);
620 let _cleanup = GraphResolutionProjectCleanup::new(&project_id);
621 insert_project(&mut conn, &project_id);
622 insert_file(&mut conn, &project_id, "src/a.rs", 1);
623 insert_file(&mut conn, &project_id, "src/b.rs", 1);
624
625 insert_symbol(
626 &mut conn,
627 &project_id,
628 "src/a.rs",
629 &unique_uuid("shared-a"),
630 "shared_lookup",
631 10,
632 );
633 insert_symbol(
634 &mut conn,
635 &project_id,
636 "src/b.rs",
637 &unique_uuid("shared-b"),
638 "shared_lookup",
639 20,
640 );
641
642 let (resolved, suggestions) =
643 resolve_symbol_with_connection(&mut conn, &project_id, "shared_lookup")
644 .expect("resolve ambiguous name");
645
646 assert!(resolved.is_none());
647 assert_eq!(suggestions.len(), 2);
648 assert!(suggestions.iter().any(|item| item.contains("src/a.rs:10")));
649 assert!(suggestions.iter().any(|item| item.contains("src/b.rs:20")));
650 }
651 }
652}