Skip to main content

gobby_code/commands/graph/
reads.rs

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
293/// Resolve user input to a canonical symbol id, printing suggestions on ambiguity.
294/// Returns None and prints an error message if no match found.
295fn 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}