1use crate::config;
2use crate::config::Context;
3use crate::index::api::{self, IndexDegradation, IndexOutcome, IndexRequest, UnsupportedFileType};
4use crate::index_lock::{self, IndexLockPolicy, IndexLockResult};
5use crate::output::{self, Format};
6use crate::projection::sync::{self, ProjectionSyncReports};
7use crate::utils::short_id;
8use serde::Serialize;
9
10pub fn run(
11 ctx: &Context,
12 path: Option<String>,
13 files: Option<Vec<String>>,
14 full: bool,
15 require_cpp_semantics: bool,
16 sync_projections: bool,
17 format: Format,
18) -> anyhow::Result<()> {
19 let (target_ctx, path_filter) = resolve_index_context(ctx, path.as_deref())?;
20 let explicit_files: Vec<std::path::PathBuf> = files
21 .unwrap_or_default()
22 .into_iter()
23 .map(std::path::PathBuf::from)
24 .collect();
25 let request = IndexRequest {
26 project_root: target_ctx.project_root.clone(),
27 path_filter: if explicit_files.is_empty() {
28 path_filter
29 } else {
30 None
31 },
32 explicit_files,
33 full,
34 require_cpp_semantics,
35 sync_projections,
36 };
37
38 let outcome = match index_lock::with_project_lock(&target_ctx, IndexLockPolicy::Wait, || {
39 api::index_files(request, &target_ctx)
40 })? {
41 IndexLockResult::Acquired(outcome) => outcome,
42 IndexLockResult::Busy => anyhow::bail!(
43 "index lock is busy for project {}; wait policy did not acquire it",
44 target_ctx.project_id
45 ),
46 };
47 if sync_projections {
48 let projections = sync::sync_after_index(&target_ctx, &outcome.indexed_file_paths)?;
49 let payload = sync_projections_payload(&outcome, projections);
50 return match format {
51 Format::Json => output::print_json(&payload),
52 Format::Text => output::print_text(&sync_projections_text(&payload)?),
53 };
54 }
55
56 match format {
57 Format::Json => output::print_json(&outcome),
58 Format::Text => output::print_text(&index_text(&outcome)),
59 }
60}
61
62fn index_text(outcome: &IndexOutcome) -> String {
63 let mut text = format!(
64 "Indexed {} files ({} skipped), {} symbols, {} chunks in {}ms",
65 outcome.indexed_files,
66 outcome.skipped_files,
67 outcome.symbols_indexed,
68 outcome.chunks_indexed,
69 outcome.durations.total_ms
70 );
71
72 if !outcome.unsupported_file_types.is_empty() {
73 text.push_str("\nUnsupported file types indexed as text only (no AST symbols):");
74 for file_type in &outcome.unsupported_file_types {
75 text.push_str(&format!(
76 "\n {}: {} {}",
77 file_type.extension,
78 file_type.files,
79 pluralize(file_type.files, "file")
80 ));
81 if !file_type.examples.is_empty() {
82 text.push_str(&format!(
83 " ({}: {})",
84 pluralize(file_type.examples.len(), "example"),
85 file_type.examples.join(", ")
86 ));
87 }
88 }
89 }
90
91 text
92}
93
94fn pluralize(count: usize, singular: &str) -> &str {
97 match (count, singular) {
98 (1, "file") => "file",
99 (_, "file") => "files",
100 (1, "example") => "example",
101 (_, "example") => "examples",
102 _ => singular,
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub(crate) struct IndexSyncProjectionsOutput {
108 pub indexed_files: usize,
109 pub skipped_files: usize,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub unsupported_file_types: Vec<UnsupportedFileType>,
112 pub symbols_indexed: usize,
113 pub chunks_indexed: usize,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub degraded: Vec<IndexDegradation>,
116 pub projections: ProjectionSyncReports,
117}
118
119pub(crate) fn sync_projections_payload(
120 outcome: &IndexOutcome,
121 projections: ProjectionSyncReports,
122) -> IndexSyncProjectionsOutput {
123 IndexSyncProjectionsOutput {
124 indexed_files: outcome.indexed_files,
125 skipped_files: outcome.skipped_files,
126 unsupported_file_types: outcome.unsupported_file_types.clone(),
127 symbols_indexed: outcome.symbols_indexed,
128 chunks_indexed: outcome.chunks_indexed,
129 degraded: outcome.degraded.clone(),
130 projections,
131 }
132}
133
134pub(crate) fn sync_projections_text(
135 payload: &IndexSyncProjectionsOutput,
136) -> anyhow::Result<String> {
137 Ok(serde_json::to_string(payload)?)
138}
139
140fn resolve_index_context(
141 ctx: &Context,
142 path: Option<&str>,
143) -> anyhow::Result<(Context, Option<std::path::PathBuf>)> {
144 let Some(p) = path else {
145 return Ok((
146 clone_context(
147 ctx,
148 ctx.project_root.clone(),
149 ctx.project_id.clone(),
150 ctx.index_scope.clone(),
151 ),
152 None,
153 ));
154 };
155
156 let target = std::path::PathBuf::from(p);
159 let target_root = crate::config::detect_project_root_from(&target)?;
160 let target_filter = path_filter_for(&target_root, &target);
161 if target_root != ctx.project_root {
162 let identity = crate::config::resolve_project_identity(
163 &target_root,
164 crate::config::MissingIdentity::Generate,
165 )?;
166 crate::config::warn_project_identity(&identity, ctx.quiet);
167 if !ctx.quiet {
168 eprintln!(
169 "Warning: path '{}' belongs to project {} (not {}), re-resolving context",
170 p,
171 short_id(&identity.project_id),
172 short_id(&ctx.project_id)
173 );
174 }
175 if identity.should_write_gcode_json {
176 crate::project::ensure_gcode_json(&target_root)?;
177 }
178 let mut conn = crate::db::connect_readonly(&ctx.database_url)?;
179 crate::config::validate_parent_code_index(&mut conn, &identity.index_scope)?;
180 Ok((
181 clone_context(ctx, target_root, identity.project_id, identity.index_scope),
182 target_filter,
183 ))
184 } else {
185 Ok((
186 clone_context(
187 ctx,
188 target_root,
189 ctx.project_id.clone(),
190 ctx.index_scope.clone(),
191 ),
192 target_filter,
193 ))
194 }
195}
196
197fn clone_context(
198 ctx: &Context,
199 project_root: std::path::PathBuf,
200 project_id: String,
201 index_scope: config::ProjectIndexScope,
202) -> Context {
203 config::Context {
204 database_url: ctx.database_url.clone(),
205 project_root,
206 project_id,
207 quiet: ctx.quiet,
208 falkordb: ctx.falkordb.clone(),
209 qdrant: ctx.qdrant.clone(),
210 embedding: ctx.embedding.clone(),
211 code_vectors: ctx.code_vectors.clone(),
212 daemon_url: ctx.daemon_url.clone(),
213 index_scope,
214 }
215}
216
217fn path_filter_for(
218 project_root: &std::path::Path,
219 target: &std::path::Path,
220) -> Option<std::path::PathBuf> {
221 let target_abs = if target.is_absolute() {
222 target.to_path_buf()
223 } else {
224 std::env::current_dir()
225 .map(|cwd| cwd.join(target))
226 .unwrap_or_else(|_| project_root.join(target))
227 };
228
229 let root_abs = project_root
230 .canonicalize()
231 .unwrap_or_else(|_| project_root.to_path_buf());
232 let target_abs = target_abs.canonicalize().unwrap_or(target_abs);
233
234 if target_abs == root_abs {
235 None
236 } else {
237 Some(target_abs)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::index::api::IndexOutcome;
245 use crate::projection::sync::{
246 ProjectionStatus, ProjectionSyncError, ProjectionSyncReport, ProjectionSyncReports,
247 };
248 use serde_json::{Value, json};
249
250 #[test]
251 fn pluralize_handles_index_status_nouns() {
252 assert_eq!(pluralize(1, "file"), "file");
253 assert_eq!(pluralize(2, "file"), "files");
254 assert_eq!(pluralize(1, "example"), "example");
255 assert_eq!(pluralize(0, "example"), "examples");
256 }
257
258 #[test]
259 fn pluralize_leaves_unknown_nouns_unchanged() {
260 assert_eq!(pluralize(2, "symbol"), "symbol");
261 }
262
263 fn sample_outcome() -> IndexOutcome {
264 IndexOutcome {
265 indexed_files: 12,
266 skipped_files: 0,
267 symbols_indexed: 348,
268 chunks_indexed: 921,
269 ..IndexOutcome::default()
270 }
271 }
272
273 fn sample_reports() -> ProjectionSyncReports {
274 ProjectionSyncReports {
275 graph: ProjectionSyncReport {
276 status: ProjectionStatus::Ok,
277 synced_files: 12,
278 synced_symbols: 348,
279 degraded: false,
280 error: None,
281 },
282 vector: ProjectionSyncReport {
283 status: ProjectionStatus::Degraded,
284 synced_files: 0,
285 synced_symbols: 0,
286 degraded: true,
287 error: Some(ProjectionSyncError {
288 kind: "missing_qdrant_config".to_string(),
289 message: "Qdrant config is required".to_string(),
290 }),
291 },
292 }
293 }
294
295 #[test]
296 fn sync_projections_json_contract() {
297 let payload = sync_projections_payload(&sample_outcome(), sample_reports());
298 assert_eq!(
299 serde_json::to_value(&payload).expect("payload serializes"),
300 json!({
301 "indexed_files": 12,
302 "skipped_files": 0,
303 "symbols_indexed": 348,
304 "chunks_indexed": 921,
305 "projections": {
306 "graph": {
307 "status": "ok",
308 "synced_files": 12,
309 "synced_symbols": 348,
310 "degraded": false,
311 "error": null
312 },
313 "vector": {
314 "status": "degraded",
315 "synced_files": 0,
316 "synced_symbols": 0,
317 "degraded": true,
318 "error": {
319 "kind": "missing_qdrant_config",
320 "message": "Qdrant config is required"
321 }
322 }
323 }
324 })
325 );
326 }
327
328 #[test]
329 fn sync_projections_text_contract() {
330 let payload = sync_projections_payload(&sample_outcome(), sample_reports());
331 let text = sync_projections_text(&payload).expect("text payload");
332 let parsed: Value = serde_json::from_str(&text).expect("text mode is structured JSON");
333 assert_eq!(parsed["indexed_files"], 12);
334 assert_eq!(parsed["projections"]["graph"]["status"], "ok");
335 assert_eq!(parsed["projections"]["vector"]["status"], "degraded");
336 }
337
338 #[test]
339 fn index_text_reports_unsupported_file_types() {
340 let mut outcome = sample_outcome();
341 outcome.unsupported_file_types = vec![
342 UnsupportedFileType {
343 extension: ".md".to_string(),
344 files: 1,
345 examples: vec!["README.md".to_string()],
346 },
347 UnsupportedFileType {
348 extension: ".txt".to_string(),
349 files: 2,
350 examples: vec!["notes.txt".to_string(), "docs/tasks.txt".to_string()],
351 },
352 UnsupportedFileType {
353 extension: "extensionless".to_string(),
354 files: 1,
355 examples: vec!["Dockerfile".to_string()],
356 },
357 ];
358
359 let text = index_text(&outcome);
360
361 assert!(text.contains("Unsupported file types indexed as text only"));
362 assert!(text.contains(".md: 1 file (example: README.md)"));
363 assert!(text.contains(".txt: 2 files (examples: notes.txt, docs/tasks.txt)"));
364 assert!(text.contains("extensionless: 1 file (example: Dockerfile)"));
365 }
366}