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        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::{IndexDurations, IndexOutcome};
245    use crate::projection::sync::{
246        ProjectionStatus, ProjectionSyncError, ProjectionSyncReport, ProjectionSyncReports,
247    };
248    use serde_json::Value;
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
299        insta::assert_json_snapshot!("sync_projections_payload", payload);
300    }
301
302    #[test]
303    fn sync_projections_text_contract() {
304        let payload = sync_projections_payload(&sample_outcome(), sample_reports());
305        let text = sync_projections_text(&payload).expect("text payload");
306
307        insta::assert_snapshot!("sync_projections_text", text);
308    }
309
310    #[test]
311    fn index_outcome_json_contract_redacts_durations() {
312        let mut outcome = sample_outcome();
313        outcome.project_id = "project-1".to_string();
314        outcome.scanned_files = 14;
315        outcome.imports_indexed = 41;
316        outcome.calls_indexed = 73;
317        outcome.unresolved_targets_indexed = 5;
318        outcome.indexed_file_paths = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
319        outcome.durations = IndexDurations {
320            discovery_ms: 11,
321            indexing_ms: 22,
322            stats_ms: 33,
323            total_ms: 66,
324        };
325        let mut redacted = serde_json::to_value(outcome).expect("outcome serializes");
326        let Value::Object(durations) = &mut redacted["durations"] else {
327            panic!("durations serialize as object");
328        };
329        for field in ["discovery_ms", "indexing_ms", "stats_ms", "total_ms"] {
330            durations.insert(
331                field.to_string(),
332                Value::String("[duration-ms]".to_string()),
333            );
334        }
335
336        insta::assert_json_snapshot!("index_outcome", redacted);
337    }
338
339    #[test]
340    fn index_text_reports_unsupported_file_types() {
341        let mut outcome = sample_outcome();
342        outcome.unsupported_file_types = vec![
343            UnsupportedFileType {
344                extension: ".md".to_string(),
345                files: 1,
346                examples: vec!["README.md".to_string()],
347            },
348            UnsupportedFileType {
349                extension: ".txt".to_string(),
350                files: 2,
351                examples: vec!["notes.txt".to_string(), "docs/tasks.txt".to_string()],
352            },
353            UnsupportedFileType {
354                extension: "extensionless".to_string(),
355                files: 1,
356                examples: vec!["Dockerfile".to_string()],
357            },
358        ];
359
360        let text = index_text(&outcome);
361
362        insta::assert_snapshot!("index_text_unsupported_file_types", text);
363    }
364}