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