Skip to main content

gobby_code/commands/
graph.rs

1use crate::config::Context;
2use crate::db;
3use crate::graph::code_graph::{
4    self, GraphBlastRadiusTarget, GraphLifecycleAction, GraphLifecycleOutput, GraphPayload,
5};
6use crate::graph::report::{ProjectGraphReport, ProjectGraphReportOptions};
7use crate::models::PagedResponse;
8use crate::output::{self, Format};
9use crate::projection::sync::ProjectionSyncReport;
10use crate::search::fts::{self, ResolvedGraphSymbol};
11use serde_json::{Value, json};
12
13const GOBBY_HINT: &str =
14    "Graph commands require FalkorDB, available with Gobby. See: https://github.com/GobbyAI/gobby";
15const DEFAULT_OVERVIEW_LIMIT: usize = 100;
16
17fn format_success_text(output: &GraphLifecycleOutput) -> String {
18    format!(
19        "{} for project {}: {}",
20        output.action.success_prefix(),
21        output.project_id,
22        output.summary
23    )
24}
25
26fn run_lifecycle_action(
27    ctx: &Context,
28    action: GraphLifecycleAction,
29    format: Format,
30) -> anyhow::Result<()> {
31    let output = match action {
32        GraphLifecycleAction::Clear => clear_project_graph(ctx)?,
33        GraphLifecycleAction::Rebuild => rebuild_project_graph(ctx)?,
34    };
35    match format {
36        Format::Json => output::print_json(&output.payload),
37        Format::Text => {
38            output::print_text(&format_success_text(&output))?;
39            output::print_json_compact(&output.payload)
40        }
41    }
42}
43
44fn lifecycle_output(
45    action: GraphLifecycleAction,
46    ctx: &Context,
47    payload: Value,
48) -> GraphLifecycleOutput {
49    let summary = code_graph::extract_summary_text(&payload).unwrap_or_else(|| payload.to_string());
50    GraphLifecycleOutput {
51        project_id: ctx.project_id.clone(),
52        action,
53        summary,
54        payload,
55    }
56}
57
58struct GraphFileSyncOutcome {
59    relationships_written: usize,
60    symbols_synced: usize,
61}
62
63fn sync_file_graph(ctx: &Context, file_path: &str) -> anyhow::Result<GraphFileSyncOutcome> {
64    code_graph::require_graph_reads(ctx)?;
65    let mut conn = db::connect_readwrite(&ctx.database_url)?;
66    let facts = db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
67    if !db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path)? {
68        anyhow::bail!(
69            "indexed file `{file_path}` was not found for project {}",
70            ctx.project_id
71        );
72    }
73    let relationships_written = code_graph::sync_file_graph(
74        ctx,
75        &facts.file_path,
76        &facts.imports,
77        &facts.definitions,
78        &facts.calls,
79    )?;
80    db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
81    Ok(GraphFileSyncOutcome {
82        relationships_written,
83        symbols_synced: facts.definitions.len(),
84    })
85}
86
87fn clear_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
88    code_graph::require_graph_reads(ctx)?;
89    let mut conn = db::connect_readwrite(&ctx.database_url)?;
90    let files_marked_pending = db::reset_graph_sync_for_project(&mut conn, &ctx.project_id)?;
91    code_graph::clear_project(ctx)?;
92    let report = ProjectionSyncReport::ok(0, 0);
93    Ok(lifecycle_output(
94        GraphLifecycleAction::Clear,
95        ctx,
96        json!({
97            "success": true,
98            "project_id": ctx.project_id,
99            "status": report.status,
100            "synced_files": report.synced_files,
101            "synced_symbols": report.synced_symbols,
102            "degraded": report.degraded,
103            "error": report.error,
104            "files_marked_pending": files_marked_pending,
105            "summary": format!("marked {files_marked_pending} files pending and cleared graph projection"),
106        }),
107    ))
108}
109
110fn rebuild_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
111    code_graph::require_graph_reads(ctx)?;
112    let mut conn = db::connect_readwrite(&ctx.database_url)?;
113    let file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;
114    code_graph::clear_project(ctx)?;
115    db::reset_graph_sync_for_project(&mut conn, &ctx.project_id)?;
116
117    let mut files_synced = 0usize;
118    let mut symbols_synced = 0usize;
119    let mut errors = Vec::new();
120    for file_path in &file_paths {
121        let synced_symbols =
122            match db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path)
123                .and_then(|updated| {
124                    if updated {
125                        Ok(())
126                    } else {
127                        anyhow::bail!("indexed file no longer exists")
128                    }
129                })
130                .and_then(|_| {
131                    let facts = db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
132                    code_graph::sync_file_graph(
133                        ctx,
134                        &facts.file_path,
135                        &facts.imports,
136                        &facts.definitions,
137                        &facts.calls,
138                    )?;
139                    db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
140                    Ok(facts.definitions.len())
141                }) {
142                Ok(symbols) => symbols,
143                Err(err) => {
144                    errors.push(format!("{file_path}: {err}"));
145                    continue;
146                }
147            };
148        files_synced += 1;
149        symbols_synced += synced_symbols;
150    }
151
152    let report = if errors.is_empty() {
153        ProjectionSyncReport::ok(files_synced, symbols_synced)
154    } else {
155        ProjectionSyncReport::degraded(
156            "sync_failed",
157            errors.join("; "),
158            files_synced,
159            symbols_synced,
160        )
161    };
162    Ok(lifecycle_output(
163        GraphLifecycleAction::Rebuild,
164        ctx,
165        json!({
166            "success": true,
167            "project_id": ctx.project_id,
168            "status": report.status,
169            "synced_files": report.synced_files,
170            "synced_symbols": report.synced_symbols,
171            "degraded": report.degraded,
172            "error": report.error,
173            "files_processed": file_paths.len(),
174            "files_synced": files_synced,
175            "files_failed": errors.len(),
176            "errors": errors,
177            "summary": format!("synced {files_synced}/{} files", file_paths.len()),
178        }),
179    ))
180}
181
182pub fn clear(ctx: &Context, format: Format) -> anyhow::Result<()> {
183    run_lifecycle_action(ctx, GraphLifecycleAction::Clear, format)
184}
185
186pub fn rebuild(ctx: &Context, format: Format) -> anyhow::Result<()> {
187    run_lifecycle_action(ctx, GraphLifecycleAction::Rebuild, format)
188}
189
190pub fn sync_file(ctx: &Context, file_path: &str, format: Format) -> anyhow::Result<()> {
191    let sync = sync_file_graph(ctx, file_path)?;
192    let relationships_written = sync.relationships_written;
193    let report = ProjectionSyncReport::ok(1, sync.symbols_synced);
194    let summary = format!("synced {relationships_written} graph relationships for {file_path}");
195    let payload = json!({
196        "success": true,
197        "project_id": ctx.project_id,
198        "file_path": file_path,
199        "status": report.status,
200        "synced_files": report.synced_files,
201        "synced_symbols": report.synced_symbols,
202        "degraded": report.degraded,
203        "error": report.error,
204        "relationships_written": relationships_written,
205        "summary": summary,
206    });
207    match format {
208        Format::Json => output::print_json(&payload),
209        Format::Text => {
210            output::print_text(&format!(
211                "Synced code-index graph for project {}: {summary}",
212                ctx.project_id
213            ))?;
214            output::print_json_compact(&payload)
215        }
216    }
217}
218
219fn format_graph_payload_text(payload: &GraphPayload) -> String {
220    let mut lines = Vec::new();
221    lines.push(format!(
222        "nodes: {}, links: {}",
223        payload.nodes.len(),
224        payload.links.len()
225    ));
226    if let Some(center) = &payload.center {
227        lines.push(format!("center: {center}"));
228    }
229    for node in &payload.nodes {
230        let file = node.file_path.as_deref().unwrap_or("");
231        if file.is_empty() {
232            lines.push(format!(
233                "node {} [{}] {}",
234                node.id, node.node_type, node.name
235            ));
236        } else {
237            lines.push(format!(
238                "node {} [{}] {} {}",
239                node.id, node.node_type, node.name, file
240            ));
241        }
242    }
243    for link in &payload.links {
244        lines.push(format!(
245            "link {} -[{}]-> {}",
246            link.source, link.link_type, link.target
247        ));
248    }
249    lines.join("\n")
250}
251
252fn print_graph_payload(payload: &GraphPayload, format: Format) -> anyhow::Result<()> {
253    match format {
254        Format::Json => output::print_json(payload),
255        Format::Text => output::print_text(&format_graph_payload_text(payload)),
256    }
257}
258
259fn format_report_text(report: &ProjectGraphReport) -> anyhow::Result<String> {
260    Ok(serde_json::to_string_pretty(report)?)
261}
262
263pub fn report(ctx: &Context, top_n: usize, format: Format) -> anyhow::Result<()> {
264    let report = crate::graph::report::generate_report_with_options(
265        ctx,
266        ProjectGraphReportOptions { top_n },
267    )?;
268    match format {
269        Format::Json => output::print_json(&report),
270        Format::Text => output::print_text(&format_report_text(&report)?),
271    }
272}
273
274pub fn overview(ctx: &Context, format: Format) -> anyhow::Result<()> {
275    let payload = code_graph::project_overview_graph(ctx, DEFAULT_OVERVIEW_LIMIT)?;
276    print_graph_payload(&payload, format)
277}
278
279pub fn file(ctx: &Context, file_path: &str, format: Format) -> anyhow::Result<()> {
280    let payload = code_graph::file_graph(ctx, file_path)?;
281    print_graph_payload(&payload, format)
282}
283
284pub fn neighbors(
285    ctx: &Context,
286    symbol_id: &str,
287    limit: usize,
288    format: Format,
289) -> anyhow::Result<()> {
290    let payload = code_graph::symbol_neighbors(ctx, symbol_id, limit)?;
291    print_graph_payload(&payload, format)
292}
293
294pub fn graph_blast_radius(
295    ctx: &Context,
296    symbol_id: Option<&str>,
297    file_path: Option<&str>,
298    depth: usize,
299    limit: usize,
300    format: Format,
301) -> anyhow::Result<()> {
302    let target = match (symbol_id, file_path) {
303        (Some(symbol_id), None) => GraphBlastRadiusTarget::SymbolId(symbol_id.to_string()),
304        (None, Some(file_path)) => GraphBlastRadiusTarget::FilePath(file_path.to_string()),
305        _ => anyhow::bail!("provide exactly one of --symbol-id or --file"),
306    };
307    let payload = code_graph::blast_radius_graph(ctx, target, depth, limit)?;
308    print_graph_payload(&payload, format)
309}
310
311fn hint_for(ctx: &Context) -> Option<String> {
312    if ctx.falkordb.is_none() {
313        Some(GOBBY_HINT.to_string())
314    } else {
315        None
316    }
317}
318
319fn print_graph_hint_text(ctx: &Context) {
320    if ctx.falkordb.is_none() {
321        eprintln!("Hint: {GOBBY_HINT}");
322    }
323}
324
325fn empty_response_for_unresolved(ctx: &Context, format: Format) -> anyhow::Result<()> {
326    match format {
327        Format::Json => output::print_json(&PagedResponse::<Vec<()>> {
328            project_id: ctx.project_id.clone(),
329            total: 0,
330            offset: 0,
331            limit: 0,
332            results: vec![],
333            hint: hint_for(ctx),
334        }),
335        Format::Text => Ok(()),
336    }
337}
338
339/// Resolve user input to a canonical symbol id, printing suggestions on ambiguity.
340/// Returns None and prints an error message if no match found.
341fn resolve_symbol(ctx: &Context, input: &str) -> Option<ResolvedGraphSymbol> {
342    let mut conn = match db::connect_readonly(&ctx.database_url) {
343        Ok(c) => c,
344        Err(e) => {
345            eprintln!("Failed to open index for graph resolution: {e}");
346            return None;
347        }
348    };
349    let (resolved, suggestions) = fts::resolve_graph_symbol(&mut conn, input, &ctx.project_id);
350    if resolved.is_none() {
351        if suggestions.is_empty() {
352            eprintln!("No symbol matching '{input}' found");
353        } else {
354            eprintln!(
355                "Ambiguous symbol '{input}'. Refine the query. Matches: {}",
356                suggestions.join(", ")
357            );
358        }
359    }
360    resolved
361}
362
363pub fn callers(
364    ctx: &Context,
365    symbol_name: &str,
366    limit: usize,
367    offset: usize,
368    format: Format,
369) -> anyhow::Result<()> {
370    code_graph::require_graph_reads(ctx)?;
371    let symbol = match resolve_symbol(ctx, symbol_name) {
372        Some(symbol) => symbol,
373        None => return empty_response_for_unresolved(ctx, format),
374    };
375    let total = code_graph::count_callers(ctx, &symbol.id)?;
376    let results = code_graph::find_callers(ctx, &symbol.id, offset, limit)?;
377
378    match format {
379        Format::Json => output::print_json(&PagedResponse {
380            project_id: ctx.project_id.clone(),
381            total,
382            offset,
383            limit,
384            results,
385            hint: hint_for(ctx),
386        }),
387        Format::Text => {
388            if results.is_empty() && offset == 0 {
389                println!("No callers found for '{}'", symbol.display_name);
390                print_graph_hint_text(ctx);
391            } else if results.is_empty() {
392                eprintln!("No callers at offset {offset} (total {total})");
393            } else {
394                for r in &results {
395                    println!(
396                        "{}:{} {} -> {}",
397                        r.file_path, r.line, r.name, symbol.display_name
398                    );
399                }
400                if total > offset + results.len() {
401                    eprintln!(
402                        "-- {} of {} results (use --offset {} for more)",
403                        results.len(),
404                        total,
405                        offset + results.len()
406                    );
407                }
408            }
409            Ok(())
410        }
411    }
412}
413
414pub fn usages(
415    ctx: &Context,
416    symbol_name: &str,
417    limit: usize,
418    offset: usize,
419    format: Format,
420) -> anyhow::Result<()> {
421    code_graph::require_graph_reads(ctx)?;
422    let symbol = match resolve_symbol(ctx, symbol_name) {
423        Some(symbol) => symbol,
424        None => return empty_response_for_unresolved(ctx, format),
425    };
426    let total = code_graph::count_usages(ctx, &symbol.id)?;
427    let results = code_graph::find_usages(ctx, &symbol.id, offset, limit)?;
428
429    match format {
430        Format::Json => output::print_json(&PagedResponse {
431            project_id: ctx.project_id.clone(),
432            total,
433            offset,
434            limit,
435            results,
436            hint: hint_for(ctx),
437        }),
438        Format::Text => {
439            if results.is_empty() && offset == 0 {
440                println!("No usages found for '{}'", symbol.display_name);
441                print_graph_hint_text(ctx);
442            } else if results.is_empty() {
443                eprintln!("No usages at offset {offset} (total {total})");
444            } else {
445                for r in &results {
446                    let rel = r.relation.as_deref().unwrap_or("unknown");
447                    println!(
448                        "{}:{} [{}] {} -> {}",
449                        r.file_path, r.line, rel, r.name, symbol.display_name
450                    );
451                }
452                if total > offset + results.len() {
453                    eprintln!(
454                        "-- {} of {} results (use --offset {} for more)",
455                        results.len(),
456                        total,
457                        offset + results.len()
458                    );
459                }
460            }
461            Ok(())
462        }
463    }
464}
465
466pub fn imports(ctx: &Context, file: &str, format: Format) -> anyhow::Result<()> {
467    code_graph::require_graph_reads(ctx)?;
468    let results = code_graph::get_imports(ctx, file)?;
469    let total = results.len();
470    match format {
471        Format::Json => output::print_json(&PagedResponse {
472            project_id: ctx.project_id.clone(),
473            total,
474            offset: 0,
475            limit: total,
476            results,
477            hint: hint_for(ctx),
478        }),
479        Format::Text => {
480            if results.is_empty() {
481                println!("No imports found for '{file}'");
482                print_graph_hint_text(ctx);
483            } else {
484                for r in &results {
485                    println!("{}", r.name);
486                }
487            }
488            Ok(())
489        }
490    }
491}
492
493pub fn blast_radius(
494    ctx: &Context,
495    target: &str,
496    depth: usize,
497    format: Format,
498) -> anyhow::Result<()> {
499    code_graph::require_graph_reads(ctx)?;
500    let symbol = match resolve_symbol(ctx, target) {
501        Some(symbol) => symbol,
502        None => return empty_response_for_unresolved(ctx, format),
503    };
504    let results = code_graph::blast_radius(ctx, &symbol.id, depth)?;
505    let total = results.len();
506    match format {
507        Format::Json => output::print_json(&PagedResponse {
508            project_id: ctx.project_id.clone(),
509            total,
510            offset: 0,
511            limit: total,
512            results,
513            hint: hint_for(ctx),
514        }),
515        Format::Text => {
516            if results.is_empty() {
517                println!("No blast radius found for '{}'", symbol.display_name);
518                print_graph_hint_text(ctx);
519            } else {
520                for r in &results {
521                    let dist = r.distance.unwrap_or(0);
522                    println!("{}:{} [distance={}] {}", r.file_path, r.line, dist, r.name);
523                }
524            }
525            Ok(())
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::models::{GraphResult, ProjectionMetadata, ProjectionProvenance};
534    use serde_json::json;
535    use std::path::PathBuf;
536
537    fn make_ctx_no_falkordb() -> Context {
538        Context {
539            database_url: "postgresql://localhost/nonexistent".to_string(),
540            project_root: PathBuf::from("/nonexistent"),
541            project_id: "test-project".to_string(),
542            quiet: true,
543            falkordb: None,
544            qdrant: None,
545            embedding: None,
546            code_vectors: crate::config::CodeVectorSettings::default(),
547            daemon_url: None,
548        }
549    }
550
551    #[test]
552    fn graph_reads_require_falkor() {
553        let ctx = make_ctx_no_falkordb();
554
555        let err = imports(&ctx, "src/lib.rs", Format::Json).expect_err("imports must fail");
556
557        assert!(matches!(
558            err.downcast_ref::<code_graph::GraphReadError>(),
559            Some(code_graph::GraphReadError::NotConfigured)
560        ));
561        assert!(
562            err.to_string().contains("FalkorDB is not configured"),
563            "unexpected error: {err}"
564        );
565    }
566
567    #[test]
568    fn report_text_structured_output() {
569        let report = crate::graph::report::empty_report("project-123");
570
571        let text = format_report_text(&report).expect("format report text");
572        let value: serde_json::Value = serde_json::from_str(&text).expect("structured JSON text");
573
574        assert_eq!(value["project_id"], "project-123");
575        assert_eq!(value["summary"]["node_count"], 0);
576        assert!(
577            value["markdown"]
578                .as_str()
579                .expect("markdown field")
580                .contains("# Project Graph Report")
581        );
582        assert!(!text.trim_start().starts_with('#'));
583    }
584
585    #[test]
586    fn report_requires_graph_service() {
587        let ctx = make_ctx_no_falkordb();
588
589        let err = report(&ctx, 10, Format::Json).expect_err("report must fail");
590
591        assert!(matches!(
592            err.downcast_ref::<crate::graph::report::ProjectGraphReportError>(),
593            Some(crate::graph::report::ProjectGraphReportError::GraphServiceNotConfigured)
594        ));
595        assert!(
596            err.to_string()
597                .contains("project graph report requires FalkorDB"),
598            "unexpected error: {err}"
599        );
600    }
601
602    #[test]
603    fn graph_lifecycle_commands_call_core_directly() {
604        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
605        let source = std::fs::read_to_string(manifest_dir.join("src/commands/graph.rs"))
606            .expect("read commands/graph.rs");
607        let clear_project = ["code_graph", "::clear_project(ctx)"].concat();
608        let sync_file_graph = ["code_graph", "::sync_file_graph("].concat();
609        let lifecycle_request = ["GraphLifecycleRequest", "::from_context"].concat();
610        let daemon_lifecycle = ["code_graph", "::run_lifecycle_action"].concat();
611
612        assert!(source.contains(&clear_project));
613        assert!(source.contains(&sync_file_graph));
614        assert!(!source.contains(&lifecycle_request));
615        assert!(!source.contains(&daemon_lifecycle));
616    }
617
618    #[test]
619    fn test_build_lifecycle_url_clear_uses_project_id_query() {
620        let url = code_graph::build_lifecycle_url(
621            "http://localhost:60887/",
622            GraphLifecycleAction::Clear,
623            "project-123",
624        )
625        .expect("url builds");
626
627        assert_eq!(
628            url.as_str(),
629            "http://localhost:60887/api/code-index/graph/clear?project_id=project-123"
630        );
631    }
632
633    #[test]
634    fn test_build_lifecycle_url_rebuild_uses_project_id_query() {
635        let url = code_graph::build_lifecycle_url(
636            "http://localhost:60887",
637            GraphLifecycleAction::Rebuild,
638            "project-123",
639        )
640        .expect("url builds");
641
642        assert_eq!(
643            url.as_str(),
644            "http://localhost:60887/api/code-index/graph/rebuild?project_id=project-123"
645        );
646    }
647
648    #[test]
649    fn test_require_daemon_url_errors_when_missing() {
650        let err = code_graph::require_daemon_url(None, GraphLifecycleAction::Clear)
651            .expect_err("must fail");
652
653        assert!(
654            err.to_string()
655                .contains("Gobby daemon URL is not configured"),
656            "unexpected error: {err}"
657        );
658        assert!(
659            err.to_string().contains("gcode graph clear"),
660            "unexpected error: {err}"
661        );
662    }
663
664    #[test]
665    fn test_format_http_error_includes_status_and_body() {
666        let url = reqwest::Url::parse("http://localhost:60887/api/code-index/graph/clear")
667            .expect("valid url");
668        let message = code_graph::format_http_error(
669            GraphLifecycleAction::Clear,
670            &url,
671            reqwest::StatusCode::BAD_GATEWAY,
672            "daemon upstream unavailable",
673        );
674
675        assert!(message.contains("HTTP 502"), "unexpected error: {message}");
676        assert!(
677            message.contains("daemon upstream unavailable"),
678            "unexpected error: {message}"
679        );
680    }
681
682    #[test]
683    fn test_parse_success_payload_fails_on_invalid_json() {
684        let err = code_graph::parse_success_payload(
685            GraphLifecycleAction::Rebuild,
686            reqwest::StatusCode::OK,
687            "not json",
688        )
689        .expect_err("invalid json must fail");
690
691        assert!(
692            err.to_string().contains("invalid JSON"),
693            "unexpected error: {err}"
694        );
695        assert!(
696            err.to_string().contains("HTTP 200 OK"),
697            "unexpected error: {err}"
698        );
699    }
700
701    #[test]
702    fn test_format_success_text_prefers_message_field() {
703        let payload = json!({
704            "message": "cleared 12 graph nodes",
705            "removed_nodes": 12
706        });
707        let output = GraphLifecycleOutput {
708            project_id: "project-123".to_string(),
709            action: GraphLifecycleAction::Clear,
710            summary: "cleared 12 graph nodes".to_string(),
711            payload,
712        };
713        let text = format_success_text(&output);
714
715        assert_eq!(
716            text,
717            "Cleared code-index graph for project project-123: cleared 12 graph nodes"
718        );
719    }
720
721    #[test]
722    fn test_format_success_text_falls_back_to_compact_json() {
723        let payload = json!({
724            "replayed": 18,
725            "synced": 18
726        });
727        let output = GraphLifecycleOutput {
728            project_id: "project-123".to_string(),
729            action: GraphLifecycleAction::Rebuild,
730            summary: payload.to_string(),
731            payload,
732        };
733        let text = format_success_text(&output);
734
735        assert_eq!(
736            text,
737            "Rebuilt code-index graph for project project-123: {\"replayed\":18,\"synced\":18}"
738        );
739    }
740
741    #[test]
742    fn top_level_read_commands_preserve_json_shape() {
743        let response = PagedResponse {
744            project_id: "project-123".to_string(),
745            total: 1,
746            offset: 0,
747            limit: 10,
748            results: vec![GraphResult {
749                id: "sym-1".to_string(),
750                name: "run".to_string(),
751                file_path: "src/lib.rs".to_string(),
752                line: 12,
753                relation: Some("CALLS".to_string()),
754                distance: Some(1),
755                metadata: None,
756            }],
757            hint: None,
758        };
759
760        let value = serde_json::to_value(&response).expect("serialize response");
761
762        assert_eq!(value["project_id"], "project-123");
763        assert_eq!(value["total"], 1);
764        assert_eq!(value["offset"], 0);
765        assert_eq!(value["limit"], 10);
766        assert_eq!(value["results"][0]["id"], "sym-1");
767        assert_eq!(value["results"][0]["name"], "run");
768        assert_eq!(value["results"][0]["file_path"], "src/lib.rs");
769        assert_eq!(value["results"][0]["line"], 12);
770        assert_eq!(value["results"][0]["relation"], "CALLS");
771        assert_eq!(value["results"][0]["distance"], 1);
772        assert!(value["hint"].is_null());
773        assert!(value["results"][0].get("metadata").is_none());
774
775        let response = PagedResponse {
776            project_id: "project-123".to_string(),
777            total: 1,
778            offset: 0,
779            limit: 10,
780            results: vec![GraphResult {
781                id: "sym-1".to_string(),
782                name: "run".to_string(),
783                file_path: "src/lib.rs".to_string(),
784                line: 12,
785                relation: Some("CALLS".to_string()),
786                distance: Some(1),
787                metadata: Some(
788                    ProjectionMetadata::new(ProjectionProvenance::Extracted, "gcode")
789                        .with_source_file_path("src/lib.rs"),
790                ),
791            }],
792            hint: None,
793        };
794        let value = serde_json::to_value(&response).expect("serialize metadata response");
795
796        assert_eq!(
797            value["results"][0]["metadata"]["source_file_path"],
798            "src/lib.rs"
799        );
800    }
801}