Skip to main content

gobby_code/commands/
index.rs

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
94/// Pluralizes only the status nouns emitted by this command; unknown nouns are
95/// returned unchanged so callers opt in deliberately.
96fn 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    // Resolve root and project_id. If the path belongs to a different project
157    // than the CWD-derived context, re-resolve identity for that project.
158    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}