Skip to main content

gobby_code/commands/
graph.rs

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
454/// Resolve user input to a canonical symbol id, printing suggestions on ambiguity.
455/// Returns None and prints an error message if no match found.
456fn 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}