1use std::collections::BTreeMap;
2
3use crate::commands::token_budget;
4use crate::config::Context;
5use crate::db;
6use crate::graph::code_graph;
7use crate::models::{GraphPathStep, GraphResult, PagedResponse};
8use crate::output::{self, Format};
9use crate::search::fts::{self, ResolvedGraphSymbol};
10use serde::Serialize;
11
12const GRAPH_BACKEND_HINT: &str =
13 "Graph commands require a configured FalkorDB graph backend and synced graph projection.";
14const USAGES_TOKEN_BUDGET_REFINE_HINT: &str =
15 "`--limit`, `--offset`, or a more specific symbol query";
16const BLAST_RADIUS_TOKEN_BUDGET_REFINE_HINT: &str =
17 "`--depth`, a more specific symbol query, or a symbol UUID";
18
19fn hint_for(ctx: &Context) -> Option<String> {
20 if ctx.falkordb.is_none() {
21 Some(GRAPH_BACKEND_HINT.to_string())
22 } else {
23 None
24 }
25}
26
27fn hint_for_error(ctx: &Context, error: &anyhow::Error) -> Option<String> {
28 match error.downcast_ref::<code_graph::GraphReadError>() {
29 Some(code_graph::GraphReadError::NotConfigured) => hint_for(ctx),
30 Some(code_graph::GraphReadError::Unreachable { message }) => Some(format!(
31 "FalkorDB is configured but unreachable; graph results are unavailable: {message}"
32 )),
33 _ => hint_for(ctx),
34 }
35}
36
37fn print_graph_hint_text(ctx: &Context, error: Option<&anyhow::Error>) {
38 let hint = error.and_then(|err| hint_for_error(ctx, err));
39 let hint = hint.or_else(|| hint_for(ctx));
40 if let Some(hint) = hint {
41 eprintln!("Hint: {hint}");
42 }
43}
44
45fn print_hint_text(hint: Option<&str>) {
46 if let Some(hint) = hint {
47 eprintln!("Hint: {hint}");
48 }
49}
50
51fn graph_read_unavailable(error: &anyhow::Error) -> bool {
52 matches!(
53 error.downcast_ref::<code_graph::GraphReadError>(),
54 Some(
55 code_graph::GraphReadError::NotConfigured
56 | code_graph::GraphReadError::Unreachable { .. }
57 )
58 )
59}
60
61fn empty_paged_response<T: Serialize>(
62 ctx: &Context,
63 offset: usize,
64 limit: usize,
65 format: Format,
66 error: Option<&anyhow::Error>,
67) -> anyhow::Result<()> {
68 match format {
69 Format::Json => output::print_json(&PagedResponse::<T> {
70 project_id: ctx.project_id.clone(),
71 total: 0,
72 offset,
73 limit,
74 results: vec![],
75 hint: error
76 .and_then(|err| hint_for_error(ctx, err))
77 .or_else(|| hint_for(ctx)),
78 }),
79 Format::Text => {
80 print_graph_hint_text(ctx, error);
81 Ok(())
82 }
83 }
84}
85
86fn graph_read_or_empty<T: Serialize>(
87 ctx: &Context,
88 offset: usize,
89 limit: usize,
90 format: Format,
91 read: impl FnOnce() -> anyhow::Result<T>,
92) -> anyhow::Result<Option<T>> {
93 match read() {
94 Ok(value) => Ok(Some(value)),
95 Err(err) if graph_read_unavailable(&err) => {
96 empty_paged_response::<T>(ctx, offset, limit, format, Some(&err))?;
97 Ok(None)
98 }
99 Err(err) => Err(err),
100 }
101}
102
103pub(super) fn format_grouped_graph_results<F>(results: &[GraphResult], format_line: F) -> String
104where
105 F: Fn(&GraphResult) -> String,
106{
107 let mut grouped: BTreeMap<&str, Vec<&GraphResult>> = BTreeMap::new();
108 for result in results {
109 grouped.entry(&result.file_path).or_default().push(result);
110 }
111
112 let mut lines = Vec::new();
113 for (file_path, mut entries) in grouped {
114 lines.push(if file_path.is_empty() {
115 "<unknown>".to_string()
116 } else {
117 file_path.to_string()
118 });
119 entries.sort_by(|a, b| {
120 a.line
121 .cmp(&b.line)
122 .then_with(|| a.name.cmp(&b.name))
123 .then_with(|| a.relation.cmp(&b.relation))
124 .then_with(|| a.distance.cmp(&b.distance))
125 });
126 lines.extend(entries.into_iter().map(&format_line));
127 }
128 lines.join("\n")
129}
130
131pub(super) fn format_caller_result_line(result: &GraphResult, target_name: &str) -> String {
132 format!(
133 "{} [{}] {} -> {}",
134 result.line, result.confidence, result.name, target_name
135 )
136}
137
138pub(super) fn format_usage_result_line(result: &GraphResult, target_name: &str) -> String {
139 let rel = result.relation.as_deref().unwrap_or("unknown");
140 format!(
141 "{} [{}] [{}] {} -> {}",
142 result.line, result.confidence, rel, result.name, target_name
143 )
144}
145
146pub(super) fn format_blast_radius_result_line(result: &GraphResult) -> String {
147 let distance = result.distance.unwrap_or(0);
148 format!(
149 "{} [{}] [distance={}] {}",
150 result.line, result.confidence, distance, result.name
151 )
152}
153
154#[derive(Serialize)]
155struct GraphPathEndpoint {
156 #[serde(skip_serializing_if = "Option::is_none")]
157 id: Option<String>,
158 display_name: String,
159}
160
161#[derive(Serialize)]
162struct GraphPathResponse {
163 project_id: String,
164 found: bool,
165 from: GraphPathEndpoint,
166 to: GraphPathEndpoint,
167 max_depth: usize,
168 hops: Option<usize>,
169 path: Vec<GraphPathStep>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 hint: Option<String>,
172}
173
174fn path_endpoint(input: &str, resolved: Option<&ResolvedGraphSymbol>) -> GraphPathEndpoint {
175 GraphPathEndpoint {
176 id: resolved.map(|symbol| symbol.id.clone()),
177 display_name: resolved
178 .map(|symbol| symbol.display_name.clone())
179 .unwrap_or_else(|| input.to_string()),
180 }
181}
182
183pub(super) fn format_symbol_path_text(
184 from_display: &str,
185 to_display: &str,
186 path: &[GraphPathStep],
187 max_depth: usize,
188) -> String {
189 if path.is_empty() {
190 return format!(
191 "No path found from '{from_display}' to '{to_display}' within {max_depth} CALLS hop(s)."
192 );
193 }
194
195 let hops = path.len().saturating_sub(1);
196 let mut lines = vec![format!(
197 "Shortest path from '{from_display}' to '{to_display}' ({hops} hop(s)):"
198 )];
199 for step in path {
200 let file_path = if step.file_path.is_empty() {
201 "<unknown>"
202 } else {
203 &step.file_path
204 };
205 lines.push(format!(
206 "{}. {} ({}:{})",
207 step.position + 1,
208 step.name,
209 file_path,
210 step.line
211 ));
212 }
213 lines.join("\n")
214}
215
216fn print_symbol_path_response(
217 ctx: &Context,
218 from: GraphPathEndpoint,
219 to: GraphPathEndpoint,
220 max_depth: usize,
221 path: Vec<GraphPathStep>,
222 format: Format,
223) -> anyhow::Result<()> {
224 let found = !path.is_empty();
225 let response = GraphPathResponse {
226 project_id: ctx.project_id.clone(),
227 found,
228 hops: found.then_some(path.len().saturating_sub(1)),
229 from,
230 to,
231 max_depth,
232 path,
233 hint: hint_for(ctx),
234 };
235
236 match format {
237 Format::Json => output::print_json(&response),
238 Format::Text => {
239 output::print_text(&format_symbol_path_text(
240 &response.from.display_name,
241 &response.to.display_name,
242 &response.path,
243 max_depth,
244 ))?;
245 if !response.found {
246 print_graph_hint_text(ctx, None);
247 }
248 Ok(())
249 }
250 }
251}
252
253fn resolve_symbol_with_connection(
254 conn: &mut postgres::Client,
255 project_id: &str,
256 input: &str,
257) -> anyhow::Result<(Option<ResolvedGraphSymbol>, Vec<String>)> {
258 if let Ok(symbol_id) = uuid::Uuid::parse_str(input) {
259 return Ok((
260 fts::resolve_graph_symbol_by_id(conn, &symbol_id.to_string(), project_id)?,
261 Vec::new(),
262 ));
263 }
264
265 fts::resolve_graph_symbol(conn, input, project_id)
266}
267
268fn resolve_symbol_candidates(
269 ctx: &Context,
270 input: &str,
271) -> anyhow::Result<(Option<ResolvedGraphSymbol>, Vec<String>)> {
272 let mut conn = match db::connect_readonly(&ctx.database_url) {
273 Ok(c) => c,
274 Err(e) => {
275 eprintln!("Failed to open index for graph resolution: {e}");
276 return Ok((None, Vec::new()));
277 }
278 };
279 resolve_symbol_with_connection(&mut conn, &ctx.project_id, input)
280}
281
282fn print_symbol_resolution_failure(input: &str, suggestions: &[String]) {
283 if suggestions.is_empty() {
284 eprintln!("No symbol matching '{input}' found");
285 } else {
286 eprintln!(
287 "Ambiguous symbol '{input}'. Refine the query. Matches: {}",
288 suggestions.join(", ")
289 );
290 }
291}
292
293fn resolve_symbol(ctx: &Context, input: &str) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
296 let (resolved, suggestions) = resolve_symbol_candidates(ctx, input)?;
297 if resolved.is_none() {
298 print_symbol_resolution_failure(input, &suggestions);
299 }
300 Ok(resolved)
301}
302
303fn resolve_blast_radius_target(
304 ctx: &Context,
305 input: &str,
306) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
307 let (resolved, suggestions) = resolve_symbol_candidates(ctx, input)?;
308 if let Some(symbol) = resolved {
309 return Ok(Some(symbol));
310 }
311 if !suggestions.is_empty() {
312 print_symbol_resolution_failure(input, &suggestions);
313 return Ok(None);
314 }
315
316 let (external, external_suggestions) = code_graph::resolve_external_call_target(ctx, input)?;
317 if let Some(target) = external {
318 return Ok(Some(ResolvedGraphSymbol {
319 id: target.id,
320 display_name: target.display_name,
321 }));
322 }
323 if external_suggestions.is_empty() {
324 eprintln!("No symbol or external call target matching '{input}' found");
325 } else {
326 eprintln!(
327 "Ambiguous external call target '{input}'. Refine the query. Matches: {}",
328 external_suggestions.join(", ")
329 );
330 }
331 Ok(None)
332}
333
334fn resolve_symbol_or_empty_response(
335 ctx: &Context,
336 input: &str,
337 offset: usize,
338 limit: usize,
339 format: Format,
340) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
341 match resolve_symbol(ctx, input)? {
342 Some(symbol) => Ok(Some(symbol)),
343 None => {
344 empty_paged_response::<crate::models::GraphResult>(ctx, offset, limit, format, None)?;
345 Ok(None)
346 }
347 }
348}
349
350fn read_paged_symbol_graph_results(
351 ctx: &Context,
352 symbol_name: &str,
353 limit: usize,
354 offset: usize,
355 format: Format,
356 count: impl FnOnce(&Context, &str) -> anyhow::Result<usize>,
357 find: impl FnOnce(&Context, &str, usize, usize) -> anyhow::Result<Vec<GraphResult>>,
358) -> anyhow::Result<Option<(ResolvedGraphSymbol, usize, Vec<GraphResult>)>> {
359 let Some(()) = graph_read_or_empty::<()>(ctx, offset, limit, format, || {
360 code_graph::require_graph_reads(ctx)
361 })?
362 else {
363 return Ok(None);
364 };
365 let Some(symbol) = resolve_symbol_or_empty_response(ctx, symbol_name, offset, limit, format)?
366 else {
367 return Ok(None);
368 };
369 let Some(total) =
370 graph_read_or_empty::<usize>(ctx, offset, limit, format, || count(ctx, &symbol.id))?
371 else {
372 return Ok(None);
373 };
374 let Some(results) =
375 graph_read_or_empty::<Vec<GraphResult>>(ctx, offset, limit, format, || {
376 find(ctx, &symbol.id, offset, limit)
377 })?
378 else {
379 return Ok(None);
380 };
381
382 Ok(Some((symbol, total, results)))
383}
384
385pub fn callers(
386 ctx: &Context,
387 symbol_name: &str,
388 limit: usize,
389 offset: usize,
390 format: Format,
391) -> anyhow::Result<()> {
392 let Some((symbol, total, results)) = read_paged_symbol_graph_results(
393 ctx,
394 symbol_name,
395 limit,
396 offset,
397 format,
398 code_graph::count_callers,
399 code_graph::find_callers,
400 )?
401 else {
402 return Ok(());
403 };
404
405 match format {
406 Format::Json => output::print_json(&PagedResponse {
407 project_id: ctx.project_id.clone(),
408 total,
409 offset,
410 limit,
411 results,
412 hint: hint_for(ctx),
413 }),
414 Format::Text => {
415 if results.is_empty() && offset == 0 {
416 output::print_text(&format!("No callers found for '{}'", symbol.display_name))?;
417 print_graph_hint_text(ctx, None);
418 } else if results.is_empty() {
419 eprintln!("No callers at offset {offset} (total {total})");
420 } else {
421 output::print_text(&format_grouped_graph_results(&results, |r| {
422 format_caller_result_line(r, &symbol.display_name)
423 }))?;
424 if total > offset + results.len() {
425 eprintln!(
426 "-- {} of {} results (use --offset {} for more)",
427 results.len(),
428 total,
429 offset + results.len()
430 );
431 }
432 }
433 Ok(())
434 }
435 }
436}
437
438pub fn usages(
439 ctx: &Context,
440 symbol_name: &str,
441 limit: usize,
442 offset: usize,
443 token_budget: Option<usize>,
444 format: Format,
445) -> anyhow::Result<()> {
446 let Some((symbol, total, results)) = read_paged_symbol_graph_results(
447 ctx,
448 symbol_name,
449 limit,
450 offset,
451 format,
452 code_graph::count_usages,
453 code_graph::find_usages,
454 )?
455 else {
456 return Ok(());
457 };
458 let unbudgeted_result_count = results.len();
459 let budgeted = token_budget::trim_results(
460 results,
461 token_budget,
462 USAGES_TOKEN_BUDGET_REFINE_HINT,
463 |result| format_usage_result_line(result, &symbol.display_name),
464 );
465 let results = budgeted.results;
466 let hint = token_budget::combine_hints(hint_for(ctx), budgeted.hint);
467
468 match format {
469 Format::Json => output::print_json(&PagedResponse {
470 project_id: ctx.project_id.clone(),
471 total,
472 offset,
473 limit,
474 results,
475 hint,
476 }),
477 Format::Text => {
478 if unbudgeted_result_count == 0 && offset == 0 {
479 output::print_text(&format!("No usages found for '{}'", symbol.display_name))?;
480 print_graph_hint_text(ctx, None);
481 } else if unbudgeted_result_count == 0 {
482 eprintln!("No usages at offset {offset} (total {total})");
483 } else if results.is_empty() {
484 print_hint_text(hint.as_deref());
485 } else {
486 output::print_text(&format_grouped_graph_results(&results, |r| {
487 format_usage_result_line(r, &symbol.display_name)
488 }))?;
489 print_hint_text(hint.as_deref());
490 if total > offset + results.len() {
491 eprintln!(
492 "-- {} of {} results (use --offset {} for more)",
493 results.len(),
494 total,
495 offset + results.len()
496 );
497 }
498 }
499 Ok(())
500 }
501 }
502}
503
504pub fn imports(ctx: &Context, file: &str, format: Format) -> anyhow::Result<()> {
505 let Some(()) =
506 graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
507 else {
508 return Ok(());
509 };
510 let Some(results) =
511 graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
512 code_graph::get_imports(ctx, file)
513 })?
514 else {
515 return Ok(());
516 };
517 let total = results.len();
518 match format {
519 Format::Json => output::print_json(&PagedResponse {
520 project_id: ctx.project_id.clone(),
521 total,
522 offset: 0,
523 limit: total,
524 results,
525 hint: hint_for(ctx),
526 }),
527 Format::Text => {
528 if results.is_empty() {
529 output::print_text(&format!("No imports found for '{file}'"))?;
530 print_graph_hint_text(ctx, None);
531 } else {
532 for r in &results {
533 output::print_text(&r.name)?;
534 }
535 }
536 Ok(())
537 }
538 }
539}
540
541pub fn path(
542 ctx: &Context,
543 symbol_a: &str,
544 symbol_b: &str,
545 max_depth: usize,
546 format: Format,
547) -> anyhow::Result<()> {
548 let from = resolve_symbol(ctx, symbol_a)?;
549 let to = resolve_symbol(ctx, symbol_b)?;
550 let from_endpoint = path_endpoint(symbol_a, from.as_ref());
551 let to_endpoint = path_endpoint(symbol_b, to.as_ref());
552 let max_depth = max_depth.clamp(1, code_graph::MAX_SYMBOL_PATH_DEPTH);
553
554 let path = match (&from, &to) {
555 (Some(from), Some(to)) => {
556 code_graph::shortest_symbol_path(ctx, &from.id, &to.id, max_depth)?
557 }
558 _ => Vec::new(),
559 };
560
561 print_symbol_path_response(ctx, from_endpoint, to_endpoint, max_depth, path, format)
562}
563
564pub fn blast_radius(
565 ctx: &Context,
566 target: &str,
567 depth: usize,
568 token_budget: Option<usize>,
569 format: Format,
570) -> anyhow::Result<()> {
571 let Some(()) =
572 graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
573 else {
574 return Ok(());
575 };
576 let Some(symbol) = resolve_blast_radius_target(ctx, target)? else {
577 empty_paged_response::<crate::models::GraphResult>(ctx, 0, 0, format, None)?;
578 return Ok(());
579 };
580 let Some(results) =
581 graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
582 code_graph::blast_radius(ctx, &symbol.id, depth)
583 })?
584 else {
585 return Ok(());
586 };
587 let total = results.len();
588 let budgeted = token_budget::trim_results(
589 results,
590 token_budget,
591 BLAST_RADIUS_TOKEN_BUDGET_REFINE_HINT,
592 format_blast_radius_result_line,
593 );
594 let results = budgeted.results;
595 let hint = token_budget::combine_hints(hint_for(ctx), budgeted.hint);
596 match format {
597 Format::Json => output::print_json(&PagedResponse {
598 project_id: ctx.project_id.clone(),
599 total,
600 offset: 0,
601 limit: total,
602 results,
603 hint,
604 }),
605 Format::Text => {
606 if total == 0 {
607 output::print_text(&format!(
608 "No blast radius found for '{}'",
609 symbol.display_name
610 ))?;
611 print_graph_hint_text(ctx, None);
612 } else if results.is_empty() {
613 print_hint_text(hint.as_deref());
614 } else {
615 output::print_text(&format_grouped_graph_results(&results, |r| {
616 format_blast_radius_result_line(r)
617 }))?;
618 print_hint_text(hint.as_deref());
619 }
620 Ok(())
621 }
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use postgres::Client;
629 use postgres::types::ToSql;
630 use std::time::{SystemTime, UNIX_EPOCH};
631
632 const GRAPH_RESOLUTION_CHILD_TABLES: &[&str] = &[
633 "code_calls",
634 "code_imports",
635 "code_symbols",
636 "code_content_chunks",
637 "code_indexed_files",
638 ];
639
640 fn graph_resolution_database_url() -> String {
641 crate::test_env::postgres_test_database_url("graph resolution tests")
642 }
643
644 fn connect_graph_resolution_test_db() -> Client {
645 let database_url = graph_resolution_database_url();
646 let mut conn = gobby_core::postgres::connect_readwrite(&database_url)
647 .expect("connect graph resolution PostgreSQL test database");
648 crate::schema::validate_runtime_schema(&mut conn)
649 .expect("graph resolution PostgreSQL test schema is valid");
650 conn
651 }
652
653 fn unique_uuid(label: &str) -> String {
654 let nanos = SystemTime::now()
655 .duration_since(UNIX_EPOCH)
656 .expect("system clock before unix epoch")
657 .as_nanos();
658 let key = format!("graph-resolution-test:{label}:{nanos}");
659 uuid::Uuid::new_v5(&crate::models::CODE_INDEX_UUID_NAMESPACE, key.as_bytes()).to_string()
660 }
661
662 struct GraphResolutionProjectCleanup {
663 database_url: String,
664 project_id: String,
665 }
666
667 impl GraphResolutionProjectCleanup {
668 fn new(project_id: &str) -> Self {
669 Self {
670 database_url: graph_resolution_database_url(),
671 project_id: project_id.to_string(),
672 }
673 }
674 }
675
676 impl Drop for GraphResolutionProjectCleanup {
677 fn drop(&mut self) {
678 match gobby_core::postgres::connect_readwrite(&self.database_url) {
679 Ok(mut conn) => {
680 if let Err(error) =
681 try_cleanup_graph_resolution_project(&mut conn, &self.project_id)
682 {
683 eprintln!("graph resolution cleanup failed: {error}");
684 }
685 }
686 Err(error) => eprintln!("graph resolution cleanup connect failed: {error}"),
687 }
688 }
689 }
690
691 fn cleanup_graph_resolution_project(conn: &mut Client, project_id: &str) {
692 try_cleanup_graph_resolution_project(conn, project_id)
693 .expect("cleanup graph resolution project");
694 }
695
696 fn try_cleanup_graph_resolution_project(
697 conn: &mut Client,
698 project_id: &str,
699 ) -> Result<(), postgres::Error> {
700 let mut tx = conn.transaction()?;
701 for table in GRAPH_RESOLUTION_CHILD_TABLES {
702 let sql = format!("DELETE FROM {table} WHERE project_id = $1");
703 tx.execute(&sql, &[&project_id])?;
704 }
705 tx.execute(
706 "DELETE FROM code_indexed_projects WHERE id = $1",
707 &[&project_id],
708 )?;
709 tx.commit()
710 }
711
712 fn insert_project(conn: &mut Client, project_id: &str) {
713 let root_path = format!("/tmp/gcode-graph-resolution-{project_id}");
714 conn.execute(
715 "INSERT INTO code_indexed_projects
716 (id, root_path, total_files, total_symbols, last_indexed_at, index_duration_ms)
717 VALUES ($1, $2, 0, 0, NOW(), 0)",
718 &[&project_id, &root_path],
719 )
720 .expect("insert graph resolution project");
721 }
722
723 fn insert_file(conn: &mut Client, project_id: &str, file_path: &str, symbol_count: i32) {
724 let id = format!("{project_id}:{file_path}");
725 let params: &[&(dyn ToSql + Sync)] = &[&id, &project_id, &file_path, &symbol_count];
726 conn.execute(
727 "INSERT INTO code_indexed_files
728 (id, project_id, file_path, language, content_hash, symbol_count, byte_size,
729 graph_synced, vectors_synced, graph_sync_attempted_at, indexed_at)
730 VALUES ($1, $2, $3, 'rust', 'hash', $4, 1, false, false, NULL, NOW())",
731 params,
732 )
733 .expect("insert graph resolution file");
734 }
735
736 fn insert_symbol(
737 conn: &mut Client,
738 project_id: &str,
739 file_path: &str,
740 id: &str,
741 name: &str,
742 line_start: i32,
743 ) {
744 let params: &[&(dyn ToSql + Sync)] = &[&id, &project_id, &file_path, &name, &line_start];
745 conn.execute(
746 "INSERT INTO code_symbols
747 (id, project_id, file_path, name, qualified_name, kind, language, byte_start,
748 byte_end, line_start, line_end, signature, docstring, parent_symbol_id,
749 content_hash, summary, created_at, updated_at)
750 VALUES ($1, $2, $3, $4, $4, 'function', 'rust', 0, 1, $5, $5, $4, NULL, NULL,
751 'hash', NULL, NOW(), NOW())",
752 params,
753 )
754 .expect("insert graph resolution symbol");
755 }
756
757 mod serial_db {
758 use super::*;
759
760 #[test]
761 #[cfg_attr(
762 not(gcode_postgres_tests),
763 ignore = "requires a PostgreSQL test database URL"
764 )]
765 #[serial_test::serial(serial_db)]
766 fn uuid_input_resolves_exact_symbol_for_active_project() {
767 let mut conn = connect_graph_resolution_test_db();
768 let project_id = unique_uuid("project");
769 cleanup_graph_resolution_project(&mut conn, &project_id);
770 let _cleanup = GraphResolutionProjectCleanup::new(&project_id);
771 insert_project(&mut conn, &project_id);
772 insert_file(&mut conn, &project_id, "src/target.rs", 1);
773
774 let symbol_id = unique_uuid("target-symbol");
775 insert_symbol(
776 &mut conn,
777 &project_id,
778 "src/target.rs",
779 &symbol_id,
780 "target_symbol",
781 7,
782 );
783
784 let (resolved, suggestions) =
785 resolve_symbol_with_connection(&mut conn, &project_id, &symbol_id)
786 .expect("resolve graph symbol by uuid");
787
788 assert!(suggestions.is_empty());
789 let resolved = resolved.expect("symbol should resolve");
790 assert_eq!(resolved.id, symbol_id);
791 assert_eq!(resolved.display_name, "target_symbol");
792 }
793
794 #[test]
795 #[cfg_attr(
796 not(gcode_postgres_tests),
797 ignore = "requires a PostgreSQL test database URL"
798 )]
799 #[serial_test::serial(serial_db)]
800 fn unknown_uuid_input_does_not_fall_back_to_name_resolution() {
801 let mut conn = connect_graph_resolution_test_db();
802 let project_id = unique_uuid("project");
803 cleanup_graph_resolution_project(&mut conn, &project_id);
804 let _cleanup = GraphResolutionProjectCleanup::new(&project_id);
805 insert_project(&mut conn, &project_id);
806 insert_file(&mut conn, &project_id, "src/name.rs", 1);
807
808 let uuid_shaped_name = unique_uuid("uuid-shaped-name");
809 insert_symbol(
810 &mut conn,
811 &project_id,
812 "src/name.rs",
813 &unique_uuid("different-symbol-id"),
814 &uuid_shaped_name,
815 3,
816 );
817
818 let (resolved, suggestions) =
819 resolve_symbol_with_connection(&mut conn, &project_id, &uuid_shaped_name)
820 .expect("resolve unknown uuid");
821
822 assert!(resolved.is_none());
823 assert!(suggestions.is_empty());
824 }
825
826 #[test]
827 #[cfg_attr(
828 not(gcode_postgres_tests),
829 ignore = "requires a PostgreSQL test database URL"
830 )]
831 #[serial_test::serial(serial_db)]
832 fn ambiguous_name_behavior_remains_unchanged() {
833 let mut conn = connect_graph_resolution_test_db();
834 let project_id = unique_uuid("project");
835 cleanup_graph_resolution_project(&mut conn, &project_id);
836 let _cleanup = GraphResolutionProjectCleanup::new(&project_id);
837 insert_project(&mut conn, &project_id);
838 insert_file(&mut conn, &project_id, "src/a.rs", 1);
839 insert_file(&mut conn, &project_id, "src/b.rs", 1);
840
841 insert_symbol(
842 &mut conn,
843 &project_id,
844 "src/a.rs",
845 &unique_uuid("shared-a"),
846 "shared_lookup",
847 10,
848 );
849 insert_symbol(
850 &mut conn,
851 &project_id,
852 "src/b.rs",
853 &unique_uuid("shared-b"),
854 "shared_lookup",
855 20,
856 );
857
858 let (resolved, suggestions) =
859 resolve_symbol_with_connection(&mut conn, &project_id, "shared_lookup")
860 .expect("resolve ambiguous name");
861
862 assert!(resolved.is_none());
863 assert_eq!(suggestions.len(), 2);
864 assert!(suggestions.iter().any(|item| item.contains("src/a.rs:10")));
865 assert!(suggestions.iter().any(|item| item.contains("src/b.rs:20")));
866 }
867 }
868}