Skip to main content

gobby_code/commands/graph/
lifecycle.rs

1use crate::config::Context;
2use crate::db;
3use crate::graph::code_graph::{self, GraphLifecycleAction, GraphLifecycleOutput};
4use crate::output::{self, Format};
5use crate::projection::{self, ProjectionReconcileFailure, sync::ProjectionSyncReport};
6use serde_json::{Value, json};
7use std::collections::HashSet;
8
9pub const GRAPH_SYNC_CONTRACT_EXIT_CODE: u8 = 2;
10
11#[derive(Debug)]
12pub struct GraphSyncContractError {
13    payload: Value,
14}
15
16impl GraphSyncContractError {
17    pub(super) fn project_not_indexed(ctx: &Context, file_path: &str) -> Self {
18        Self {
19            payload: json!({
20                "success": false,
21                "project_id": ctx.project_id,
22                "file_path": file_path,
23                "status": "error",
24                "reason": "project_not_indexed",
25                "error": format!("project {} is not indexed", ctx.project_id),
26            }),
27        }
28    }
29
30    pub(super) fn indexed_file_not_found(ctx: &Context, file_path: &str) -> Self {
31        Self {
32            payload: json!({
33                "success": false,
34                "project_id": ctx.project_id,
35                "file_path": file_path,
36                "status": "error",
37                "reason": "indexed_file_not_found",
38                "error": format!("indexed file `{file_path}` was not found for project {}", ctx.project_id),
39            }),
40        }
41    }
42
43    pub fn exit_code(&self) -> u8 {
44        GRAPH_SYNC_CONTRACT_EXIT_CODE
45    }
46
47    pub fn print(&self) -> anyhow::Result<()> {
48        output::print_json(&self.payload)
49    }
50
51    pub fn payload(&self) -> &Value {
52        &self.payload
53    }
54}
55
56impl std::fmt::Display for GraphSyncContractError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        let reason = self
59            .payload
60            .get("reason")
61            .and_then(Value::as_str)
62            .unwrap_or("graph_sync_contract_error");
63        write!(f, "graph sync-file contract error: {reason}")
64    }
65}
66
67impl std::error::Error for GraphSyncContractError {}
68
69pub(super) fn format_success_text(output: &GraphLifecycleOutput) -> String {
70    format!(
71        "{} for project {}: {}",
72        output.action.success_prefix(),
73        output.project_id,
74        output.summary
75    )
76}
77
78pub(super) trait LifecycleBackend {
79    fn run(
80        &self,
81        ctx: &Context,
82        action: GraphLifecycleAction,
83    ) -> anyhow::Result<GraphLifecycleOutput>;
84}
85
86struct CodeGraphLifecycleBackend;
87
88impl LifecycleBackend for CodeGraphLifecycleBackend {
89    fn run(
90        &self,
91        ctx: &Context,
92        action: GraphLifecycleAction,
93    ) -> anyhow::Result<GraphLifecycleOutput> {
94        match action {
95            GraphLifecycleAction::Clear => clear_project_graph(ctx),
96            GraphLifecycleAction::Rebuild => rebuild_project_graph(ctx),
97        }
98    }
99}
100
101pub(super) fn run_lifecycle_action_with_backend(
102    ctx: &Context,
103    action: GraphLifecycleAction,
104    format: Format,
105    backend: &impl LifecycleBackend,
106) -> anyhow::Result<()> {
107    let output = backend.run(ctx, action)?;
108    match format {
109        Format::Json => output::print_json(&output.payload),
110        Format::Text => {
111            output::print_text(&format_success_text(&output))?;
112            output::print_json_compact(&output.payload)
113        }
114    }
115}
116
117fn lifecycle_output(
118    action: GraphLifecycleAction,
119    ctx: &Context,
120    payload: Value,
121) -> GraphLifecycleOutput {
122    let summary = code_graph::extract_summary_text(&payload).unwrap_or_else(|| payload.to_string());
123    GraphLifecycleOutput {
124        project_id: ctx.project_id.clone(),
125        action,
126        summary,
127        payload,
128    }
129}
130
131enum GraphFileSyncOutcome {
132    Synced {
133        relationships_written: usize,
134        symbols_synced: usize,
135    },
136    SkippedNoGraphFacts,
137    SkippedMissingIndexedFile {
138        reconcile_failures: Vec<ProjectionReconcileFailure>,
139    },
140}
141
142pub(super) fn skipped_missing_indexed_file_payload(
143    ctx: &Context,
144    file_path: &str,
145    reconcile_failures: &[ProjectionReconcileFailure],
146) -> Value {
147    let error = if reconcile_failures.is_empty() {
148        None
149    } else {
150        Some(serde_json::json!({
151            "kind": "projection_reconcile_failed",
152            "message": reconcile_failures
153                .iter()
154                .map(|failure| {
155                    format!(
156                        "failed to reconcile {:?} projection: {}",
157                        failure.target, failure.message
158                    )
159                })
160                .collect::<Vec<_>>()
161                .join("; "),
162        }))
163    };
164    json!({
165        "success": true,
166        "project_id": ctx.project_id,
167        "file_path": file_path,
168        "status": "skipped",
169        "reason": "indexed_file_not_found",
170        "synced_files": 0,
171        "synced_symbols": 0,
172        "skipped_files": 1,
173        "failed_files": 0,
174        "relationships_written": 0,
175        "degraded": error.is_some(),
176        "error": error,
177        "summary": format!("skipped graph sync for {file_path}: indexed file not found"),
178    })
179}
180
181pub(super) fn skipped_no_graph_facts_payload(ctx: &Context, file_path: &str) -> Value {
182    json!({
183        "success": true,
184        "project_id": ctx.project_id,
185        "file_path": file_path,
186        "status": "skipped",
187        "reason": "no_graph_facts",
188        "synced_files": 1,
189        "synced_symbols": 0,
190        "skipped_files": 1,
191        "failed_files": 0,
192        "relationships_written": 0,
193        "degraded": false,
194        "error": null,
195        "summary": format!("skipped graph sync for {file_path}: no graph facts"),
196    })
197}
198
199pub(super) fn has_no_graph_facts<I, D, C>(imports: &[I], definitions: &[D], calls: &[C]) -> bool {
200    imports.is_empty() && definitions.is_empty() && calls.is_empty()
201}
202
203fn sync_file_graph(
204    ctx: &Context,
205    file_path: &str,
206    allow_missing_indexed_file: bool,
207) -> anyhow::Result<GraphFileSyncOutcome> {
208    let mut conn = db::connect_readwrite(&ctx.database_url)?;
209    if !db::indexed_project_exists(&mut conn, &ctx.project_id)? {
210        return Err(GraphSyncContractError::project_not_indexed(ctx, file_path).into());
211    }
212    code_graph::require_graph_reads(ctx)?;
213    if !db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path)? {
214        if allow_missing_indexed_file {
215            return Ok(GraphFileSyncOutcome::SkippedMissingIndexedFile {
216                reconcile_failures: projection::reconcile_deleted_file(ctx, file_path),
217            });
218        }
219        return Err(GraphSyncContractError::indexed_file_not_found(ctx, file_path).into());
220    }
221    let facts = db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
222    if has_no_graph_facts(&facts.imports, &facts.definitions, &facts.calls) {
223        code_graph::with_code_graph(ctx, |graph| {
224            graph.delete_file_graph(&facts.file_path, &[])?;
225            graph.delete_file_node(&facts.file_path)
226        })?;
227        db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
228        return Ok(GraphFileSyncOutcome::SkippedNoGraphFacts);
229    }
230    // Per-file sync intentionally does NOT run project-wide orphan cleanup:
231    // `cleanup_orphans` performs O(project graph size) anti-join sweeps that are
232    // unbounded relative to this one file and caused the daemon's per-file sync to
233    // time out on large graphs. File-scoped stale records are still removed by
234    // `delete_stale_file_graph` inside the sync. Project-wide orphan GC runs in
235    // `graph rebuild` and the dedicated `graph cleanup-orphans` command instead.
236    let relationships_written = code_graph::sync_file_graph(
237        ctx,
238        &facts.file_path,
239        &facts.imports,
240        &facts.definitions,
241        &facts.calls,
242        false,
243    )?;
244    db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
245    Ok(GraphFileSyncOutcome::Synced {
246        relationships_written,
247        symbols_synced: facts.definitions.len(),
248    })
249}
250
251fn clear_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
252    code_graph::require_graph_reads(ctx)?;
253    let mut conn = db::connect_readwrite(&ctx.database_url)?;
254    let files_marked_pending = db::reset_graph_sync_for_project(&mut conn, &ctx.project_id)?;
255    code_graph::clear_project(ctx)?;
256    let report = ProjectionSyncReport::ok(0, 0);
257    Ok(lifecycle_output(
258        GraphLifecycleAction::Clear,
259        ctx,
260        json!({
261            "success": true,
262            "project_id": ctx.project_id,
263            "status": report.status,
264            "synced_files": report.synced_files,
265            "synced_symbols": report.synced_symbols,
266            "skipped_files": report.skipped_files,
267            "failed_files": report.failed_files,
268            "degraded": report.degraded,
269            "error": report.error,
270            "files_marked_pending": files_marked_pending,
271            "summary": format!("marked {files_marked_pending} files pending and cleared graph projection"),
272        }),
273    ))
274}
275
276fn rebuild_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
277    code_graph::require_graph_reads(ctx)?;
278    let mut conn = db::connect_readwrite(&ctx.database_url)?;
279    let file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;
280
281    let mut files_synced = 0usize;
282    let mut symbols_synced = 0usize;
283    let mut files_skipped = 0usize;
284    let mut files_failed = 0usize;
285    let mut errors = Vec::new();
286    let mut error_kind = None;
287    code_graph::with_code_graph(ctx, |graph| {
288        for file_path in &file_paths {
289            match db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path) {
290                Ok(true) => {}
291                Ok(false) => {
292                    files_skipped += 1;
293                    for failure in projection::reconcile_deleted_file(ctx, file_path) {
294                        error_kind.get_or_insert_with(|| "projection_reconcile_failed".to_string());
295                        errors.push(format!(
296                            "{file_path}: failed to reconcile {:?} projection: {}",
297                            failure.target, failure.message
298                        ));
299                    }
300                    continue;
301                }
302                Err(err) => {
303                    files_failed += 1;
304                    error_kind.get_or_insert_with(|| "sync_failed".to_string());
305                    errors.push(format!("{file_path}: {err}"));
306                    continue;
307                }
308            }
309
310            let synced_symbols = match (|| -> anyhow::Result<usize> {
311                let facts = db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
312                if has_no_graph_facts(&facts.imports, &facts.definitions, &facts.calls) {
313                    graph.delete_file_graph(&facts.file_path, &[])?;
314                    graph.delete_file_node(&facts.file_path)?;
315                    db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
316                    return Ok(0);
317                }
318                graph.sync_file(
319                    &facts.file_path,
320                    &facts.imports,
321                    &facts.definitions,
322                    &facts.calls,
323                    false,
324                )?;
325                db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
326                Ok(facts.definitions.len())
327            })() {
328                Ok(symbols) => symbols,
329                Err(err) => {
330                    files_failed += 1;
331                    error_kind.get_or_insert_with(|| "sync_failed".to_string());
332                    errors.push(format!("{file_path}: {err}"));
333                    continue;
334                }
335            };
336            files_synced += 1;
337            symbols_synced += synced_symbols;
338        }
339        Ok(())
340    })?;
341    if errors.is_empty()
342        && files_synced > 0
343        && let Err(err) = code_graph::cleanup_orphans(ctx)
344    {
345        error_kind.get_or_insert_with(|| "sync_failed".to_string());
346        errors.push(format!("cleanup_orphans: {err}"));
347    }
348
349    let report = if errors.is_empty() {
350        ProjectionSyncReport::ok_with_counts(
351            files_synced,
352            symbols_synced,
353            files_skipped,
354            files_failed,
355        )
356    } else {
357        ProjectionSyncReport::degraded_with_counts(
358            error_kind.unwrap_or_else(|| "sync_failed".to_string()),
359            errors.join("; "),
360            files_synced,
361            symbols_synced,
362            files_skipped,
363            files_failed,
364        )
365    };
366    Ok(lifecycle_output(
367        GraphLifecycleAction::Rebuild,
368        ctx,
369        json!({
370            "success": errors.is_empty(),
371            "project_id": ctx.project_id,
372            "status": report.status,
373            "synced_files": report.synced_files,
374            "synced_symbols": report.synced_symbols,
375            "skipped_files": report.skipped_files,
376            "failed_files": report.failed_files,
377            "degraded": report.degraded,
378            "error": report.error,
379            "files_processed": file_paths.len(),
380            "files_synced": files_synced,
381            "files_skipped": files_skipped,
382            "files_failed": files_failed,
383            "errors": errors,
384            "summary": format!("synced {files_synced}/{} files", file_paths.len()),
385        }),
386    ))
387}
388
389pub fn clear(ctx: &Context, format: Format) -> anyhow::Result<()> {
390    run_lifecycle_action_with_backend(
391        ctx,
392        GraphLifecycleAction::Clear,
393        format,
394        &CodeGraphLifecycleBackend,
395    )
396}
397
398pub fn rebuild(ctx: &Context, format: Format) -> anyhow::Result<()> {
399    run_lifecycle_action_with_backend(
400        ctx,
401        GraphLifecycleAction::Rebuild,
402        format,
403        &CodeGraphLifecycleBackend,
404    )
405}
406
407/// Run project-wide orphan cleanup as a standalone maintenance command.
408///
409/// This is the same sweep that `graph rebuild` performs at the end, exposed on
410/// its own so the daemon can schedule it periodically instead of paying its
411/// O(project graph size) cost on every per-file `graph sync-file`.
412pub fn cleanup_orphans(ctx: &Context, format: Format) -> anyhow::Result<()> {
413    code_graph::require_graph_reads(ctx)?;
414    let cleanup = cleanup_deleted_project_graph(ctx)?;
415    let payload = json!({
416        "status": "ok",
417        "action": "cleanup_orphans",
418        "project_id": ctx.project_id.clone(),
419        "stale_graph_files_deleted": cleanup.stale_files_deleted,
420        "graph_nodes_deleted": cleanup.graph_nodes_deleted,
421    });
422    match format {
423        Format::Json => output::print_json(&payload),
424        Format::Text => {
425            output::print_text(&format!(
426                "Removed {} stale code-graph file(s) and {} file-scoped graph node(s)",
427                cleanup.stale_files_deleted, cleanup.graph_nodes_deleted
428            ))?;
429            output::print_json_compact(&payload)
430        }
431    }
432}
433
434pub(crate) fn cleanup_deleted_project_graph(
435    ctx: &Context,
436) -> anyhow::Result<code_graph::GraphOrphanCleanup> {
437    let mut conn = db::connect_readonly(&ctx.database_url)?;
438    let indexed_file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?
439        .into_iter()
440        .collect::<HashSet<_>>();
441    code_graph::cleanup_deleted_files(ctx, &indexed_file_paths)
442}
443
444pub fn sync_file(
445    ctx: &Context,
446    file_path: &str,
447    allow_missing_indexed_file: bool,
448    format: Format,
449) -> anyhow::Result<()> {
450    let sync = sync_file_graph(ctx, file_path, allow_missing_indexed_file)?;
451    let (relationships_written, symbols_synced) = match sync {
452        GraphFileSyncOutcome::Synced {
453            relationships_written,
454            symbols_synced,
455        } => (relationships_written, symbols_synced),
456        GraphFileSyncOutcome::SkippedNoGraphFacts => {
457            let payload = skipped_no_graph_facts_payload(ctx, file_path);
458            return match format {
459                Format::Json => output::print_json(&payload),
460                Format::Text => {
461                    output::print_text(&format!(
462                        "Skipped code-index graph sync for project {}: `{file_path}` has no graph facts",
463                        ctx.project_id
464                    ))?;
465                    output::print_json_compact(&payload)
466                }
467            };
468        }
469        GraphFileSyncOutcome::SkippedMissingIndexedFile { reconcile_failures } => {
470            let payload = skipped_missing_indexed_file_payload(ctx, file_path, &reconcile_failures);
471            return match format {
472                Format::Json => output::print_json(&payload),
473                Format::Text => {
474                    output::print_text(&format!(
475                        "Skipped code-index graph sync for project {}: indexed file `{file_path}` was not found",
476                        ctx.project_id
477                    ))?;
478                    output::print_json_compact(&payload)
479                }
480            };
481        }
482    };
483    let report = ProjectionSyncReport::ok(1, symbols_synced);
484    let summary = format!("synced {relationships_written} graph relationships for {file_path}");
485    let payload = json!({
486        "success": true,
487        "project_id": ctx.project_id,
488        "file_path": file_path,
489        "status": report.status,
490        "synced_files": report.synced_files,
491        "synced_symbols": report.synced_symbols,
492        "skipped_files": report.skipped_files,
493        "failed_files": report.failed_files,
494        "degraded": report.degraded,
495        "error": report.error,
496        "relationships_written": relationships_written,
497        "summary": summary,
498    });
499    match format {
500        Format::Json => output::print_json(&payload),
501        Format::Text => {
502            output::print_text(&format!(
503                "Synced code-index graph for project {}: {summary}",
504                ctx.project_id
505            ))?;
506            output::print_json_compact(&payload)
507        }
508    }
509}