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