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
337fn 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}