1use crate::config::{CODE_SYMBOL_COLLECTION_PREFIX, Context};
2use crate::db;
3use crate::output::{self, Format};
4use crate::projection::{
5 self,
6 sync::{ProjectionStatus, ProjectionSyncError, ProjectionSyncReport},
7};
8use crate::vector::code_symbols::{
9 self, CodeSymbolVectorLifecycle, CodeSymbolVectorLifecycleAction,
10 CodeSymbolVectorLifecycleOutput, CodeSymbolVectorLifecycleStatus, EmbeddingSource,
11 VectorLifecycleError, VectorOrphanCleanup, embedding_source_from_context,
12};
13use serde::Serialize;
14use std::collections::HashSet;
15
16pub fn lifecycle_status(
17 ctx: &Context,
18 action: CodeSymbolVectorLifecycleAction,
19) -> Result<CodeSymbolVectorLifecycleStatus, VectorLifecycleError> {
20 let prefix = CODE_SYMBOL_COLLECTION_PREFIX;
21 code_symbols::lifecycle_status(ctx.project_id.clone(), prefix, action)
22}
23
24pub(crate) fn lifecycle_from_context(
25 ctx: &Context,
26) -> Result<CodeSymbolVectorLifecycle, VectorLifecycleError> {
27 lifecycle_from_resolved_embedding_source(ctx, embedding_source_from_context(ctx))
28}
29
30fn lifecycle_from_resolved_embedding_source(
31 ctx: &Context,
32 embedding: Option<EmbeddingSource>,
33) -> Result<CodeSymbolVectorLifecycle, VectorLifecycleError> {
34 let qdrant = ctx
35 .qdrant
36 .clone()
37 .ok_or(VectorLifecycleError::MissingQdrantConfig)?;
38 let embedding = embedding.ok_or(VectorLifecycleError::MissingEmbeddingConfig)?;
39 CodeSymbolVectorLifecycle::new(
40 ctx.project_id.clone(),
41 qdrant,
42 embedding,
43 ctx.code_vectors.clone(),
44 )
45}
46
47pub fn sync_file(
48 ctx: &Context,
49 file_path: &str,
50 allow_missing_indexed_file: bool,
51 format: Format,
52) -> anyhow::Result<()> {
53 let mut conn = db::connect_readwrite(&ctx.database_url)?;
54 if !db::indexed_file_exists(&mut conn, &ctx.project_id, file_path)? {
55 if allow_missing_indexed_file {
56 return print_skipped_missing_indexed_file(ctx, file_path, format);
57 }
58 anyhow::bail!(
59 "indexed file `{file_path}` was not found for project {}",
60 ctx.project_id
61 );
62 }
63 let mut lifecycle = lifecycle_from_context(ctx)?;
64 let symbols = code_symbols::fetch_symbols_for_file(&mut conn, &ctx.project_id, file_path)?;
65 let output = lifecycle.sync_file_symbols(file_path, &symbols)?;
66 if !db::mark_vectors_synced(&mut conn, &ctx.project_id, file_path)? {
67 if allow_missing_indexed_file {
68 return print_skipped_missing_indexed_file(ctx, file_path, format);
69 }
70 anyhow::bail!(
71 "indexed file `{file_path}` was not found for project {}",
72 ctx.project_id
73 );
74 }
75 let report = ProjectionSyncReport::ok(1, output.symbols);
76 print_lifecycle_output(&output, report, format)
77}
78
79fn print_skipped_missing_indexed_file(
80 ctx: &Context,
81 file_path: &str,
82 format: Format,
83) -> anyhow::Result<()> {
84 let failures = projection::reconcile_deleted_file(ctx, file_path);
85 let collection = code_symbols::collection_name(CODE_SYMBOL_COLLECTION_PREFIX, &ctx.project_id)?;
86 let error = if failures.is_empty() {
87 None
88 } else {
89 Some(ProjectionSyncError {
90 kind: "projection_reconcile_failed".to_string(),
91 message: failures
92 .iter()
93 .map(|failure| {
94 format!(
95 "failed to reconcile {:?} projection: {}",
96 failure.target, failure.message
97 )
98 })
99 .collect::<Vec<_>>()
100 .join("; "),
101 })
102 };
103 let degraded = error.is_some();
104 let payload = serde_json::json!({
105 "success": true,
106 "project_id": ctx.project_id,
107 "projection": "vector",
108 "action": "sync_file",
109 "file_path": file_path,
110 "collection": collection,
111 "status": "skipped",
112 "reason": "indexed_file_not_found",
113 "synced_files": 0,
114 "synced_symbols": 0,
115 "skipped_files": 1,
116 "failed_files": 0,
117 "degraded": degraded,
118 "error": error,
119 "summary": format!("skipped vector sync for {file_path}: indexed file not found"),
120 });
121 match format {
122 Format::Json => output::print_json(&payload),
123 Format::Text => {
124 output::print_text(&format!(
125 "Skipped code-symbol vector sync for project {}: indexed file `{file_path}` was not found",
126 ctx.project_id
127 ))?;
128 output::print_json_compact(&payload)
129 }
130 }
131}
132
133pub fn clear(ctx: &Context, format: Format) -> anyhow::Result<()> {
134 let mut lifecycle = lifecycle_from_context(ctx)?;
135 let mut conn = db::connect_readwrite(&ctx.database_url)?;
136 db::reset_vectors_sync_for_project(&mut conn, &ctx.project_id)?;
137 let output = lifecycle.clear_project_vectors()?;
138 let report = ProjectionSyncReport::ok(0, 0);
139 print_lifecycle_output(&output, report, format)
140}
141
142pub fn rebuild(ctx: &Context, format: Format) -> anyhow::Result<()> {
143 let mut lifecycle = lifecycle_from_context(ctx)?;
144 let mut conn = db::connect_readwrite(&ctx.database_url)?;
145 let file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;
146 db::reset_vectors_sync_for_project(&mut conn, &ctx.project_id)?;
147 let symbols = code_symbols::fetch_symbols_for_project(&mut conn, &ctx.project_id)?;
148 let output = lifecycle.rebuild_symbols(&symbols)?;
149 let files_synced = db::mark_project_vectors_synced(&mut conn, &ctx.project_id)? as usize;
150 let report = ProjectionSyncReport::ok(files_synced.min(file_paths.len()), output.symbols);
151 print_lifecycle_output(&output, report, format)
152}
153
154pub fn cleanup_orphans(ctx: &Context, format: Format) -> anyhow::Result<()> {
155 let qdrant = ctx
156 .qdrant
157 .as_ref()
158 .ok_or(VectorLifecycleError::MissingQdrantConfig)?;
159 let mut conn = db::connect_readonly(&ctx.database_url)?;
160 let indexed_file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?
161 .into_iter()
162 .collect::<HashSet<_>>();
163 let cleanup =
164 code_symbols::cleanup_orphan_file_vectors(qdrant, &ctx.project_id, &indexed_file_paths)?;
165 print_orphan_cleanup(&cleanup, format)
166}
167
168fn print_lifecycle_output(
169 output: &CodeSymbolVectorLifecycleOutput,
170 report: ProjectionSyncReport,
171 format: Format,
172) -> anyhow::Result<()> {
173 let payload = lifecycle_json_payload(output, report);
174 match format {
175 Format::Json => output::print_json(&payload),
176 Format::Text => output::print_text(&serde_json::to_string(&payload)?),
177 }
178}
179
180fn print_orphan_cleanup(cleanup: &VectorOrphanCleanup, format: Format) -> anyhow::Result<()> {
181 let payload = serde_json::json!({
182 "project_id": cleanup.project_id.as_str(),
183 "projection": "vector",
184 "action": "cleanup_orphans",
185 "collection": cleanup.collection.as_str(),
186 "status": ProjectionStatus::Ok,
187 "vector_files_scanned": cleanup.vector_files_scanned,
188 "orphan_files_deleted": cleanup.orphan_files_deleted,
189 "vectors_deleted": cleanup.vectors_deleted,
190 "summary": format!(
191 "Removed {} orphaned vector file(s) and {} vector point(s)",
192 cleanup.orphan_files_deleted, cleanup.vectors_deleted
193 ),
194 });
195 match format {
196 Format::Json => output::print_json(&payload),
197 Format::Text => output::print_text(&serde_json::to_string(&payload)?),
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
202pub(crate) struct VectorLifecycleJsonPayload {
203 pub project_id: String,
204 pub projection: &'static str,
205 pub action: CodeSymbolVectorLifecycleAction,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub file_path: Option<String>,
208 pub collection: String,
209 pub status: ProjectionStatus,
210 pub synced_files: usize,
211 pub synced_symbols: usize,
212 pub skipped_files: usize,
213 pub failed_files: usize,
214 pub degraded: bool,
215 pub error: Option<crate::projection::sync::ProjectionSyncError>,
216 pub symbols: usize,
217 pub vectors_upserted: usize,
218 pub delete_operations_issued: usize,
219 pub summary: String,
220}
221
222pub(crate) fn lifecycle_json_payload(
223 output: &CodeSymbolVectorLifecycleOutput,
224 report: ProjectionSyncReport,
225) -> VectorLifecycleJsonPayload {
226 VectorLifecycleJsonPayload {
227 project_id: output.project_id.clone(),
228 projection: "vector",
229 action: output.action,
230 file_path: output.file_path.clone(),
231 collection: output.collection.clone(),
232 status: report.status,
233 synced_files: report.synced_files,
234 synced_symbols: report.synced_symbols,
235 skipped_files: report.skipped_files,
236 failed_files: report.failed_files,
237 degraded: report.degraded,
238 error: report.error,
239 symbols: output.symbols,
240 vectors_upserted: output.vectors_upserted,
241 delete_operations_issued: output.delete_operations_issued,
242 summary: output.summary.clone(),
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::projection::sync::{ProjectionStatus, ProjectionSyncError, ProjectionSyncReport};
250 use serde_json::json;
251 use std::path::PathBuf;
252
253 fn make_ctx() -> Context {
254 Context {
255 database_url: "postgresql://localhost/nonexistent".to_string(),
256 project_root: PathBuf::from("/nonexistent"),
257 project_id: "project-1".to_string(),
258 quiet: true,
259 falkordb: None,
260 qdrant: None,
261 embedding: None,
262 code_vectors: crate::config::CodeVectorSettings { vector_dim: None },
263 indexing: gobby_core::config::IndexingConfig::default(),
264 daemon_url: None,
265 index_scope: crate::config::ProjectIndexScope::Single,
266 }
267 }
268
269 fn qdrant_config() -> crate::config::QdrantConfig {
270 crate::config::QdrantConfig {
271 url: Some("http://localhost:6333".to_string()),
272 api_key: None,
273 }
274 }
275
276 fn daemon_embedding_source() -> EmbeddingSource {
277 use gobby_core::ai_context::{
278 AiConfigSource, AiContext, AiContextOptions, NoPrimaryAiConfigSource,
279 };
280 use gobby_core::config::AiRouting;
281
282 let mut source = AiConfigSource::with_primary(NoPrimaryAiConfigSource, None);
283 let context = AiContext::resolve_with_options(
284 Some("project-1".to_string()),
285 &mut source,
286 AiContextOptions {
287 no_ai: false,
288 forced_routing: Some(AiRouting::Daemon),
289 },
290 );
291 EmbeddingSource::Daemon(Box::new(context))
292 }
293
294 #[test]
295 fn vector_lifecycle_requires_config() {
296 let err = lifecycle_from_context(&make_ctx()).expect_err("missing config must fail");
297 assert!(matches!(
298 err,
299 code_symbols::VectorLifecycleError::MissingQdrantConfig
300 ));
301
302 let ctx = Context {
303 qdrant: Some(qdrant_config()),
304 ..make_ctx()
305 };
306 let err = lifecycle_from_resolved_embedding_source(&ctx, None)
307 .expect_err("missing embedding must fail");
308 assert!(matches!(
309 err,
310 code_symbols::VectorLifecycleError::MissingEmbeddingConfig
311 ));
312
313 lifecycle_from_resolved_embedding_source(&ctx, Some(daemon_embedding_source()))
314 .expect("daemon embedding source must satisfy lifecycle config");
315 }
316
317 #[test]
318 fn lifecycle_json_contract() {
319 let output = CodeSymbolVectorLifecycleOutput {
320 project_id: "project-1".to_string(),
321 collection: "gcode_code_symbols_project-1".to_string(),
322 action: CodeSymbolVectorLifecycleAction::SyncFile,
323 file_path: Some("src/lib.rs".to_string()),
324 symbols: 2,
325 vectors_upserted: 2,
326 delete_operations_issued: 1,
327 summary: "2 vector(s) upserted, 1 delete operation(s) issued".to_string(),
328 };
329
330 let payload = lifecycle_json_payload(
331 &output,
332 ProjectionSyncReport {
333 status: ProjectionStatus::Ok,
334 synced_files: 1,
335 synced_symbols: 2,
336 skipped_files: 0,
337 failed_files: 0,
338 degraded: false,
339 error: None,
340 },
341 );
342 assert_eq!(
343 serde_json::to_value(&payload).expect("payload serializes"),
344 json!({
345 "project_id": "project-1",
346 "projection": "vector",
347 "action": "sync_file",
348 "file_path": "src/lib.rs",
349 "collection": "gcode_code_symbols_project-1",
350 "status": "ok",
351 "synced_files": 1,
352 "synced_symbols": 2,
353 "skipped_files": 0,
354 "failed_files": 0,
355 "degraded": false,
356 "error": null,
357 "symbols": 2,
358 "vectors_upserted": 2,
359 "delete_operations_issued": 1,
360 "summary": "2 vector(s) upserted, 1 delete operation(s) issued"
361 })
362 );
363
364 let degraded = lifecycle_json_payload(
365 &output,
366 ProjectionSyncReport {
367 status: ProjectionStatus::Degraded,
368 synced_files: 0,
369 synced_symbols: 0,
370 skipped_files: 0,
371 failed_files: 0,
372 degraded: true,
373 error: Some(ProjectionSyncError {
374 kind: "missing_qdrant_config".to_string(),
375 message: "Qdrant config is required".to_string(),
376 }),
377 },
378 );
379 let degraded = serde_json::to_value(°raded).expect("payload serializes");
380 assert_eq!(degraded["status"], "degraded");
381 assert_eq!(degraded["error"]["kind"], "missing_qdrant_config");
382 }
383}