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(°raded).expect("payload serializes");
232 assert_eq!(degraded["status"], "degraded");
233 assert_eq!(degraded["error"]["kind"], "missing_qdrant_config");
234 }
235}