1use crate::config::{CODE_SYMBOL_COLLECTION_PREFIX, Context};
2use crate::db;
3use crate::output::{self, Format};
4use crate::projection::sync::{ProjectionStatus, ProjectionSyncReport};
5use crate::vector::code_symbols::{
6 self, CodeSymbolVectorLifecycle, CodeSymbolVectorLifecycleAction,
7 CodeSymbolVectorLifecycleOutput, CodeSymbolVectorLifecycleStatus, EmbeddingSource,
8 VectorLifecycleError, embedding_source_from_context,
9};
10use serde::Serialize;
11
12pub fn lifecycle_status(
13 ctx: &Context,
14 action: CodeSymbolVectorLifecycleAction,
15) -> CodeSymbolVectorLifecycleStatus {
16 let prefix = CODE_SYMBOL_COLLECTION_PREFIX;
17 code_symbols::lifecycle_status(ctx.project_id.clone(), prefix, action)
18}
19
20pub(crate) fn lifecycle_from_context(
21 ctx: &Context,
22) -> Result<CodeSymbolVectorLifecycle, VectorLifecycleError> {
23 lifecycle_from_resolved_embedding_source(ctx, embedding_source_from_context(ctx))
24}
25
26fn lifecycle_from_resolved_embedding_source(
27 ctx: &Context,
28 embedding: Option<EmbeddingSource>,
29) -> Result<CodeSymbolVectorLifecycle, VectorLifecycleError> {
30 let qdrant = ctx
31 .qdrant
32 .clone()
33 .ok_or(VectorLifecycleError::MissingQdrantConfig)?;
34 let embedding = embedding.ok_or(VectorLifecycleError::MissingEmbeddingConfig)?;
35 CodeSymbolVectorLifecycle::new(
36 ctx.project_id.clone(),
37 qdrant,
38 embedding,
39 ctx.code_vectors.clone(),
40 )
41}
42
43pub fn sync_file(ctx: &Context, file_path: &str, format: Format) -> anyhow::Result<()> {
44 let mut lifecycle = lifecycle_from_context(ctx)?;
45 let mut conn = db::connect_readwrite(&ctx.database_url)?;
46 if !db::indexed_file_exists(&mut conn, &ctx.project_id, file_path)? {
47 anyhow::bail!(
48 "indexed file `{file_path}` was not found for project {}",
49 ctx.project_id
50 );
51 }
52 let symbols = code_symbols::fetch_symbols_for_file(&mut conn, &ctx.project_id, file_path)?;
53 let output = lifecycle.sync_file_symbols(file_path, &symbols)?;
54 if !db::mark_vectors_synced(&mut conn, &ctx.project_id, file_path)? {
55 anyhow::bail!(
56 "indexed file `{file_path}` was not found for project {}",
57 ctx.project_id
58 );
59 }
60 let report = ProjectionSyncReport::ok(1, output.symbols);
61 print_lifecycle_output(&output, report, format)
62}
63
64pub fn clear(ctx: &Context, format: Format) -> anyhow::Result<()> {
65 let mut lifecycle = lifecycle_from_context(ctx)?;
66 let mut conn = db::connect_readwrite(&ctx.database_url)?;
67 db::reset_vectors_sync_for_project(&mut conn, &ctx.project_id)?;
68 let output = lifecycle.clear_project_vectors()?;
69 let report = ProjectionSyncReport::ok(0, 0);
70 print_lifecycle_output(&output, report, format)
71}
72
73pub fn rebuild(ctx: &Context, format: Format) -> anyhow::Result<()> {
74 let mut lifecycle = lifecycle_from_context(ctx)?;
75 let mut conn = db::connect_readwrite(&ctx.database_url)?;
76 let file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;
77 db::reset_vectors_sync_for_project(&mut conn, &ctx.project_id)?;
78 let symbols = code_symbols::fetch_symbols_for_project(&mut conn, &ctx.project_id)?;
79 let output = lifecycle.rebuild_symbols(&symbols)?;
80 let files_synced = db::mark_project_vectors_synced(&mut conn, &ctx.project_id)? as usize;
81 let report = ProjectionSyncReport::ok(files_synced.min(file_paths.len()), output.symbols);
82 print_lifecycle_output(&output, report, format)
83}
84
85fn print_lifecycle_output(
86 output: &CodeSymbolVectorLifecycleOutput,
87 report: ProjectionSyncReport,
88 format: Format,
89) -> anyhow::Result<()> {
90 let payload = lifecycle_json_payload(output, report);
91 match format {
92 Format::Json => output::print_json(&payload),
93 Format::Text => output::print_text(&serde_json::to_string(&payload)?),
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98pub(crate) struct VectorLifecycleJsonPayload {
99 pub project_id: String,
100 pub projection: &'static str,
101 pub action: CodeSymbolVectorLifecycleAction,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub file_path: Option<String>,
104 pub collection: String,
105 pub status: ProjectionStatus,
106 pub synced_files: usize,
107 pub synced_symbols: usize,
108 pub degraded: bool,
109 pub error: Option<crate::projection::sync::ProjectionSyncError>,
110 pub symbols: usize,
111 pub vectors_upserted: usize,
112 pub delete_operations_issued: usize,
113 pub summary: String,
114}
115
116pub(crate) fn lifecycle_json_payload(
117 output: &CodeSymbolVectorLifecycleOutput,
118 report: ProjectionSyncReport,
119) -> VectorLifecycleJsonPayload {
120 VectorLifecycleJsonPayload {
121 project_id: output.project_id.clone(),
122 projection: "vector",
123 action: output.action,
124 file_path: output.file_path.clone(),
125 collection: output.collection.clone(),
126 status: report.status,
127 synced_files: report.synced_files,
128 synced_symbols: report.synced_symbols,
129 degraded: report.degraded,
130 error: report.error,
131 symbols: output.symbols,
132 vectors_upserted: output.vectors_upserted,
133 delete_operations_issued: output.delete_operations_issued,
134 summary: output.summary.clone(),
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::projection::sync::{ProjectionStatus, ProjectionSyncError, ProjectionSyncReport};
142 use serde_json::json;
143 use std::path::PathBuf;
144
145 fn make_ctx() -> Context {
146 Context {
147 database_url: "postgresql://localhost/nonexistent".to_string(),
148 project_root: PathBuf::from("/nonexistent"),
149 project_id: "project-1".to_string(),
150 quiet: true,
151 falkordb: None,
152 qdrant: None,
153 embedding: None,
154 code_vectors: crate::config::CodeVectorSettings { vector_dim: None },
155 daemon_url: None,
156 index_scope: crate::config::ProjectIndexScope::Single,
157 }
158 }
159
160 fn qdrant_config() -> crate::config::QdrantConfig {
161 crate::config::QdrantConfig {
162 url: Some("http://localhost:6333".to_string()),
163 api_key: None,
164 }
165 }
166
167 fn daemon_embedding_source() -> EmbeddingSource {
168 use gobby_core::ai_context::{
169 AiConfigSource, AiContext, AiContextOptions, NoPrimaryAiConfigSource,
170 };
171 use gobby_core::config::AiRouting;
172
173 let mut source = AiConfigSource::with_primary(NoPrimaryAiConfigSource, None);
174 let context = AiContext::resolve_with_options(
175 Some("project-1".to_string()),
176 &mut source,
177 AiContextOptions {
178 no_ai: false,
179 forced_routing: Some(AiRouting::Daemon),
180 },
181 );
182 EmbeddingSource::Daemon(Box::new(context))
183 }
184
185 #[test]
186 fn vector_lifecycle_requires_config() {
187 let err = lifecycle_from_context(&make_ctx()).expect_err("missing config must fail");
188 assert!(matches!(
189 err,
190 code_symbols::VectorLifecycleError::MissingQdrantConfig
191 ));
192
193 let ctx = Context {
194 qdrant: Some(qdrant_config()),
195 ..make_ctx()
196 };
197 let err = lifecycle_from_resolved_embedding_source(&ctx, None)
198 .expect_err("missing embedding must fail");
199 assert!(matches!(
200 err,
201 code_symbols::VectorLifecycleError::MissingEmbeddingConfig
202 ));
203
204 lifecycle_from_resolved_embedding_source(&ctx, Some(daemon_embedding_source()))
205 .expect("daemon embedding source must satisfy lifecycle config");
206 }
207
208 #[test]
209 fn lifecycle_json_contract() {
210 let output = CodeSymbolVectorLifecycleOutput {
211 project_id: "project-1".to_string(),
212 collection: "gcode_code_symbols_project-1".to_string(),
213 action: CodeSymbolVectorLifecycleAction::SyncFile,
214 file_path: Some("src/lib.rs".to_string()),
215 symbols: 2,
216 vectors_upserted: 2,
217 delete_operations_issued: 1,
218 summary: "2 vector(s) upserted, 1 delete operation(s) issued".to_string(),
219 };
220
221 let payload = lifecycle_json_payload(
222 &output,
223 ProjectionSyncReport {
224 status: ProjectionStatus::Ok,
225 synced_files: 1,
226 synced_symbols: 2,
227 degraded: false,
228 error: None,
229 },
230 );
231 assert_eq!(
232 serde_json::to_value(&payload).expect("payload serializes"),
233 json!({
234 "project_id": "project-1",
235 "projection": "vector",
236 "action": "sync_file",
237 "file_path": "src/lib.rs",
238 "collection": "gcode_code_symbols_project-1",
239 "status": "ok",
240 "synced_files": 1,
241 "synced_symbols": 2,
242 "degraded": false,
243 "error": null,
244 "symbols": 2,
245 "vectors_upserted": 2,
246 "delete_operations_issued": 1,
247 "summary": "2 vector(s) upserted, 1 delete operation(s) issued"
248 })
249 );
250
251 let degraded = lifecycle_json_payload(
252 &output,
253 ProjectionSyncReport {
254 status: ProjectionStatus::Degraded,
255 synced_files: 0,
256 synced_symbols: 0,
257 degraded: true,
258 error: Some(ProjectionSyncError {
259 kind: "missing_qdrant_config".to_string(),
260 message: "Qdrant config is required".to_string(),
261 }),
262 },
263 );
264 let degraded = serde_json::to_value(°raded).expect("payload serializes");
265 assert_eq!(degraded["status"], "degraded");
266 assert_eq!(degraded["error"]["kind"], "missing_qdrant_config");
267 }
268}