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::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(&degraded).expect("payload serializes");
265        assert_eq!(degraded["status"], "degraded");
266        assert_eq!(degraded["error"]["kind"], "missing_qdrant_config");
267    }
268}