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