Skip to main content

gobby_code/commands/
graph.rs

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