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