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 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
407pub 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}