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