Skip to main content

gobby_code/commands/
vector.rs

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(&degraded).expect("payload serializes");
380        assert_eq!(degraded["status"], "degraded");
381        assert_eq!(degraded["error"]["kind"], "missing_qdrant_config");
382    }
383}