Skip to main content

gitcortex_indexer/
indexer.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::{Path, PathBuf},
5};
6
7use gitcortex_core::{
8    error::{GitCortexError, Result},
9    graph::{Edge, GraphDiff, Node, NodeId, NodeMetadata, Span},
10    schema::{EdgeConfidence, EdgeKind, NodeKind, Visibility},
11};
12use ignore::gitignore::{Gitignore, GitignoreBuilder};
13use rayon::prelude::*;
14
15use crate::{
16    differ::{Differ, FileChange},
17    parser::{parser_for_path, ParseResult},
18};
19
20type FileIndexResult = Result<(
21    GraphDiff,
22    Vec<(NodeId, String, u32)>, // deferred_calls
23    Vec<(NodeId, String)>,      // deferred_uses
24    Vec<(NodeId, String)>,      // deferred_implements
25    Vec<(NodeId, String)>,      // deferred_imports
26    Vec<(NodeId, String)>,      // deferred_inherits
27    Vec<(NodeId, String)>,      // deferred_throws
28    Vec<(NodeId, String)>,      // deferred_annotated
29    Vec<(NodeId, String)>,      // deferred_doc_refs
30)>;
31
32// ── IncrementalIndexer ────────────────────────────────────────────────────────
33
34/// Orchestrates the differ and parser to produce a `GraphDiff` from
35/// `last_sha..HEAD`. Stateless — callers own the repo root and last SHA.
36pub struct IncrementalIndexer {
37    repo_root: PathBuf,
38    ignorer: Gitignore,
39}
40
41impl IncrementalIndexer {
42    /// Build an indexer rooted at `repo_root`.
43    ///
44    /// Reads `.gitcortex/ignore` (if present) for exclusion patterns.
45    pub fn new(repo_root: &Path) -> Result<Self> {
46        let ignorer = build_ignorer(repo_root);
47        Ok(Self {
48            repo_root: repo_root.to_owned(),
49            ignorer,
50        })
51    }
52
53    /// Run an incremental index from `from_sha` (exclusive) to HEAD.
54    ///
55    /// Returns:
56    /// - `GraphDiff` — changes to apply to the store
57    /// - `String`    — the HEAD SHA to persist as `last_indexed_sha`
58    ///
59    /// When `from_sha` is `None` the entire HEAD tree is indexed (first run).
60    pub fn run(&self, from_sha: Option<&str>) -> Result<(GraphDiff, String)> {
61        let differ = Differ::open(&self.repo_root)?;
62        let head_sha = differ.head_sha()?;
63
64        if from_sha.map(|s| s == head_sha).unwrap_or(false) {
65            return Ok((GraphDiff::default(), head_sha));
66        }
67
68        let timing = std::env::var_os("GCX_TIMING").is_some();
69        let t0 = std::time::Instant::now();
70        macro_rules! mark {
71            ($label:expr) => {
72                if timing {
73                    eprintln!("[gcx-timing] {:>8.2?}  {}", t0.elapsed(), $label);
74                }
75            };
76        }
77
78        let supported = self.supported_extensions();
79        let changes = differ.changed_files(from_sha, &supported)?;
80
81        if changes.is_empty() {
82            return Ok((GraphDiff::default(), head_sha));
83        }
84
85        let (to_parse, to_delete): (Vec<_>, Vec<_>) = changes
86            .into_iter()
87            .partition(|c| !matches!(c, FileChange::Deleted(_)));
88        mark!(format!("diff: {} files to parse", to_parse.len()));
89
90        // Parse added/modified files in parallel.
91        let per_file: Vec<FileIndexResult> = to_parse
92            .par_iter()
93            .map(|change| self.index_file(change.path()))
94            .collect();
95        mark!("parse (parallel) done");
96
97        // Merge diffs and collect all deferred (cross-file) references.
98        let mut merged = GraphDiff::default();
99        let mut all_calls: Vec<(NodeId, String, u32)> = Vec::new();
100        let mut all_uses: Vec<(NodeId, String)> = Vec::new();
101        let mut all_implements: Vec<(NodeId, String)> = Vec::new();
102        let mut all_imports: Vec<(NodeId, String)> = Vec::new();
103        let mut all_inherits: Vec<(NodeId, String)> = Vec::new();
104        let mut all_throws: Vec<(NodeId, String)> = Vec::new();
105        let mut all_annotated: Vec<(NodeId, String)> = Vec::new();
106        let mut all_doc_refs: Vec<(NodeId, String)> = Vec::new();
107        for result in per_file {
108            let (diff, calls, uses, implements, imports, inherits, throws, annotated, doc_refs) =
109                result?;
110            merged.merge(diff);
111            all_calls.extend(calls);
112            all_uses.extend(uses);
113            all_implements.extend(implements);
114            all_imports.extend(imports);
115            all_inherits.extend(inherits);
116            all_throws.extend(throws);
117            all_annotated.extend(annotated);
118            all_doc_refs.extend(doc_refs);
119        }
120        mark!(format!(
121            "merge done: {} nodes, {} direct edges, {} deferred calls",
122            merged.added_nodes.len(),
123            merged.added_edges.len(),
124            all_calls.len()
125        ));
126
127        // Build name → NodeId index from all nodes added in this diff.
128        // Used to resolve all four deferred edge types.
129        let name_to_ids: HashMap<&str, Vec<&NodeId>> = {
130            let mut map: HashMap<&str, Vec<&NodeId>> = HashMap::new();
131            for node in &merged.added_nodes {
132                map.entry(node.name.as_str()).or_default().push(&node.id);
133            }
134            map
135        };
136
137        // Parallel id → file map. Used to constrain deferred resolution to the
138        // caller's language family — without this, a Java caller's deferred
139        // `greet` would be resolved to all `greet` symbols across every
140        // language in the diff, producing spurious cross-language edges.
141        let id_to_file: HashMap<&NodeId, &Path> = merged
142            .added_nodes
143            .iter()
144            .map(|n| (&n.id, n.file.as_path()))
145            .collect();
146
147        // File-level imported-names index: file_path → set of leaf names that
148        // are explicitly imported in that file. Used by `resolve_calls` and
149        // `resolve_deferred` to upgrade confidence from `Inferred` to `Resolved`
150        // when the callee/target was explicitly imported at the call site.
151        let imported_by_file: HashMap<&Path, HashSet<&str>> = {
152            let mut map: HashMap<&Path, HashSet<&str>> = HashMap::new();
153            for (mod_id, leaf_name) in &all_imports {
154                if let Some(&file) = id_to_file.get(mod_id) {
155                    map.entry(file).or_default().insert(leaf_name.as_str());
156                }
157            }
158            map
159        };
160
161        // Edge dedup set, seeded from the direct edges the parsers already
162        // emitted. `resolve_deferred` consults this instead of scanning the
163        // `added_edges` Vec — the old `Vec::contains` made resolution O(E²)
164        // and dominated full-index time on large repos.
165        let mut seen_edges: HashSet<(String, String, String)> = merged
166            .added_edges
167            .iter()
168            .map(|e| (e.src.as_str(), e.dst.as_str(), e.kind.to_string()))
169            .collect();
170
171        merged.deferred_calls = resolve_calls(
172            &name_to_ids,
173            &id_to_file,
174            &imported_by_file,
175            &all_calls,
176            &mut merged.added_edges,
177            &mut seen_edges,
178        );
179        merged.deferred_uses = resolve_deferred(
180            &name_to_ids,
181            &id_to_file,
182            &imported_by_file,
183            &all_uses,
184            EdgeKind::Uses,
185            &mut merged.added_edges,
186            &mut seen_edges,
187        );
188        merged.deferred_implements = resolve_deferred(
189            &name_to_ids,
190            &id_to_file,
191            &imported_by_file,
192            &all_implements,
193            EdgeKind::Implements,
194            &mut merged.added_edges,
195            &mut seen_edges,
196        );
197        // Imports use placeholder src IDs so we can't resolve them against the store;
198        // resolve what we can locally and silently drop the rest.
199        let _ = resolve_deferred(
200            &name_to_ids,
201            &id_to_file,
202            &imported_by_file,
203            &all_imports,
204            EdgeKind::Imports,
205            &mut merged.added_edges,
206            &mut seen_edges,
207        );
208        merged.deferred_inherits = resolve_deferred(
209            &name_to_ids,
210            &id_to_file,
211            &imported_by_file,
212            &all_inherits,
213            EdgeKind::Inherits,
214            &mut merged.added_edges,
215            &mut seen_edges,
216        );
217        merged.deferred_throws = resolve_deferred(
218            &name_to_ids,
219            &id_to_file,
220            &imported_by_file,
221            &all_throws,
222            EdgeKind::Throws,
223            &mut merged.added_edges,
224            &mut seen_edges,
225        );
226        merged.deferred_annotated = resolve_deferred(
227            &name_to_ids,
228            &id_to_file,
229            &imported_by_file,
230            &all_annotated,
231            EdgeKind::Annotated,
232            &mut merged.added_edges,
233            &mut seen_edges,
234        );
235        // Doc references are inherently cross-language (a Markdown file has
236        // no `language_extensions_for_path` match, so `resolve_deferred`'s
237        // language-scope filter is naturally a no-op here — no fan-out cap
238        // bypass needed, the existing MAX_RESOLVE_FANOUT guard still applies).
239        merged.deferred_doc_refs = resolve_deferred(
240            &name_to_ids,
241            &id_to_file,
242            &imported_by_file,
243            &all_doc_refs,
244            EdgeKind::References,
245            &mut merged.added_edges,
246            &mut seen_edges,
247        );
248        mark!(format!(
249            "resolve_deferred done: {} total edges",
250            merged.added_edges.len()
251        ));
252
253        // Mirror annotation NAMES onto each decorated node's metadata. The
254        // `Annotated` edge above only survives when the decorator is defined
255        // in-repo; most framework decorators (`@app.route`, `@Test`) are
256        // external and dropped. Storing the names as metadata keeps them
257        // queryable regardless.
258        {
259            let mut ann_by_id: HashMap<String, Vec<String>> = HashMap::new();
260            for (node_id, name) in &all_annotated {
261                ann_by_id
262                    .entry(node_id.as_str())
263                    .or_default()
264                    .push(name.clone());
265            }
266            for node in &mut merged.added_nodes {
267                if let Some(names) = ann_by_id.get(&node.id.as_str()) {
268                    let mut seen: HashSet<String> = HashSet::new();
269                    node.metadata.annotations = names
270                        .iter()
271                        .filter(|n| seen.insert((*n).clone()))
272                        .cloned()
273                        .collect();
274                }
275            }
276        }
277
278        for deleted in to_delete {
279            merged.removed_files.push(deleted.path().to_owned());
280        }
281
282        // Synthesise Folder and File structural nodes from the file paths of
283        // all nodes in this diff, then append them to the merged result.
284        let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
285        merged.added_nodes.extend(struct_nodes);
286        merged.added_edges.extend(struct_edges);
287        mark!("structural nodes done");
288
289        Ok((merged, head_sha))
290    }
291
292    /// Re-index specific files that changed on disk without requiring a git commit.
293    /// Used by the file-watcher in `gcx serve` to keep the graph current for
294    /// uncommitted edits.
295    pub fn index_files_from_disk(&self, abs_paths: &[PathBuf]) -> Result<GraphDiff> {
296        if abs_paths.is_empty() {
297            return Ok(GraphDiff::default());
298        }
299
300        let rel_paths: Vec<PathBuf> = abs_paths
301            .iter()
302            .filter_map(|p| p.strip_prefix(&self.repo_root).ok().map(|r| r.to_owned()))
303            .collect();
304
305        let per_file: Vec<FileIndexResult> =
306            rel_paths.par_iter().map(|p| self.index_file(p)).collect();
307
308        let mut merged = GraphDiff::default();
309        let mut all_calls: Vec<(NodeId, String, u32)> = Vec::new();
310        let mut all_uses: Vec<(NodeId, String)> = Vec::new();
311        let mut all_implements: Vec<(NodeId, String)> = Vec::new();
312        let mut all_imports: Vec<(NodeId, String)> = Vec::new();
313        let mut all_inherits: Vec<(NodeId, String)> = Vec::new();
314        let mut all_throws: Vec<(NodeId, String)> = Vec::new();
315        let mut all_annotated: Vec<(NodeId, String)> = Vec::new();
316        let mut all_doc_refs: Vec<(NodeId, String)> = Vec::new();
317        for result in per_file {
318            let (diff, calls, uses, implements, imports, inherits, throws, annotated, doc_refs) =
319                result?;
320            merged.merge(diff);
321            all_calls.extend(calls);
322            all_uses.extend(uses);
323            all_implements.extend(implements);
324            all_imports.extend(imports);
325            all_inherits.extend(inherits);
326            all_throws.extend(throws);
327            all_annotated.extend(annotated);
328            all_doc_refs.extend(doc_refs);
329        }
330
331        let name_to_ids: HashMap<&str, Vec<&NodeId>> = {
332            let mut map: HashMap<&str, Vec<&NodeId>> = HashMap::new();
333            for node in &merged.added_nodes {
334                map.entry(node.name.as_str()).or_default().push(&node.id);
335            }
336            map
337        };
338        let id_to_file: HashMap<&NodeId, &Path> = merged
339            .added_nodes
340            .iter()
341            .map(|n| (&n.id, n.file.as_path()))
342            .collect();
343        let imported_by_file: HashMap<&Path, HashSet<&str>> = {
344            let mut map: HashMap<&Path, HashSet<&str>> = HashMap::new();
345            for (mod_id, leaf_name) in &all_imports {
346                if let Some(&file) = id_to_file.get(mod_id) {
347                    map.entry(file).or_default().insert(leaf_name.as_str());
348                }
349            }
350            map
351        };
352        let mut seen_edges: HashSet<(String, String, String)> = merged
353            .added_edges
354            .iter()
355            .map(|e| (e.src.as_str(), e.dst.as_str(), e.kind.to_string()))
356            .collect();
357
358        merged.deferred_calls = resolve_calls(
359            &name_to_ids,
360            &id_to_file,
361            &imported_by_file,
362            &all_calls,
363            &mut merged.added_edges,
364            &mut seen_edges,
365        );
366        merged.deferred_uses = resolve_deferred(
367            &name_to_ids,
368            &id_to_file,
369            &imported_by_file,
370            &all_uses,
371            EdgeKind::Uses,
372            &mut merged.added_edges,
373            &mut seen_edges,
374        );
375        merged.deferred_implements = resolve_deferred(
376            &name_to_ids,
377            &id_to_file,
378            &imported_by_file,
379            &all_implements,
380            EdgeKind::Implements,
381            &mut merged.added_edges,
382            &mut seen_edges,
383        );
384        let _ = resolve_deferred(
385            &name_to_ids,
386            &id_to_file,
387            &imported_by_file,
388            &all_imports,
389            EdgeKind::Imports,
390            &mut merged.added_edges,
391            &mut seen_edges,
392        );
393        merged.deferred_inherits = resolve_deferred(
394            &name_to_ids,
395            &id_to_file,
396            &imported_by_file,
397            &all_inherits,
398            EdgeKind::Inherits,
399            &mut merged.added_edges,
400            &mut seen_edges,
401        );
402        merged.deferred_throws = resolve_deferred(
403            &name_to_ids,
404            &id_to_file,
405            &imported_by_file,
406            &all_throws,
407            EdgeKind::Throws,
408            &mut merged.added_edges,
409            &mut seen_edges,
410        );
411        merged.deferred_annotated = resolve_deferred(
412            &name_to_ids,
413            &id_to_file,
414            &imported_by_file,
415            &all_annotated,
416            EdgeKind::Annotated,
417            &mut merged.added_edges,
418            &mut seen_edges,
419        );
420        merged.deferred_doc_refs = resolve_deferred(
421            &name_to_ids,
422            &id_to_file,
423            &imported_by_file,
424            &all_doc_refs,
425            EdgeKind::References,
426            &mut merged.added_edges,
427            &mut seen_edges,
428        );
429
430        let mut ann_by_id: HashMap<String, Vec<String>> = HashMap::new();
431        for (node_id, name) in &all_annotated {
432            ann_by_id
433                .entry(node_id.as_str())
434                .or_default()
435                .push(name.clone());
436        }
437        for node in &mut merged.added_nodes {
438            if let Some(names) = ann_by_id.get(&node.id.as_str()) {
439                let mut seen: HashSet<String> = HashSet::new();
440                node.metadata.annotations = names
441                    .iter()
442                    .filter(|n| seen.insert((*n).clone()))
443                    .cloned()
444                    .collect();
445            }
446        }
447
448        // Remove stale nodes for these files before the store applies additions.
449        for p in abs_paths {
450            if let Ok(rel) = p.strip_prefix(&self.repo_root) {
451                merged.removed_files.push(rel.to_owned());
452            }
453        }
454
455        let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
456        merged.added_nodes.extend(struct_nodes);
457        merged.added_edges.extend(struct_edges);
458
459        Ok(merged)
460    }
461
462    // ── Private helpers ───────────────────────────────────────────────────────
463
464    fn supported_extensions(&self) -> Vec<&'static str> {
465        vec![
466            "rs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go", "java", "md", "markdown",
467        ]
468    }
469
470    fn index_file(&self, repo_relative_path: &Path) -> FileIndexResult {
471        let abs_path = self.repo_root.join(repo_relative_path);
472        let empty = || {
473            (
474                GraphDiff::default(),
475                Vec::<(NodeId, String, u32)>::new(),
476                Vec::<(NodeId, String)>::new(),
477                Vec::<(NodeId, String)>::new(),
478                Vec::<(NodeId, String)>::new(),
479                Vec::<(NodeId, String)>::new(),
480                Vec::<(NodeId, String)>::new(),
481                Vec::<(NodeId, String)>::new(),
482                Vec::<(NodeId, String)>::new(),
483            )
484        };
485
486        if self.should_ignore(repo_relative_path) {
487            return Ok(empty());
488        }
489
490        let source = fs::read_to_string(&abs_path).map_err(|e| GitCortexError::Parse {
491            file: abs_path.clone(),
492            message: e.to_string(),
493        })?;
494
495        if source.len() > 512 * 1024 {
496            return Ok(empty());
497        }
498
499        let parser = match parser_for_path(repo_relative_path) {
500            Some(p) => p,
501            None => return Ok(empty()),
502        };
503
504        let ParseResult {
505            nodes,
506            edges,
507            deferred_calls,
508            deferred_uses,
509            deferred_implements,
510            deferred_imports,
511            deferred_inherits,
512            deferred_throws,
513            deferred_annotated,
514            deferred_doc_refs,
515        } = parser.parse(repo_relative_path, &source)?;
516
517        let mut diff = GraphDiff::default();
518        // Remove old code nodes for this file and old structural nodes for
519        // every ancestor folder (they'll be re-synthesised after merging).
520        diff.removed_files.push(repo_relative_path.to_owned());
521        for ancestor in repo_relative_path.ancestors().skip(1) {
522            if ancestor == Path::new("") || ancestor == Path::new(".") {
523                break;
524            }
525            diff.removed_files.push(ancestor.to_path_buf());
526        }
527        diff.added_nodes = nodes;
528        diff.added_edges = edges;
529
530        Ok((
531            diff,
532            deferred_calls,
533            deferred_uses,
534            deferred_implements,
535            deferred_imports,
536            deferred_inherits,
537            deferred_throws,
538            deferred_annotated,
539            deferred_doc_refs,
540        ))
541    }
542
543    fn should_ignore(&self, path: &Path) -> bool {
544        self.ignorer
545            .matched_path_or_any_parents(path, false)
546            .is_ignore()
547    }
548}
549
550// ── Helpers ───────────────────────────────────────────────────────────────────
551
552/// When a name resolves to more than this many same-language definitions, it's
553/// treated as ambiguous and produces NO edges. Hot names like `get`, `save`,
554/// `__init__`, `filter` have hundreds of definitions; linking a call site to
555/// every one of them is both useless (no precision) and the source of an
556/// edge explosion that made full indexing O(call_sites × defs). Skipping the
557/// over-ambiguous names keeps the precise majority and drops the noise.
558const MAX_RESOLVE_FANOUT: usize = 8;
559
560/// Resolve deferred `(src_id, target_name)` pairs against a diff-local
561/// name→NodeId map. Returns the subset that couldn't be resolved (because the
562/// target lives in an unchanged file not present in this diff). The caller
563/// stores the unresolved entries in `GraphDiff.deferred_*` so the store can
564/// resolve them against its full existing database.
565///
566/// Candidate destinations are filtered to the same language family as the
567/// source (by file extension) — otherwise a Java caller's deferred `greet`
568/// would resolve to every `greet` symbol across all languages in the diff.
569///
570/// `seen` dedups edges in O(1); the previous `Vec::contains` made this O(E²)
571/// and dominated full-index time on large repos.
572fn resolve_deferred(
573    name_to_ids: &HashMap<&str, Vec<&NodeId>>,
574    id_to_file: &HashMap<&NodeId, &Path>,
575    imported_by_file: &HashMap<&Path, HashSet<&str>>,
576    deferred: &[(NodeId, String)],
577    kind: EdgeKind,
578    edges: &mut Vec<Edge>,
579    seen: &mut HashSet<(String, String, String)>,
580) -> Vec<(NodeId, String)> {
581    let mut unresolved = Vec::new();
582    for (src_id, target_name) in deferred {
583        let src_file = id_to_file.get(src_id).copied();
584        let src_exts = src_file.and_then(language_extensions_for_path);
585
586        let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
587            unresolved.push((src_id.clone(), target_name.clone()));
588            continue;
589        };
590
591        // Same-language candidates only.
592        let candidates: Vec<&NodeId> = dst_ids
593            .iter()
594            .copied()
595            .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
596                (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
597                    .map(|dst_exts| dst_exts.first() == exts.first())
598                    .unwrap_or(true),
599                _ => true,
600            })
601            .collect();
602
603        // Over-ambiguous name: don't fan out. Treat as resolved (not pushed to
604        // `unresolved`) so the store doesn't retry the same explosive match.
605        if candidates.len() > MAX_RESOLVE_FANOUT {
606            continue;
607        }
608
609        // Promote to Resolved when the target was explicitly imported in this file.
610        let confidence = src_file
611            .and_then(|f| imported_by_file.get(f))
612            .map(|imports| {
613                if imports.contains(target_name.as_str()) {
614                    EdgeConfidence::Resolved
615                } else {
616                    EdgeConfidence::Inferred
617                }
618            })
619            .unwrap_or(EdgeConfidence::Inferred);
620
621        let mut matched_any = false;
622        for dst_id in candidates {
623            matched_any = true;
624            let key = (src_id.as_str(), dst_id.as_str(), kind.to_string());
625            if seen.insert(key) {
626                edges.push(
627                    Edge::new(src_id.clone(), dst_id.clone(), kind.clone())
628                        .with_confidence(confidence),
629                );
630            }
631        }
632        if !matched_any {
633            unresolved.push((src_id.clone(), target_name.clone()));
634        }
635    }
636    unresolved
637}
638
639/// Like [`resolve_deferred`] but for `Calls` edges, carrying each call's source
640/// line onto the resulting edge. Unresolved calls are returned as
641/// `(caller_id, callee_name, line)` so the store can resolve them later and
642/// still record the line.
643fn resolve_calls(
644    name_to_ids: &HashMap<&str, Vec<&NodeId>>,
645    id_to_file: &HashMap<&NodeId, &Path>,
646    imported_by_file: &HashMap<&Path, HashSet<&str>>,
647    deferred: &[(NodeId, String, u32)],
648    edges: &mut Vec<Edge>,
649    seen: &mut HashSet<(String, String, String)>,
650) -> Vec<(NodeId, String, u32)> {
651    let mut unresolved = Vec::new();
652    for (src_id, target_name, line) in deferred {
653        let src_file = id_to_file.get(src_id).copied();
654        let src_exts = src_file.and_then(language_extensions_for_path);
655
656        let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
657            unresolved.push((src_id.clone(), target_name.clone(), *line));
658            continue;
659        };
660
661        let candidates: Vec<&NodeId> = dst_ids
662            .iter()
663            .copied()
664            .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
665                (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
666                    .map(|dst_exts| dst_exts.first() == exts.first())
667                    .unwrap_or(true),
668                _ => true,
669            })
670            .collect();
671
672        if candidates.len() > MAX_RESOLVE_FANOUT {
673            continue;
674        }
675
676        // Promote to Resolved when the callee was explicitly imported in this file.
677        let confidence = src_file
678            .and_then(|f| imported_by_file.get(f))
679            .map(|imports| {
680                if imports.contains(target_name.as_str()) {
681                    EdgeConfidence::Resolved
682                } else {
683                    EdgeConfidence::Inferred
684                }
685            })
686            .unwrap_or(EdgeConfidence::Inferred);
687
688        let mut matched_any = false;
689        for dst_id in candidates {
690            matched_any = true;
691            let key = (
692                src_id.as_str(),
693                dst_id.as_str(),
694                EdgeKind::Calls.to_string(),
695            );
696            if seen.insert(key) {
697                edges.push(
698                    Edge::call(src_id.clone(), dst_id.clone(), *line).with_confidence(confidence),
699                );
700            }
701        }
702        if !matched_any {
703            unresolved.push((src_id.clone(), target_name.clone(), *line));
704        }
705    }
706    unresolved
707}
708
709/// File extension family for `path`. Mirrors the store-side helper — kept
710/// duplicated to avoid pulling kuzu into the indexer.
711fn language_extensions_for_path(path: &Path) -> Option<&'static [&'static str]> {
712    let ext = path.extension()?.to_str()?;
713    match ext {
714        "rs" => Some(&[".rs"]),
715        "py" => Some(&[".py"]),
716        "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
717            Some(&[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"])
718        }
719        "go" => Some(&[".go"]),
720        "java" => Some(&[".java"]),
721        _ => None,
722    }
723}
724
725/// Derives `Folder` and `File` structural nodes from the code nodes already in
726/// `diff`. For each unique file path seen in `diff.added_nodes`:
727///   - Creates one `File` node (kind = File, file = the_file_path).
728///   - Creates `Folder` nodes for every ancestor directory component.
729///   - Adds Contains edges: folder→subfolder, folder→file, file→top-level nodes.
730///
731/// Top-level nodes are code nodes that are NOT already the `dst` of a Contains
732/// edge within the same file (i.e. not already contained by a module/struct).
733fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
734    use std::collections::HashSet;
735
736    // Which code node IDs already have an incoming Contains edge?
737    let contained: HashSet<&NodeId> = diff
738        .added_edges
739        .iter()
740        .filter(|e| e.kind == EdgeKind::Contains)
741        .map(|e| &e.dst)
742        .collect();
743
744    // Group code nodes by file path.
745    let mut by_file: HashMap<&Path, Vec<&Node>> = HashMap::new();
746    for node in &diff.added_nodes {
747        by_file.entry(node.file.as_path()).or_default().push(node);
748    }
749
750    let mut new_nodes: Vec<Node> = Vec::new();
751    let mut new_edges: Vec<Edge> = Vec::new();
752    // folder path → NodeId (so we can build hierarchy edges)
753    let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
754
755    // ── File nodes ────────────────────────────────────────────────────────────
756    let mut file_ids: HashMap<&Path, NodeId> = HashMap::new();
757    for (file_path, code_nodes) in &by_file {
758        let file_id = NodeId::new();
759        let name = file_path
760            .file_name()
761            .and_then(|n| n.to_str())
762            .unwrap_or("")
763            .to_owned();
764        new_nodes.push(Node {
765            id: file_id.clone(),
766            name,
767            kind: NodeKind::File,
768            qualified_name: file_path.to_string_lossy().into_owned(),
769            file: file_path.to_path_buf(),
770            span: Span {
771                start_line: 1,
772                end_line: 1,
773            },
774            metadata: NodeMetadata {
775                visibility: Visibility::Pub,
776                ..Default::default()
777            },
778        });
779        file_ids.insert(file_path, file_id.clone());
780
781        // File → top-level code node Contains edges.
782        for node in code_nodes {
783            if !contained.contains(&node.id) {
784                new_edges.push(Edge {
785                    src: file_id.clone(),
786                    dst: node.id.clone(),
787                    kind: EdgeKind::Contains,
788                    line: None,
789                    confidence: EdgeConfidence::Extracted,
790                });
791            }
792        }
793    }
794
795    // ── Folder nodes (collect unique ancestor directories) ────────────────────
796    let unique_dirs: HashSet<PathBuf> = by_file
797        .keys()
798        .flat_map(|p| p.ancestors().skip(1))
799        .filter(|p| *p != Path::new("") && *p != Path::new("."))
800        .map(|p| p.to_path_buf())
801        .collect();
802
803    for dir in &unique_dirs {
804        let dir_id = folder_ids.entry(dir.clone()).or_default().clone();
805        let name = dir
806            .file_name()
807            .and_then(|n| n.to_str())
808            .unwrap_or(dir.to_str().unwrap_or(""))
809            .to_owned();
810        new_nodes.push(Node {
811            id: dir_id,
812            name,
813            kind: NodeKind::Folder,
814            qualified_name: dir.to_string_lossy().into_owned(),
815            file: dir.clone(),
816            span: Span {
817                start_line: 1,
818                end_line: 1,
819            },
820            metadata: NodeMetadata {
821                visibility: Visibility::Pub,
822                ..Default::default()
823            },
824        });
825    }
826
827    // ── Folder→File and Folder→Folder Contains edges ──────────────────────────
828    for (file_path, file_id) in &file_ids {
829        if let Some(parent) = file_path.parent().filter(|p| *p != Path::new("")) {
830            if let Some(dir_id) = folder_ids.get(parent) {
831                new_edges.push(Edge {
832                    src: dir_id.clone(),
833                    dst: file_id.clone(),
834                    kind: EdgeKind::Contains,
835                    line: None,
836                    confidence: EdgeConfidence::Extracted,
837                });
838            }
839        }
840    }
841    for dir in &unique_dirs {
842        let child_id = folder_ids[dir].clone();
843        if let Some(parent) = dir
844            .parent()
845            .filter(|p| *p != Path::new("") && *p != Path::new("."))
846        {
847            if let Some(parent_id) = folder_ids.get(parent) {
848                new_edges.push(Edge {
849                    src: parent_id.clone(),
850                    dst: child_id,
851                    kind: EdgeKind::Contains,
852                    line: None,
853                    confidence: EdgeConfidence::Extracted,
854                });
855            }
856        }
857    }
858
859    (new_nodes, new_edges)
860}
861
862fn build_ignorer(repo_root: &Path) -> Gitignore {
863    let ignore_path = repo_root.join(".gitcortex/ignore");
864    let mut builder = GitignoreBuilder::new(repo_root);
865    if ignore_path.exists() {
866        let _ = builder.add(ignore_path);
867    }
868    builder.build().unwrap_or_else(|_| Gitignore::empty())
869}