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 indexing: ctx.indexing,
213 daemon_url: ctx.daemon_url.clone(),
214 index_scope,
215 }
216}
217
218fn path_filter_for(
219 project_root: &std::path::Path,
220 target: &std::path::Path,
221) -> Option<std::path::PathBuf> {
222 let target_abs = if target.is_absolute() {
223 target.to_path_buf()
224 } else {
225 std::env::current_dir()
226 .map(|cwd| cwd.join(target))
227 .unwrap_or_else(|_| project_root.join(target))
228 };
229
230 let root_abs = project_root
231 .canonicalize()
232 .unwrap_or_else(|_| project_root.to_path_buf());
233 let target_abs = target_abs.canonicalize().unwrap_or(target_abs);
234
235 if target_abs == root_abs {
236 None
237 } else {
238 Some(target_abs)
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::index::api::{IndexDurations, IndexOutcome};
246 use crate::projection::sync::{
247 ProjectionStatus, ProjectionSyncError, ProjectionSyncReport, ProjectionSyncReports,
248 };
249 use serde_json::Value;
250
251 #[test]
252 fn pluralize_handles_index_status_nouns() {
253 assert_eq!(pluralize(1, "file"), "file");
254 assert_eq!(pluralize(2, "file"), "files");
255 assert_eq!(pluralize(1, "example"), "example");
256 assert_eq!(pluralize(0, "example"), "examples");
257 }
258
259 #[test]
260 fn pluralize_leaves_unknown_nouns_unchanged() {
261 assert_eq!(pluralize(2, "symbol"), "symbol");
262 }
263
264 fn sample_outcome() -> IndexOutcome {
265 IndexOutcome {
266 indexed_files: 12,
267 skipped_files: 0,
268 symbols_indexed: 348,
269 chunks_indexed: 921,
270 ..IndexOutcome::default()
271 }
272 }
273
274 fn sample_reports() -> ProjectionSyncReports {
275 ProjectionSyncReports {
276 graph: ProjectionSyncReport {
277 status: ProjectionStatus::Ok,
278 synced_files: 12,
279 synced_symbols: 348,
280 skipped_files: 1,
281 failed_files: 0,
282 degraded: false,
283 error: None,
284 },
285 vector: ProjectionSyncReport {
286 status: ProjectionStatus::Degraded,
287 synced_files: 0,
288 synced_symbols: 0,
289 skipped_files: 0,
290 failed_files: 0,
291 degraded: true,
292 error: Some(ProjectionSyncError {
293 kind: "missing_qdrant_config".to_string(),
294 message: "Qdrant config is required".to_string(),
295 }),
296 },
297 }
298 }
299
300 #[test]
301 fn sync_projections_json_contract() {
302 let payload = sync_projections_payload(&sample_outcome(), sample_reports());
303
304 insta::assert_json_snapshot!("sync_projections_payload", payload);
305 }
306
307 #[test]
308 fn sync_projections_text_contract() {
309 let payload = sync_projections_payload(&sample_outcome(), sample_reports());
310 let text = sync_projections_text(&payload).expect("text payload");
311
312 insta::assert_snapshot!("sync_projections_text", text);
313 }
314
315 #[test]
316 fn index_outcome_json_contract_redacts_durations() {
317 let mut outcome = sample_outcome();
318 outcome.project_id = "project-1".to_string();
319 outcome.scanned_files = 14;
320 outcome.imports_indexed = 41;
321 outcome.calls_indexed = 73;
322 outcome.unresolved_targets_indexed = 5;
323 outcome.indexed_file_paths = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
324 outcome.durations = IndexDurations {
325 discovery_ms: 11,
326 indexing_ms: 22,
327 stats_ms: 33,
328 total_ms: 66,
329 };
330 let mut redacted = serde_json::to_value(outcome).expect("outcome serializes");
331 let Value::Object(durations) = &mut redacted["durations"] else {
332 panic!("durations serialize as object");
333 };
334 for field in ["discovery_ms", "indexing_ms", "stats_ms", "total_ms"] {
335 durations.insert(
336 field.to_string(),
337 Value::String("[duration-ms]".to_string()),
338 );
339 }
340
341 insta::assert_json_snapshot!("index_outcome", redacted);
342 }
343
344 #[test]
345 fn index_text_reports_unsupported_file_types() {
346 let mut outcome = sample_outcome();
347 outcome.unsupported_file_types = vec![
348 UnsupportedFileType {
349 extension: ".md".to_string(),
350 files: 1,
351 examples: vec!["README.md".to_string()],
352 },
353 UnsupportedFileType {
354 extension: ".txt".to_string(),
355 files: 2,
356 examples: vec!["notes.txt".to_string(), "docs/tasks.txt".to_string()],
357 },
358 UnsupportedFileType {
359 extension: "extensionless".to_string(),
360 files: 1,
361 examples: vec!["Dockerfile".to_string()],
362 },
363 ];
364
365 let text = index_text(&outcome);
366
367 insta::assert_snapshot!("index_text_unsupported_file_types", text);
368 }
369}