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