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::sync::ProjectionSyncReport;
6use serde_json::{Value, json};
7
8pub const GRAPH_SYNC_CONTRACT_EXIT_CODE: u8 = 2;
9
10#[derive(Debug)]
11pub struct GraphSyncContractError {
12    payload: Value,
13}
14
15impl GraphSyncContractError {
16    pub(super) fn project_not_indexed(ctx: &Context, file_path: &str) -> Self {
17        Self {
18            payload: json!({
19                "success": false,
20                "project_id": ctx.project_id,
21                "file_path": file_path,
22                "status": "error",
23                "reason": "project_not_indexed",
24                "error": format!("project {} is not indexed", ctx.project_id),
25            }),
26        }
27    }
28
29    pub(super) fn indexed_file_not_found(ctx: &Context, file_path: &str) -> Self {
30        Self {
31            payload: json!({
32                "success": false,
33                "project_id": ctx.project_id,
34                "file_path": file_path,
35                "status": "error",
36                "reason": "indexed_file_not_found",
37                "error": format!("indexed file `{file_path}` was not found for project {}", ctx.project_id),
38            }),
39        }
40    }
41
42    pub fn exit_code(&self) -> u8 {
43        GRAPH_SYNC_CONTRACT_EXIT_CODE
44    }
45
46    pub fn print(&self) -> anyhow::Result<()> {
47        output::print_json(&self.payload)
48    }
49
50    pub fn payload(&self) -> &Value {
51        &self.payload
52    }
53}
54
55impl std::fmt::Display for GraphSyncContractError {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        let reason = self
58            .payload
59            .get("reason")
60            .and_then(Value::as_str)
61            .unwrap_or("graph_sync_contract_error");
62        write!(f, "graph sync-file contract error: {reason}")
63    }
64}
65
66impl std::error::Error for GraphSyncContractError {}
67
68pub(super) fn format_success_text(output: &GraphLifecycleOutput) -> String {
69    format!(
70        "{} for project {}: {}",
71        output.action.success_prefix(),
72        output.project_id,
73        output.summary
74    )
75}
76
77pub(super) trait LifecycleBackend {
78    fn run(
79        &self,
80        ctx: &Context,
81        action: GraphLifecycleAction,
82    ) -> anyhow::Result<GraphLifecycleOutput>;
83}
84
85struct CodeGraphLifecycleBackend;
86
87impl LifecycleBackend for CodeGraphLifecycleBackend {
88    fn run(
89        &self,
90        ctx: &Context,
91        action: GraphLifecycleAction,
92    ) -> anyhow::Result<GraphLifecycleOutput> {
93        match action {
94            GraphLifecycleAction::Clear => clear_project_graph(ctx),
95            GraphLifecycleAction::Rebuild => rebuild_project_graph(ctx),
96        }
97    }
98}
99
100pub(super) fn run_lifecycle_action_with_backend(
101    ctx: &Context,
102    action: GraphLifecycleAction,
103    format: Format,
104    backend: &impl LifecycleBackend,
105) -> anyhow::Result<()> {
106    let output = backend.run(ctx, action)?;
107    match format {
108        Format::Json => output::print_json(&output.payload),
109        Format::Text => {
110            output::print_text(&format_success_text(&output))?;
111            output::print_json_compact(&output.payload)
112        }
113    }
114}
115
116fn lifecycle_output(
117    action: GraphLifecycleAction,
118    ctx: &Context,
119    payload: Value,
120) -> GraphLifecycleOutput {
121    let summary = code_graph::extract_summary_text(&payload).unwrap_or_else(|| payload.to_string());
122    GraphLifecycleOutput {
123        project_id: ctx.project_id.clone(),
124        action,
125        summary,
126        payload,
127    }
128}
129
130enum GraphFileSyncOutcome {
131    Synced {
132        relationships_written: usize,
133        symbols_synced: usize,
134    },
135    SkippedMissingIndexedFile,
136}
137
138pub(super) fn skipped_missing_indexed_file_payload(ctx: &Context, file_path: &str) -> Value {
139    json!({
140        "project_id": ctx.project_id,
141        "file_path": file_path,
142        "status": "skipped",
143        "reason": "indexed_file_not_found",
144    })
145}
146
147fn sync_file_graph(
148    ctx: &Context,
149    file_path: &str,
150    allow_missing_indexed_file: bool,
151) -> anyhow::Result<GraphFileSyncOutcome> {
152    let mut conn = db::connect_readwrite(&ctx.database_url)?;
153    if !db::indexed_project_exists(&mut conn, &ctx.project_id)? {
154        return Err(GraphSyncContractError::project_not_indexed(ctx, file_path).into());
155    }
156    code_graph::require_graph_reads(ctx)?;
157    if !db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path)? {
158        if allow_missing_indexed_file {
159            return Ok(GraphFileSyncOutcome::SkippedMissingIndexedFile);
160        }
161        return Err(GraphSyncContractError::indexed_file_not_found(ctx, file_path).into());
162    }
163    let facts = db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
164    let relationships_written = code_graph::sync_file_graph(
165        ctx,
166        &facts.file_path,
167        &facts.imports,
168        &facts.definitions,
169        &facts.calls,
170        true,
171    )?;
172    db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
173    Ok(GraphFileSyncOutcome::Synced {
174        relationships_written,
175        symbols_synced: facts.definitions.len(),
176    })
177}
178
179fn clear_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
180    code_graph::require_graph_reads(ctx)?;
181    let mut conn = db::connect_readwrite(&ctx.database_url)?;
182    let files_marked_pending = db::reset_graph_sync_for_project(&mut conn, &ctx.project_id)?;
183    code_graph::clear_project(ctx)?;
184    let report = ProjectionSyncReport::ok(0, 0);
185    Ok(lifecycle_output(
186        GraphLifecycleAction::Clear,
187        ctx,
188        json!({
189            "success": true,
190            "project_id": ctx.project_id,
191            "status": report.status,
192            "synced_files": report.synced_files,
193            "synced_symbols": report.synced_symbols,
194            "degraded": report.degraded,
195            "error": report.error,
196            "files_marked_pending": files_marked_pending,
197            "summary": format!("marked {files_marked_pending} files pending and cleared graph projection"),
198        }),
199    ))
200}
201
202fn rebuild_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
203    code_graph::require_graph_reads(ctx)?;
204    let mut conn = db::connect_readwrite(&ctx.database_url)?;
205    let file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;
206
207    let mut files_synced = 0usize;
208    let mut symbols_synced = 0usize;
209    let mut errors = Vec::new();
210    code_graph::with_code_graph(ctx, |graph| {
211        for file_path in &file_paths {
212            let synced_symbols =
213                match db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path)
214                    .and_then(|updated| {
215                        if updated {
216                            Ok(())
217                        } else {
218                            anyhow::bail!("indexed file no longer exists")
219                        }
220                    })
221                    .and_then(|_| {
222                        let facts =
223                            db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
224                        graph.sync_file(
225                            &facts.file_path,
226                            &facts.imports,
227                            &facts.definitions,
228                            &facts.calls,
229                            false,
230                        )?;
231                        db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
232                        Ok(facts.definitions.len())
233                    }) {
234                    Ok(symbols) => symbols,
235                    Err(err) => {
236                        errors.push(format!("{file_path}: {err}"));
237                        continue;
238                    }
239                };
240            files_synced += 1;
241            symbols_synced += synced_symbols;
242        }
243        Ok(())
244    })?;
245    if errors.is_empty()
246        && files_synced > 0
247        && let Err(err) = code_graph::cleanup_orphans(ctx)
248    {
249        errors.push(format!("cleanup_orphans: {err}"));
250    }
251
252    let report = if errors.is_empty() {
253        ProjectionSyncReport::ok(files_synced, symbols_synced)
254    } else {
255        ProjectionSyncReport::degraded(
256            "sync_failed",
257            errors.join("; "),
258            files_synced,
259            symbols_synced,
260        )
261    };
262    Ok(lifecycle_output(
263        GraphLifecycleAction::Rebuild,
264        ctx,
265        json!({
266            "success": errors.is_empty(),
267            "project_id": ctx.project_id,
268            "status": report.status,
269            "synced_files": report.synced_files,
270            "synced_symbols": report.synced_symbols,
271            "degraded": report.degraded,
272            "error": report.error,
273            "files_processed": file_paths.len(),
274            "files_synced": files_synced,
275            "files_failed": errors.len(),
276            "errors": errors,
277            "summary": format!("synced {files_synced}/{} files", file_paths.len()),
278        }),
279    ))
280}
281
282pub fn clear(ctx: &Context, format: Format) -> anyhow::Result<()> {
283    run_lifecycle_action_with_backend(
284        ctx,
285        GraphLifecycleAction::Clear,
286        format,
287        &CodeGraphLifecycleBackend,
288    )
289}
290
291pub fn rebuild(ctx: &Context, format: Format) -> anyhow::Result<()> {
292    run_lifecycle_action_with_backend(
293        ctx,
294        GraphLifecycleAction::Rebuild,
295        format,
296        &CodeGraphLifecycleBackend,
297    )
298}
299
300pub fn sync_file(
301    ctx: &Context,
302    file_path: &str,
303    allow_missing_indexed_file: bool,
304    format: Format,
305) -> anyhow::Result<()> {
306    let sync = sync_file_graph(ctx, file_path, allow_missing_indexed_file)?;
307    let GraphFileSyncOutcome::Synced {
308        relationships_written,
309        symbols_synced,
310    } = sync
311    else {
312        let payload = skipped_missing_indexed_file_payload(ctx, file_path);
313        return match format {
314            Format::Json => output::print_json(&payload),
315            Format::Text => {
316                output::print_text(&format!(
317                    "Skipped code-index graph sync for project {}: indexed file `{file_path}` was not found",
318                    ctx.project_id
319                ))?;
320                output::print_json_compact(&payload)
321            }
322        };
323    };
324    let report = ProjectionSyncReport::ok(1, symbols_synced);
325    let summary = format!("synced {relationships_written} graph relationships for {file_path}");
326    let payload = json!({
327        "success": true,
328        "project_id": ctx.project_id,
329        "file_path": file_path,
330        "status": report.status,
331        "synced_files": report.synced_files,
332        "synced_symbols": report.synced_symbols,
333        "degraded": report.degraded,
334        "error": report.error,
335        "relationships_written": relationships_written,
336        "summary": summary,
337    });
338    match format {
339        Format::Json => output::print_json(&payload),
340        Format::Text => {
341            output::print_text(&format!(
342                "Synced code-index graph for project {}: {summary}",
343                ctx.project_id
344            ))?;
345            output::print_json_compact(&payload)
346        }
347    }
348}