gobby_code/commands/graph/
lifecycle.rs1use 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}