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