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